summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:33:51 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:33:51 -0300
commit2fb251996798798af38903fe535b8d147084a50a (patch)
tree9bcbb6ead9c7c4c1910495186707dc0f8859169d
parentb71faa6e95a0736f1ff171e006b171467b65db90 (diff)
downloadcpp-492ee0a03b6f23c3459cd32694cb1ed6bffdf014.tar.gz
Expose the municipality behind a not-found CEPHEADv0.2.0main
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.
-rw-r--r--CMakeLists.txt2
-rw-r--r--README.md40
-rw-r--r--include/cepis/cepis.hpp6
-rw-r--r--include/cepis/client.hpp7
-rw-r--r--include/cepis/errors.hpp30
-rw-r--r--include/cepis/models.hpp39
-rw-r--r--src/client.cpp21
-rw-r--r--src/models.cpp19
-rw-r--r--tests/test_cepis.cpp88
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 <optional>
#include <stdexcept>
#include <string>
+#include <utility>
+
+#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> 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>& faixa() const noexcept { return faixa_; }
+
+ private:
+ std::optional<Faixa> 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<Faixa> 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> faixa_from_error_body(const std::string& body) {
+ const std::optional<Json> 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 = 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> 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}})",
+ "<html>gateway</html>",
+ }) {
+ 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"})", {}});