From 35c76398f87c3e2d78575c7dacd588b7765ded47 Mon Sep 17 00:00:00 2001 From: Michael Fox Date: Sun, 4 Oct 2026 23:16:13 -0400 Subject: [PATCH] Document and expose API warnings Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + README.md | 43 +++++ src/geocodio/client.py | 33 +++- src/geocodio/exceptions.py | 24 ++- src/geocodio/models.py | 32 ++++ tests/unit/test_warnings.py | 364 ++++++++++++++++++++++++++++++++++++ 6 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_warnings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index abdf664..7f05bdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Warnings support: the API's `_warnings` response key is now parsed into a `warnings` list on `GeocodingResponse`, `GeocodingResult`, `ListResponse`, `DistanceJobResponse` and `PaginatedResponse` (empty when the API sent none). Previously the key was only reachable through `GeocodingResponse.raw`, and was dropped entirely for list and distance matrix job responses. For batch requests, `GeocodingResult.warnings` includes the warnings attached to that query and `GeocodingResponse.warnings` holds the de-duplicated set across all queries. +- `GeocodioError.warnings` (and `GeocodioErrorDetail.warnings`), exposing warnings attached to error responses. + ## [1.4.0] - 2026-08-28 ### Added diff --git a/README.md b/README.md index b910605..3e15b70 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,49 @@ response.rate_limit.reset # unix timestamp, when sent by the API client.rate_limit # the most recent rate limit state seen ``` +### Warnings + +The API reports non-fatal advisories under a `_warnings` key — a misspelled +field name, an unexpected query parameter, a superseded API version, or an +append that had to be skipped. The request still succeeds, so nothing is raised +or logged; the warnings are parsed onto the response as a `warnings` list, which +is empty when the API sent none: + +```python +response = client.geocode("1109 N Highland St, Arlington VA", fields=["congress"]) + +for warning in response.warnings: + print(warning) + # "The field congress is not recognized. Did you mean cd?" +``` + +Warnings show up in a few places, depending on what raised them: + +| Where | Applies to | +| -- | -- | +| `response.warnings` | Single `geocode()` and `reverse()`. For batch requests, the de-duplicated warnings from every query | +| `response.results[i].warnings` | An individual result, e.g. an `ffiec` append skipped because the match is not street-level. For batch requests, this also includes the warnings attached to that query | +| `list_response.warnings` | `create_list()`, `get_list()` and `get_lists()` | +| `job.warnings` | `create_distance_matrix_job()`, `distance_matrix_job_status()` and `distance_matrix_jobs()` | + +Warnings are also attached to error responses, where they are available on the +exception: + +```python +from geocodio.exceptions import GeocodioError + +try: + response = client.geocode("1109 N Highland St", fields=["congress"]) +except GeocodioError as e: + for warning in e.warnings: + print(warning) # "The field congress is not recognized. Did you mean cd?" +``` + +> [!TIP] +> Warnings are worth logging during development — they are how the API tells +> you a field append was silently skipped, which otherwise looks like missing +> data. + ### Address components For forward geocoding requests it is possible to supply [individual address components](https://www.geocod.io/docs/#single-address) instead of a full address string: diff --git a/src/geocodio/client.py b/src/geocodio/client.py index eee430a..9deddfe 100644 --- a/src/geocodio/client.py +++ b/src/geocodio/client.py @@ -66,6 +66,7 @@ Timezone, UKLegislativeDistrict, ZIP4Data, + parse_warnings, ) @@ -370,12 +371,19 @@ def _handle_error_response(self, resp) -> httpx.Response: exception_mappings = self.get_status_exception_mappings() # dump the type and content of the exception mappings for debugging logger.error(f"Error response: {resp.status_code} - {resp.text}") + + try: + warnings = parse_warnings(resp.json()) + except ValueError: + warnings = [] + if resp.status_code in exception_mappings: exception_class = exception_mappings[resp.status_code] - raise exception_class(resp.text) + raise exception_class(resp.text, warnings=warnings) else: raise GeocodioServerError( - f"Unrecognized status code {resp.status_code}: {resp.text}" + f"Unrecognized status code {resp.status_code}: {resp.text}", + warnings=warnings, ) def _parse_geocoding_response( @@ -395,9 +403,14 @@ def _parse_geocoding_response( and "response" in response_json["results"][0] ): results = [] + batch_warnings: List[str] = parse_warnings(response_json) for res in response_json["results"]: query = res.get("query", "") matches = res.get("response", {}).get("results") or [] + query_warnings = parse_warnings(res.get("response")) + batch_warnings.extend( + w for w in query_warnings if w not in batch_warnings + ) # Unmatched query (e.g. an unparseable address): keep an entry # so the result list stays aligned with the submitted addresses, @@ -412,6 +425,7 @@ def _parse_geocoding_response( accuracy_type="", source="", query=query, + warnings=query_warnings, ) ) continue @@ -433,10 +447,14 @@ def _parse_geocoding_response( match_type=top.get("match_type"), address_lines=top.get("address_lines"), raw=top, + warnings=query_warnings + parse_warnings(top), ) ) return GeocodingResponse( - results=results, raw=response_json, rate_limit=rate_limit + results=results, + raw=response_json, + rate_limit=rate_limit, + warnings=batch_warnings, ) # Handle single response format @@ -455,11 +473,15 @@ def _parse_geocoding_response( match_type=res.get("match_type"), address_lines=res.get("address_lines"), raw=res, + warnings=parse_warnings(res), ) for res in response_json.get("results", []) ] return GeocodingResponse( - results=results, raw=response_json, rate_limit=rate_limit + results=results, + raw=response_json, + rate_limit=rate_limit, + warnings=parse_warnings(response_json), ) # ────────────────────────────────────────────────────────────────────────── @@ -566,6 +588,7 @@ def get_lists(self) -> PaginatedResponse: first_page_url=pagination_info.get("first_page_url"), next_page_url=pagination_info.get("next_page_url"), prev_page_url=pagination_info.get("prev_page_url"), + warnings=parse_warnings(pagination_info), ) def get_list(self, list_id: str) -> ListResponse: @@ -617,6 +640,7 @@ def _parse_list_response( download_url=response_json.get("download_url"), expires_at=response_json.get("expires_at"), http_response=response, + warnings=parse_warnings(response_json), ) @staticmethod @@ -1365,6 +1389,7 @@ def distance_matrix_jobs(self, page: int = 1) -> PaginatedResponse: first_page_url=pagination_info.get("first_page_url"), next_page_url=pagination_info.get("next_page_url"), prev_page_url=pagination_info.get("prev_page_url"), + warnings=parse_warnings(pagination_info), ) def get_distance_matrix_job_results( diff --git a/src/geocodio/exceptions.py b/src/geocodio/exceptions.py index 6088810..f1fa6d8 100644 --- a/src/geocodio/exceptions.py +++ b/src/geocodio/exceptions.py @@ -5,7 +5,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List, Optional, Union # ────────────────────────────────────────────────────────────────────────────── @@ -22,6 +22,7 @@ class GeocodioErrorDetail: message: str code: Optional[int] = None # e.g. HTTP status or internal errors: Optional[List[str]] = None # field‑specific validation messages + warnings: List[str] = field(default_factory=list) # from ``_warnings`` # ────────────────────────────────────────────────────────────────────────────── @@ -32,13 +33,30 @@ class GeocodioErrorDetail: class GeocodioError(Exception): """Root of the library’s exception hierarchy.""" - def __init__(self, detail: Union[str, GeocodioErrorDetail]): + def __init__( + self, + detail: Union[str, GeocodioErrorDetail], + warnings: Optional[List[str]] = None, + ): if isinstance(detail, str): - self.detail = GeocodioErrorDetail(message=detail) + self.detail = GeocodioErrorDetail( + message=detail, warnings=list(warnings or []) + ) else: self.detail = detail super().__init__(self.detail.message) + @property + def warnings(self) -> List[str]: + """ + Non-fatal advisories the API returned alongside the error. + + The API appends a ``_warnings`` key to error responses just as it does + to successful ones, e.g. to point out a misspelled field name in a + request that failed for an unrelated reason. + """ + return self.detail.warnings + def __str__(self) -> str: # prettier default printing return self.detail.message diff --git a/src/geocodio/models.py b/src/geocodio/models.py index 2a85ba2..ba6ed0f 100644 --- a/src/geocodio/models.py +++ b/src/geocodio/models.py @@ -14,6 +14,21 @@ T = TypeVar("T", bound="ExtrasMixin") +def parse_warnings(data: Any) -> List[str]: + """ + Read the ``_warnings`` key from an API payload. + + The API only sends the key when at least one warning was raised, so a + missing key (or a payload that is not an object) yields an empty list. + """ + if not isinstance(data, dict): + return [] + warnings = data.get("_warnings") + if not isinstance(warnings, list): + return [] + return [str(warning) for warning in warnings] + + class ExtrasMixin: """Mixin to provide additional functionality for API response models.""" @@ -733,6 +748,7 @@ class DistanceJobResponse: total_calculations: Total number of distance calculations. download_url: URL to download results (when completed). calculations_completed: Number of completed calculations. + warnings: Non-fatal advisories from the API's ``_warnings`` key. """ id: int @@ -746,10 +762,14 @@ class DistanceJobResponse: download_url: Optional[str] = None calculations_completed: Optional[int] = None progress: Optional[int] = None + warnings: List[str] = field(default_factory=list) @classmethod def from_api(cls, data: Dict[str, Any]) -> "DistanceJobResponse": """Create from API response data.""" + # Warnings sit beside the nested "data" key, so read them first + warnings = parse_warnings(data) + # Handle nested "data" key for status responses if "data" in data and isinstance(data["data"], dict): data = data["data"] @@ -771,6 +791,7 @@ def from_api(cls, data: Dict[str, Any]) -> "DistanceJobResponse": download_url=data.get("download_url"), calculations_completed=data.get("calculations_completed"), progress=data.get("progress"), + warnings=warnings, ) @@ -793,6 +814,9 @@ class GeocodingResult: match_type: Optional[str] = None address_lines: Optional[List[str]] = None raw: Dict[str, Any] = field(default_factory=dict, repr=False) + # This result's ``_warnings`` (e.g. a skipped ffiec append). For batch + # requests, also the warnings attached to this query's response. + warnings: List[str] = field(default_factory=list) @property def matched(self) -> bool: @@ -820,11 +844,15 @@ class GeocodingResponse: results: Flat list of results, one per submitted query. raw: The untouched JSON payload as returned by the API. rate_limit: Rate limit state from the response headers, when present. + warnings: Non-fatal advisories from the API's ``_warnings`` key, e.g. + an unrecognized field name. For batch requests, the de-duplicated + warnings from every query's response. """ results: List[GeocodingResult] = field(default_factory=list) raw: Dict[str, Any] = field(default_factory=dict, repr=False) rate_limit: Optional[RateLimit] = None + warnings: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: """ @@ -851,6 +879,8 @@ class ListProcessingState: class ListResponse: """ status, download_url, expires_at are not always present. + + warnings holds non-fatal advisories from the API's ``_warnings`` key. """ id: str @@ -859,6 +889,7 @@ class ListResponse: download_url: Optional[str] = None expires_at: Optional[str] = None http_response: Optional[httpx.Response] = None + warnings: List[str] = field(default_factory=list) @dataclass(slots=True, frozen=True) @@ -876,3 +907,4 @@ class PaginatedResponse: first_page_url: str next_page_url: Optional[str] = None prev_page_url: Optional[str] = None + warnings: List[str] = field(default_factory=list) diff --git a/tests/unit/test_warnings.py b/tests/unit/test_warnings.py new file mode 100644 index 0000000..60864ab --- /dev/null +++ b/tests/unit/test_warnings.py @@ -0,0 +1,364 @@ +""" +The API reports non-fatal advisories under a `_warnings` key -- an +unrecognized field name, a superseded API version, an append that was +skipped. These tests lock in that the key is parsed onto every response +shape, so a future model refactor cannot silently drop it. + +The shapes are the ones the OpenAPI specification models (see the `Warnings` +schema): top-level on single geocode/reverse, per result, per batch item, +and on the lists and distance-jobs responses. +""" + +import pytest + +from geocodio.exceptions import ( + AuthenticationError, + GeocodioError, + InvalidRequestError, +) + +FIELD_WARNING = "The field congress is not recognized. Did you mean cd?" +VERSION_WARNING = ( + "There is a newer API version available, please consider upgrading to v2." +) +FIELDS_FORMAT_WARNING = ( + "The fields parameter should contain a comma-separated list of fields " + "instead of an array" +) +FFIEC_WARNING = "ffiec field was skipped since result is not street-level" + + +def result(formatted_address: str, **extra) -> dict: + return { + "address_components": {"city": "Arlington", "country": "US"}, + "formatted_address": formatted_address, + "location": {"lat": 38.886665, "lng": -77.094733}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", + **extra, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Geocoding responses +# ────────────────────────────────────────────────────────────────────────────── + + +def test_single_forward_geocode_warnings(client, httpx_mock): + httpx_mock.add_response( + json={ + "results": [result("1109 N Highland St, Arlington, VA 22201")], + "_warnings": [FIELD_WARNING], + } + ) + + response = client.geocode("1109 N Highland St, Arlington VA", fields=["congress"]) + + assert response.warnings == [FIELD_WARNING] + assert response.results[0].warnings == [] + + +def test_single_reverse_geocode_warnings(client, httpx_mock): + warning = ( + "Ignoring parameter zipcode as it was not expected. Did you mean postal_code?" + ) + httpx_mock.add_response( + json={ + "results": [result("1109 N Highland St, Arlington, VA 22201")], + "_warnings": [warning], + } + ) + + response = client.reverse("38.886665,-77.094733") + + assert response.warnings == [warning] + + +def test_per_result_warnings(client, httpx_mock): + httpx_mock.add_response( + json={ + "results": [ + result( + "Arlington, VA 22201", + accuracy_type="place", + _warnings=[FFIEC_WARNING], + ) + ], + } + ) + + response = client.geocode("22201", fields=["ffiec"]) + + assert response.warnings == [] + assert response.results[0].warnings == [FFIEC_WARNING] + + +def test_batch_forward_geocode_warnings(client, httpx_mock): + httpx_mock.add_response( + json={ + "results": [ + { + "query": "1109 N Highland St, Arlington VA", + "response": { + "results": [ + result( + "1109 N Highland St, Arlington, VA 22201", + _warnings=[FFIEC_WARNING], + ) + ], + "_warnings": [FIELD_WARNING], + }, + }, + { + "query": "525 University Ave, Toronto, ON, Canada", + "response": { + "results": [result("525 University Ave, Toronto, ON M5G")], + "_warnings": [FIELD_WARNING], + }, + }, + ] + } + ) + + response = client.geocode( + [ + "1109 N Highland St, Arlington VA", + "525 University Ave, Toronto, ON, Canada", + ], + fields=["congress"], + ) + + # Per query: that query's warnings, then the result's own + assert response.results[0].warnings == [FIELD_WARNING, FFIEC_WARNING] + assert response.results[1].warnings == [FIELD_WARNING] + # Across the batch: each warning once + assert response.warnings == [FIELD_WARNING] + + +def test_batch_reverse_geocode_warnings(client, httpx_mock): + httpx_mock.add_response( + json={ + "results": [ + { + "query": "35.9746000,-77.9658000", + "response": { + "results": [result("101 W Washington St, Nashville, NC 27856")], + "_warnings": [VERSION_WARNING], + }, + } + ] + } + ) + + response = client.reverse(["35.9746000,-77.9658000"]) + + assert response.results[0].warnings == [VERSION_WARNING] + assert response.warnings == [VERSION_WARNING] + + +def test_batch_warnings_kept_for_unmatched_query(client, httpx_mock): + httpx_mock.add_response( + json={ + "results": [ + { + "query": "not an address", + "response": {"results": [], "_warnings": [FIELD_WARNING]}, + }, + ] + } + ) + + response = client.geocode(["not an address"], fields=["congress"]) + + assert response.results[0].matched is False + assert response.results[0].warnings == [FIELD_WARNING] + assert response.warnings == [FIELD_WARNING] + + +def test_no_warnings_when_api_sends_none(client, httpx_mock): + httpx_mock.add_response( + json={"results": [result("1109 N Highland St, Arlington, VA 22201")]} + ) + + response = client.geocode("1109 N Highland St, Arlington VA") + + assert response.warnings == [] + assert response.results[0].warnings == [] + assert "_warnings" not in response.raw + + +def test_warnings_remain_on_raw_payload(client, httpx_mock): + httpx_mock.add_response( + json={ + "results": [result("1109 N Highland St, Arlington, VA 22201")], + "_warnings": [FIELD_WARNING], + } + ) + + response = client.geocode("1109 N Highland St, Arlington VA", fields=["congress"]) + + assert response.raw["_warnings"] == [FIELD_WARNING] + + +# ────────────────────────────────────────────────────────────────────────────── +# Lists responses +# ────────────────────────────────────────────────────────────────────────────── + + +def test_create_list_warnings(client, httpx_mock): + warning = ( + "The following field was not recognized and has been skipped: " + "congressional_district" + ) + httpx_mock.add_response( + json={ + "id": 42, + "file": {"filename": "inline.csv"}, + "status": {"state": "PROCESSING"}, + "_warnings": [warning], + } + ) + + response = client.create_list( + file="address\n1109 N Highland St, Arlington VA", + filename="inline.csv", + fields=["congressional_district"], + ) + + assert response.warnings == [warning] + + +def test_get_list_warnings(client, httpx_mock): + httpx_mock.add_response( + json={ + "id": 42, + "file": {"filename": "inline.csv"}, + "status": {"state": "COMPLETED"}, + "_warnings": [FIELDS_FORMAT_WARNING], + } + ) + + assert client.get_list("42").warnings == [FIELDS_FORMAT_WARNING] + + +def test_get_lists_warnings(client, httpx_mock): + httpx_mock.add_response( + json={ + "current_page": 1, + "data": [], + "from": 0, + "to": 0, + "path": "/v2/lists", + "per_page": 15, + "first_page_url": "/v2/lists?page=1", + "_warnings": [FIELDS_FORMAT_WARNING], + } + ) + + assert client.get_lists().warnings == [FIELDS_FORMAT_WARNING] + + +# ────────────────────────────────────────────────────────────────────────────── +# Distance matrix job responses +# ────────────────────────────────────────────────────────────────────────────── + + +def test_create_distance_matrix_job_warnings(client, httpx_mock): + httpx_mock.add_response( + json={ + "id": 123, + "identifier": "dmj_abc123", + "status": "ENQUEUED", + "name": "Store coverage", + "created_at": "2026-09-25T12:00:00.000000Z", + "origins_count": 1, + "destinations_count": 1, + "total_calculations": 1, + "_warnings": [VERSION_WARNING], + } + ) + + response = client.create_distance_matrix_job( + name="Store coverage", + origins=[(38.886665, -77.094733)], + destinations=[(38.897675, -77.036547)], + ) + + assert response.warnings == [VERSION_WARNING] + + +def test_distance_matrix_job_status_warnings(client, httpx_mock): + # Warnings sit beside the nested "data" key, not inside it + httpx_mock.add_response( + json={ + "data": {"id": 123, "identifier": "dmj_abc123", "status": "COMPLETED"}, + "_warnings": [FIELDS_FORMAT_WARNING], + } + ) + + response = client.distance_matrix_job_status("dmj_abc123") + + assert response.status == "COMPLETED" + assert response.warnings == [FIELDS_FORMAT_WARNING] + + +def test_distance_matrix_jobs_warnings(client, httpx_mock): + httpx_mock.add_response( + json={ + "current_page": 1, + "data": [], + "from": 0, + "to": 0, + "path": "/v2/distance-jobs", + "per_page": 10, + "first_page_url": "/v2/distance-jobs?page=1", + "_warnings": [FIELDS_FORMAT_WARNING], + } + ) + + assert client.distance_matrix_jobs().warnings == [FIELDS_FORMAT_WARNING] + + +# ────────────────────────────────────────────────────────────────────────────── +# Error responses +# ────────────────────────────────────────────────────────────────────────────── + + +def test_error_response_warnings(client, httpx_mock): + httpx_mock.add_response( + status_code=422, + json={ + "error": "Could not geocode address. Postal code or city required.", + "_warnings": [FIELD_WARNING], + }, + ) + + with pytest.raises(InvalidRequestError) as exc_info: + client.geocode("1109 N Highland St", fields=["congress"]) + + assert exc_info.value.warnings == [FIELD_WARNING] + assert exc_info.value.detail.warnings == [FIELD_WARNING] + + +def test_error_response_without_warnings(client, httpx_mock): + httpx_mock.add_response(status_code=403, json={"error": "Invalid API key"}) + + with pytest.raises(AuthenticationError) as exc_info: + client.geocode("1109 N Highland St, Arlington VA") + + assert exc_info.value.warnings == [] + + +def test_non_json_error_response_has_no_warnings(client, httpx_mock): + httpx_mock.add_response(status_code=502, text="Bad Gateway") + + with pytest.raises(GeocodioError) as exc_info: + client.geocode("1109 N Highland St, Arlington VA") + + assert exc_info.value.warnings == [] + + +def test_error_constructed_directly_has_no_warnings(): + assert GeocodioError("boom").warnings == [] + assert GeocodioError("boom", warnings=[FIELD_WARNING]).warnings == [FIELD_WARNING]