From b199ae044f8f9cd4b745214cd5e5495f86b5eb54 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:32:01 -0300 Subject: Carry the municipality of a CEP that does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failing CEP is rarely random: it is someone who knows the city and guessed the number, and the classic guess is the -000 that 957 municipalities never received. The API now answers those 404s with a faixa block naming the municipality the CEP would have belonged to, so a caller can ask "did you mean somewhere in Areal/RJ?" instead of "invalid CEP". CEPNotFound gains faixa, defaulting to None. It stays a 404 and stays an exception — the field is context for the error, never a demotion of it into a partial success. Parsing is deliberately total: anything missing, empty or malformed under faixa yields None, because turning a clean not-found into a parse failure over an optional courtesy field would be strictly worse than not having the field at all. Faixa's cep_ini/cep_fim are the smallest and largest CEP that actually exist there, not the bounds the Correios publish, and the range in between is sparse — hence ceps, so the pair is never read as a list of valid numbers. --- src/cepis/client.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) (limited to 'src/cepis/client.py') diff --git a/src/cepis/client.py b/src/cepis/client.py index bebc6e6..ffd1332 100644 --- a/src/cepis/client.py +++ b/src/cepis/client.py @@ -35,7 +35,7 @@ from .errors import ( RateLimited, ServerError, ) -from .models import Address, Health +from .models import Address, Faixa, Health DEFAULT_URL = "https://cep.is" @@ -160,6 +160,16 @@ class Client: API's own message: the format already passed local validation, so the server knows something this client does not. + A 404 may carry the municipality the CEP would have belonged to, in + `CEPNotFound.faixa` (see :class:`cepis.Faixa`) — still a 404, still an + error, but usually enough to ask a better question than "invalid CEP":: + + try: + address = client.lookup(cep) + except CEPNotFound as exc: + if exc.faixa: + print(f"no such CEP in {exc.faixa.label}") + :raises InvalidCEP: the CEP is not 8 digits (no request is made). :raises CEPNotFound: well-formed, but not in the database. :raises RateLimited: 429 after every attempt. @@ -170,7 +180,11 @@ class Client: status, body, _ = self._request(f"/{number}") if status == 404: - raise CEPNotFound(f"CEP {number} does not exist in the DNE database") + faixa = Faixa.from_json(self._error_json(body).get("faixa")) + message = f"CEP {number} does not exist in the DNE database" + if faixa is not None: + message = f"{message} (it would be in {faixa.label})" + raise CEPNotFound(message, faixa=faixa) if status == 400: raise InvalidCEP(self._error_message(body, f"CEP {number} rejected")) @@ -258,15 +272,23 @@ class Client: return data @staticmethod - def _error_message(body: bytes, fallback: str) -> str: - """Pull `mensagem` out of an error body, falling back to a default.""" + def _error_json(body: bytes) -> dict[str, Any]: + """Decode an error body, or return `{}`. + + Unlike :meth:`_json` this never raises. An error response is already + the bad news; whether its body could be parsed only decides how much + detail the exception carries, never whether one is raised. + """ try: data = json.loads(body.decode("utf-8")) - if isinstance(data, dict) and data.get("mensagem"): - return str(data["mensagem"]) except (ValueError, UnicodeDecodeError): - pass - return fallback + return {} + return data if isinstance(data, dict) else {} + + def _error_message(self, body: bytes, fallback: str) -> str: + """Pull `mensagem` out of an error body, falling back to a default.""" + message = self._error_json(body).get("mensagem") + return str(message) if message else fallback def _backoff(self, attempt: int, retry_after: float | None) -> float: """How long to wait before the next attempt. -- cgit v1.3