From 2fb251996798798af38903fe535b8d147084a50a Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:33:51 -0300 Subject: Expose the municipality behind a not-found CEP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CEP that fails a lookup is rarely random: it is usually someone who knows the city and guessed the number, and the classic guess is the -000 that 957 municipalities never received. The API now names, on the 404 itself, the municipality that CEP would have belonged to, so a caller can say "not found — did you mean somewhere in Areal/RJ?" instead of just "not found". CepNotFound carries it as an optional Faixa. It stays an error: the block is context, not an address, and the range it describes is sparse, which is why it reports a count alongside the first and last CEP that actually exist there rather than the bounds the Correios publish. Parsing it is best-effort on purpose — a missing, empty or malformed block leaves the optional empty, since a 404 is already the answer and reading the hint attached to it must never cost the diagnosis. --- CMakeLists.txt | 2 +- README.md | 40 +++++++++++++++++++++- include/cepis/cepis.hpp | 6 ++-- include/cepis/client.hpp | 7 ++-- include/cepis/errors.hpp | 30 +++++++++++++++++ include/cepis/models.hpp | 39 +++++++++++++++++++++ src/client.cpp | 21 +++++++++++- src/models.cpp | 19 +++++++++++ tests/test_cepis.cpp | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 245 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ae1fc9..ef6cbe5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -project(cepis VERSION 0.1.0 LANGUAGES CXX) +project(cepis VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index 0b85199..876a819 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ make**, not by the HTTP status that caused it: | error | when | does retrying help? | | --- | --- | --- | | `InvalidCep` | not 8 digits | no | -| `CepNotFound` | well-formed, not in the database | no | +| `CepNotFound` | well-formed, not in the database; may expose `faixa()` | no | | `RateLimited` | 429; exposes `retry_after()` | yes, later | | `ServerError` | 5xx; exposes `status()` | yes, later | | `NetworkError` | DNS, connection, timeout, TLS | yes, later | @@ -209,6 +209,44 @@ try { } ``` +### A CEP that does not exist + +`CepNotFound` often knows **where the CEP would have been**. A CEP that fails +is rarely random: it is usually someone who knows the city and guessed the +number, and the classic guess is the `-000` that 957 municipalities never +received. + +```cpp +try { + const cepis::Address a = cepis::lookup("25845-000"); +} catch (const cepis::CepNotFound& error) { + if (error.faixa()) { + error.faixa()->localidade; // "Areal" + error.faixa()->uf; // "RJ" + error.faixa()->ibge; // "3300225" + error.faixa()->cep_ini; // "25845001" + error.faixa()->cep_fim; // "25849899" + error.faixa()->ceps; // 118 + } +} +``` + +Three things to keep straight: + +- **It is still a 404.** `faixa` is context for an error, not an address. The + CEP does not exist; nothing here makes it deliverable. +- **`cep_ini` and `cep_fim` are the CEPs that actually exist**, not the bounds + the Correios publish. The DNE's own start for Areal is `25845000` — exactly + the CEP that just failed. +- **The range is sparse.** Areal's 118 CEPs are scattered across the ~4,900 + numbers between `25845001` and `25849899`. That is what `ceps` is for; never + present the pair as a list of valid CEPs. + +When the CEP falls in a band no municipality owns, the API **omits** `faixa` +entirely — it never sends null — and `error.faixa()` is empty. A block that +cannot be read is empty too: a 404 is already the answer, and failing to read +the hint attached to it never turns into a parse error. + `InvalidResponse` almost always means something between you and the API is answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful as a diagnosis: the problem is not in your code. diff --git a/include/cepis/cepis.hpp b/include/cepis/cepis.hpp index 4ac3f1f..1861d88 100644 --- a/include/cepis/cepis.hpp +++ b/include/cepis/cepis.hpp @@ -28,8 +28,10 @@ /// ```cpp /// try { /// const cepis::Address a = cepis::lookup(cep_from_form); -/// } catch (const cepis::CepNotFound&) { -/// // the CEP does not exist: ask the user for another +/// } catch (const cepis::CepNotFound& error) { +/// // the CEP does not exist: ask the user for another. `error.faixa()` +/// // often names the municipality it would have belonged to — context for +/// // the message, never an address. /// } catch (const cepis::Error&) { /// // network, server, rate limit: retry later /// } diff --git a/include/cepis/client.hpp b/include/cepis/client.hpp index 205ba85..3a0d863 100644 --- a/include/cepis/client.hpp +++ b/include/cepis/client.hpp @@ -15,7 +15,7 @@ namespace cepis { /// The library version, as it appears in the default `User-Agent`. -inline constexpr std::string_view kVersion = "0.1.0"; +inline constexpr std::string_view kVersion = "0.2.0"; /// Where the public API lives. inline constexpr std::string_view kDefaultBaseUrl = "https://cep.is"; @@ -33,7 +33,7 @@ inline constexpr std::size_t kMaxBatch = 500; /// trip for the server to say so wastes both sides' time. std::string normalize_cep(std::string_view value); -/// The default `User-Agent`, e.g. `cepis-cpp/0.1.0 (+https://cep.is)`. +/// The default `User-Agent`, e.g. `cepis-cpp/0.2.0 (+https://cep.is)`. std::string default_user_agent(); /// How a `Client` behaves. Every field has a working default; in practice only @@ -108,6 +108,9 @@ class Client { /// 5xx after every attempt; `NetworkError` when the request never /// completed; `InvalidResponse` when a success status carries a body that /// is not a JSON object. + /// + /// A `CepNotFound` often carries `faixa()`, the municipality the CEP would + /// have belonged to. It is context for the error, not an address. [[nodiscard]] Address lookup(std::string_view cep) const; /// Looks up many CEPs in one batch. diff --git a/include/cepis/errors.hpp b/include/cepis/errors.hpp index 0420ecd..fa928f0 100644 --- a/include/cepis/errors.hpp +++ b/include/cepis/errors.hpp @@ -4,6 +4,9 @@ #include #include #include +#include + +#include "cepis/models.hpp" namespace cepis { @@ -38,9 +41,36 @@ class InvalidCep : public Error { }; /// The CEP is well-formed but does not exist in the DNE database. +/// +/// The API often says **where it would have been** — see `faixa()`. That is +/// still a not-found: the extra context does not make the CEP deliverable, and +/// this remains the exception thrown for it. class CepNotFound : public Error { public: using Error::Error; + + CepNotFound(const std::string& message, std::optional faixa) + : Error(message), faixa_(std::move(faixa)) {} + + /// The municipality the CEP would have belonged to, when the API names one. + /// + /// `std::nullopt` when the CEP falls in a band no municipality owns — the + /// API **omits** the block there, it never sends null — and also whenever + /// the block cannot be read. Read `Faixa` before showing any of it: the + /// range it describes is sparse, and the CEP does not exist either way. + /// + /// ```cpp + /// } catch (const cepis::CepNotFound& error) { + /// if (error.faixa()) { + /// std::cerr << "not found; did you mean somewhere in " + /// << error.faixa()->localidade << '/' << error.faixa()->uf << "?\n"; + /// } + /// } + /// ``` + [[nodiscard]] const std::optional& faixa() const noexcept { return faixa_; } + + private: + std::optional faixa_; }; /// The request never completed: DNS, refused connection, timeout, TLS. diff --git a/include/cepis/models.hpp b/include/cepis/models.hpp index faa2dde..6252001 100644 --- a/include/cepis/models.hpp +++ b/include/cepis/models.hpp @@ -96,6 +96,45 @@ struct Address { static Address from_json(const Json& data); }; +/// The municipality a CEP that does **not** exist would have belonged to. +/// +/// It arrives on a `404`, attached to `CepNotFound`. It is context for an +/// error, never an address: the CEP still does not exist, and nothing in this +/// block makes it deliverable. Treat it as a hint for the message you show — +/// "did you mean somewhere in Areal/RJ?" — not as a result. +/// +/// It exists because a CEP that fails is rarely random. It is usually someone +/// who knows the city and guessed the number, and the classic guess is the +/// `-000` that 957 municipalities never received. +/// +/// `cep_ini` and `cep_fim` are the smallest and the largest CEP that +/// **actually exist** in that municipality — not the bounds the Correios +/// publish. For Areal the DNE's own start is `25845000`, which is precisely +/// the CEP that just failed; `cep_ini` is `25845001`. +/// +/// The range is also **sparse**: Areal's 118 CEPs are scattered across the +/// ~4,900 numbers between `25845001` and `25849899`. That is what `ceps` is +/// for. The pair is a span, not a list — walking from `cep_ini` to `cep_fim` +/// produces thousands of CEPs that do not exist either. +struct Faixa { + std::string localidade; + std::string uf; + std::string estado; + std::string regiao; + std::string ibge; + std::string cep_ini; + std::string cep_fim; + std::int64_t ceps = 0; + + /// Reads the API's `faixa` block, or `std::nullopt` when it cannot be read. + /// + /// Anything unexpected — a missing block, a non-object, an empty one, a + /// municipality without a name — yields `std::nullopt` rather than an + /// error. The 404 is already the answer; failing to read the hint attached + /// to it must not turn a clean not-found into a parse failure. + static std::optional from_json(const Json& data); +}; + /// The response of `GET /health`. /// /// Good for two things: checking that the API answers, and knowing **which diff --git a/src/client.cpp b/src/client.cpp index ef19da8..84011a3 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -170,6 +170,20 @@ std::string message_from_error_body(const std::string& body, const std::string& return fallback; } +/// Reads the `faixa` block a 404 may carry, never failing. +/// +/// A body that is not JSON, or carries no `faixa`, or carries one that cannot +/// be read, all give `std::nullopt`. The 404 is the answer; the hint attached +/// to it is a bonus and must never cost the diagnosis. +std::optional faixa_from_error_body(const std::string& body) { + const std::optional parsed = Json::parse(body); + if (!parsed) { + return std::nullopt; + } + const Json* block = parsed->find("faixa"); + return block == nullptr ? std::nullopt : Faixa::from_json(*block); +} + std::string preview(const std::string& body) { constexpr std::size_t kLimit = 120; return body.size() <= kLimit ? body : body.substr(0, kLimit) + "..."; @@ -232,7 +246,12 @@ Address Client::lookup(std::string_view cep) const { const Response response = request("/" + number, {200}); if (response.status == 404) { - throw CepNotFound("CEP " + number + " does not exist in the DNE database"); + std::optional faixa = faixa_from_error_body(response.body); + std::string message = "CEP " + number + " does not exist in the DNE database"; + if (faixa) { + message += " (the range it falls in belongs to " + faixa->localidade + "/" + faixa->uf + ")"; + } + throw CepNotFound(message, std::move(faixa)); } if (response.status == 400) { throw InvalidCep(message_from_error_body(response.body, "CEP " + number + " rejected")); diff --git a/src/models.cpp b/src/models.cpp index 84e03f9..088283b 100644 --- a/src/models.cpp +++ b/src/models.cpp @@ -124,6 +124,25 @@ Address Address::from_json(const Json& data) { return address; } +std::optional Faixa::from_json(const Json& data) { + if (!data.is_object()) { + return std::nullopt; + } + Faixa faixa; + faixa.localidade = as_field(data.find("localidade")); + faixa.uf = as_field(data.find("uf")); + if (faixa.localidade.empty() || faixa.uf.empty()) { + return std::nullopt; + } + faixa.estado = as_field(data.find("estado")); + faixa.regiao = as_field(data.find("regiao")); + faixa.ibge = as_field(data.find("ibge")); + faixa.cep_ini = as_field(data.find("cep_ini")); + faixa.cep_fim = as_field(data.find("cep_fim")); + faixa.ceps = as_integer(data.find("ceps")).value_or(0); + return faixa; +} + Health Health::from_json(const Json& data) { Health health; health.status = as_field(data.find("status")); diff --git a/tests/test_cepis.cpp b/tests/test_cepis.cpp index 6b8b9bf..96b6826 100644 --- a/tests/test_cepis.cpp +++ b/tests/test_cepis.cpp @@ -145,6 +145,94 @@ CEPIS_TEST(not_found_throws_cep_not_found) { CHECK_THROWS(fixture.client.lookup("99999999"), cepis::CepNotFound); } +CEPIS_TEST(not_found_names_the_municipality_when_the_api_does) { + Fixture fixture; + fixture.server.respond( + "/25845000", + Reply{404, + R"({"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE",)" + R"("faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro",)" + R"("regiao":"Sudeste","ibge":"3300225","cep_ini":"25845001",)" + R"("cep_fim":"25849899","ceps":118}})", + {}}); + + bool threw = false; + try { + (void)fixture.client.lookup("25845-000"); + } catch (const cepis::CepNotFound& error) { + threw = true; + CHECK(error.faixa().has_value()); + CHECK_EQ(error.faixa()->localidade, std::string("Areal")); + CHECK_EQ(error.faixa()->uf, std::string("RJ")); + CHECK_EQ(error.faixa()->estado, std::string("Rio de Janeiro")); + CHECK_EQ(error.faixa()->regiao, std::string("Sudeste")); + CHECK_EQ(error.faixa()->ibge, std::string("3300225")); + // The bounds are the CEPs that exist, not the ones the Correios publish: + // the DNE's own start for Areal is 25845000 — the CEP that just failed. + CHECK_EQ(error.faixa()->cep_ini, std::string("25845001")); + CHECK_EQ(error.faixa()->cep_fim, std::string("25849899")); + // And the range is sparse: 118 CEPs across ~4,900 numbers. + CHECK_EQ(error.faixa()->ceps, std::int64_t{118}); + CHECK(std::string(error.what()).find("Areal/RJ") != std::string::npos); + } + CHECK(threw); +} + +CEPIS_TEST(not_found_without_a_faixa_is_still_a_plain_not_found) { + Fixture fixture; + fixture.server.respond( + "/99999999", + Reply{ + 404, R"({"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"})", {}}); + + bool threw = false; + try { + (void)fixture.client.lookup("99999999"); + } catch (const cepis::CepNotFound& error) { + threw = true; + // The API omits the block for a band no municipality owns. It never sends + // null, and an absent block is not an error. + CHECK(!error.faixa().has_value()); + CHECK(std::string(error.what()).find("99999999") != std::string::npos); + } + CHECK(threw); +} + +CEPIS_TEST(malformed_faixa_does_not_become_a_parse_failure) { + for (const std::string body : { + R"({"erro":"cep_nao_encontrado","faixa":{}})", + R"({"erro":"cep_nao_encontrado","faixa":null})", + R"({"erro":"cep_nao_encontrado","faixa":"Areal"})", + R"({"erro":"cep_nao_encontrado","faixa":{"localidade":"","uf":"RJ"}})", + R"({"erro":"cep_nao_encontrado","faixa":{"uf":"RJ","ceps":118}})", + "gateway", + }) { + Fixture broken; + broken.server.respond("/99999999", Reply{404, body, {}}); + + bool threw = false; + try { + (void)broken.client.lookup("99999999"); + } catch (const cepis::CepNotFound& error) { + threw = true; + CHECK(!error.faixa().has_value()); + } + CHECK(threw); + } +} + +CEPIS_TEST(a_faixa_never_turns_a_not_found_into_an_address) { + Fixture fixture; + fixture.server.respond( + "/25845000", Reply{404, + R"({"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal",)" + R"("uf":"RJ","cep_ini":"25845001","cep_fim":"25849899","ceps":118}})", + {}}); + + CHECK_THROWS(fixture.client.lookup("25845000"), cepis::CepNotFound); + CHECK_EQ(fixture.server.count("/25845000"), 1); +} + CEPIS_TEST(not_found_is_not_retried) { Fixture fixture; fixture.server.respond("/99999999", Reply{404, R"({"erro":"cep_nao_encontrado"})", {}}); -- cgit v1.3