Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 29 additions & 4 deletions src/geocodio/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
Timezone,
UKLegislativeDistrict,
ZIP4Data,
parse_warnings,
)


Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -412,6 +425,7 @@ def _parse_geocoding_response(
accuracy_type="",
source="",
query=query,
warnings=query_warnings,
)
)
continue
Expand All @@ -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
Expand All @@ -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),
)

# ──────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 21 additions & 3 deletions src/geocodio/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import List, Optional, Union

# ──────────────────────────────────────────────────────────────────────────────
Expand All @@ -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``


# ──────────────────────────────────────────────────────────────────────────────
Expand All @@ -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

Expand Down
32 changes: 32 additions & 0 deletions src/geocodio/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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
Expand All @@ -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"]
Expand All @@ -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,
)


Expand All @@ -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:
Expand Down Expand Up @@ -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]:
"""
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Loading
Loading