summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:37:09 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:37:09 -0300
commita891657aa43d2aba06a6347dd2f94edd19afd6ba (patch)
treeb6369ed2c8485455f9c1aa707c1cd287cb8e7065
parent794f28199a4dde9d18ec711864336c96c58bb422 (diff)
downloadd-main.tar.gz
Carry the municipality a missing CEP would belong toHEADv0.2.0main
A 404 is rarely a random number: it is usually someone who knows the city and guessed the digits, and the classic guess is the -000 that 957 municipalities never received. The API now attaches a faixa to the not-found body naming the municipality that owns the range, so the caller can say "no such CEP, but that range is Areal/RJ" instead of only "no". It stays an error. Faixa hangs off CepNotFoundException, never off Address, and parsing is deliberately lenient: an absent, malformed or unnamed block yields a null Nullable, so reading the extra context can never cost the caller the answer it already had. cep_ini and cep_fim are the smallest and largest CEP that actually exist there, not the bounds the Correios publish, and the range between them is sparse — hence ceps, so nobody mistakes the pair for a list.
-rw-r--r--README.md47
-rw-r--r--dub.json2
-rw-r--r--source/cepis/client.d37
-rw-r--r--source/cepis/exception.d27
-rw-r--r--source/cepis/model.d129
-rw-r--r--source/cepis/package.d4
-rw-r--r--test/cepis_test.d99
7 files changed, 335 insertions, 10 deletions
diff --git a/README.md b/README.md
index 9c76d3d..9ca1fe7 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ Or in `dub.json`:
```json
"dependencies": {
- "cepis": "~>0.1.0"
+ "cepis": "~>0.2.0"
}
```
@@ -166,7 +166,7 @@ status that caused it:
| exception | when | does retrying help? |
| --- | --- | --- |
| `InvalidCepException` | not 8 digits | no |
-| `CepNotFoundException` | well-formed, not in the database | no |
+| `CepNotFoundException` | well-formed, not in the database; may carry `faixa` | no |
| `RateLimitedException` | 429; carries `retryAfter` | yes, later |
| `ServerException` | 5xx; carries `status` | yes, later |
| `NetworkException` | DNS, connection, timeout, TLS | yes, later |
@@ -190,6 +190,49 @@ catch (CepisException error)
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.
+### When the CEP does not exist
+
+A 404 sometimes knows *where* the CEP would have been. `CepNotFoundException`
+carries it in `faixa`: the municipality that owns the range the number falls
+in.
+
+```d
+try
+ render(lookup("25845-000"));
+catch (CepNotFoundException error)
+{
+ if (error.faixa.isNull)
+ askForAnotherCep();
+ else
+ {
+ auto f = error.faixa.get;
+ writeln("no such CEP — that range is ", f.localidade, "/", f.uf);
+ writeln(f.ceps, " CEPs, from ", f.cep_ini, " to ", f.cep_fim);
+ // 118 CEPs, from 25845001 to 25849899
+ }
+}
+```
+
+It is **context for an error, not an address.** The status is still 404 and the
+CEP still does not exist; `faixa` never becomes an `Address` and must not be
+rendered as if the lookup had worked.
+
+`cep_ini` and `cep_fim` are the smallest and the largest CEP that **actually
+exist** there — not the bounds the Correios publish. The DNE's own start for
+Areal is `25845000`, which is 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 the list of valid CEPs for the city, and do not walk it
+expecting the numbers in between to resolve.
+
+`faixa` is null whenever the API sent none — and it is **omitted, never null**,
+when the CEP falls in a band no municipality owns.
+
+Why it exists: a CEP that fails is rarely a random number, it is someone who
+knows the city and guessed the digits — and the classic guess is the `-000`
+that 957 municipalities never received.
+
## Development
Everything runs through `make`, which shells out to `dub`:
diff --git a/dub.json b/dub.json
index 640f036..6e03a3d 100644
--- a/dub.json
+++ b/dub.json
@@ -1,6 +1,6 @@
{
"name": "cepis",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Client for the public CEP API at cep.is (official Correios/DNE data)",
"authors": [
"Murilo Ijanc <[email protected]>"
diff --git a/source/cepis/client.d b/source/cepis/client.d
index a93af7f..ef2f10e 100644
--- a/source/cepis/client.d
+++ b/source/cepis/client.d
@@ -37,7 +37,7 @@ import cepis.retry;
@safe:
/// The version of this library, as sent in the `User-Agent`.
-enum string VERSION = "0.1.0";
+enum string VERSION = "0.2.0";
/// Where the public API lives.
enum string DEFAULT_URL = "https://cep.is";
@@ -232,11 +232,16 @@ struct Client
* API's own message: the format already passed local validation, so the
* server knows something this client does not.
*
+ * A 404 may carry a `faixa` — the municipality the CEP would have belonged
+ * to — which lands on $(D CepNotFoundException.faixa). It is still a 404:
+ * the block is context for the error and never becomes an $(D Address).
+ *
* Throws:
* $(UL
* $(LI $(D InvalidCepException) — not 8 digits (no request is made).)
* $(LI $(D CepNotFoundException) — well-formed, but not in the
- * database.)
+ * database; carries $(D faixa) when the API named a
+ * municipality.)
* $(LI $(D RateLimitedException) — 429 after every attempt.)
* $(LI $(D ServerException) — 5xx after every attempt.)
* $(LI $(D NetworkException) — the request never completed.)
@@ -250,7 +255,7 @@ struct Client
const response = request("/" ~ number, [ushort(200)]);
if (response.status == 404)
- throw new CepNotFoundException(number);
+ throw new CepNotFoundException(number, notFoundFaixa(response.content));
if (response.status == 400)
throw new InvalidCepException(errorMessage(response.content,
"CEP " ~ number ~ " rejected"));
@@ -540,6 +545,32 @@ private string errorMessage(string content, string fallback)
return fallback;
}
+/**
+ * Pull the `faixa` block out of a 404 body.
+ *
+ * Every failure here — a body that is not JSON, a `faixa` that is missing,
+ * empty or malformed — is null, not an exception. The answer to the question
+ * asked was "this CEP does not exist"; failing to read the extra context the
+ * API attached to it must never cost the caller that answer.
+ */
+private Nullable!Faixa notFoundFaixa(string content)
+{
+ try
+ {
+ const value = parseJSON(content);
+ if (value.type == JSONType.object)
+ {
+ const object = value.objectNoRef;
+ if (auto block = "faixa" in object)
+ return Faixa.fromJSON(*block);
+ }
+ }
+ catch (Exception)
+ {
+ }
+ return Nullable!Faixa.init;
+}
+
private string preview(string content)
{
return `"` ~ sanitize(content).byDchar.take(120).array.to!string ~ `"`;
diff --git a/source/cepis/exception.d b/source/cepis/exception.d
index 9b77449..d34f725 100644
--- a/source/cepis/exception.d
+++ b/source/cepis/exception.d
@@ -26,6 +26,8 @@ module cepis.exception;
import core.time : Duration;
import std.typecons : Nullable;
+import cepis.model : Faixa;
+
@safe:
/**
@@ -74,17 +76,36 @@ class InvalidCepException : CepisException
}
}
-/// The CEP is well-formed but does not exist in the DNE database.
+/**
+ * The CEP is well-formed but does not exist in the DNE database.
+ *
+ * When the API can say which municipality the number $(I would) have belonged
+ * to, that arrives in $(D faixa) and the message names it. It is $(B context
+ * for the error), not a result: the CEP still does not exist.
+ */
class CepNotFoundException : CepisException
{
/// The CEP that was looked up, unmasked.
const string cep;
+ /**
+ * The municipality whose range the CEP falls in, or null.
+ *
+ * Null whenever the API sent no usable `faixa` — including the ordinary
+ * case of a CEP in a band no municipality owns, where the API $(B omits)
+ * the field rather than sending null. Either way the lookup is still a
+ * not-found; see $(D Faixa) before showing any of it to a user.
+ */
+ const Nullable!Faixa faixa;
+
///
- this(string cep, string file = __FILE__, size_t line = __LINE__) pure nothrow
+ this(string cep, Nullable!Faixa faixa = Nullable!Faixa.init,
+ string file = __FILE__, size_t line = __LINE__) pure nothrow
{
- super("CEP " ~ cep ~ " does not exist in the DNE database", file, line);
+ super("CEP " ~ cep ~ " does not exist in the DNE database" ~ (faixa.isNull
+ ? "" : " (that range belongs to " ~ faixa.get.localidadeUf ~ ")"), file, line);
this.cep = cep;
+ this.faixa = faixa;
}
}
diff --git a/source/cepis/model.d b/source/cepis/model.d
index 9ec3ffb..01692dd 100644
--- a/source/cepis/model.d
+++ b/source/cepis/model.d
@@ -201,6 +201,92 @@ struct Address
}
/**
+ * The municipality a CEP that does $(B not) exist would have belonged to.
+ *
+ * This arrives with a $(B 404), on $(D CepNotFoundException.faixa). It is
+ * $(B context for an error, never an address): the CEP asked for does not
+ * exist, and nothing here says otherwise. Rendering it as though the lookup had
+ * succeeded turns a clean not-found into a wrong answer.
+ *
+ * It is worth having because a CEP that fails is rarely a random number — it is
+ * someone who knows the city and guessed the digits, and the classic guess is
+ * the `-000` that 957 municipalities never received.
+ *
+ * $(D cep_ini) and $(D cep_fim) are the smallest and the largest CEP that
+ * $(B actually exist) in that municipality, $(B not) the bounds the Correios
+ * publish for it. The DNE's own start for Areal is `25845000` — precisely the
+ * CEP that just failed; the smallest one that exists is `25845001`.
+ *
+ * The range is also $(B sparse). Areal's 118 CEPs are scattered across the
+ * ~4,900 numbers between `25845001` and `25849899`. That is what $(D ceps) is
+ * for: never present the pair as the list of valid CEPs for the city, and never
+ * walk it expecting the numbers in between to resolve.
+ */
+struct Faixa
+{
+ /// Municipality that owns the range: `"Areal"`.
+ string localidade;
+ /// Federative unit, two letters: `"RJ"`.
+ string uf;
+ /// Federative unit spelled out: `"Rio de Janeiro"`.
+ string estado;
+ /// Region: `"Sudeste"`, `"Nordeste"`, and so on.
+ string regiao;
+ /// IBGE municipality code — the key to join with public datasets.
+ string ibge;
+ /// The smallest CEP that exists in the municipality, unmasked.
+ string cep_ini;
+ /// The largest CEP that exists in the municipality, unmasked.
+ string cep_fim;
+ /// How many CEPs actually exist between $(D cep_ini) and $(D cep_fim).
+ ulong ceps;
+
+ /**
+ * The municipality the way one says it out loud: `"Areal/RJ"`.
+ *
+ * For a message to a human — it is what the not-found error puts in its
+ * own. Falls back to whichever of the two the API sent.
+ */
+ string localidadeUf() const pure nothrow
+ {
+ if (localidade.length == 0)
+ return uf;
+ if (uf.length == 0)
+ return localidade;
+ return localidade ~ "/" ~ uf;
+ }
+
+ /**
+ * Build from the `faixa` block a 404 carried.
+ *
+ * Returns a null $(D Nullable) for anything that does not name a
+ * municipality: absent, not an object, or with an empty $(D localidade).
+ * The API $(B omits) the block — it never sends null — when the CEP falls
+ * in a band no municipality owns, and that is an ordinary not-found, not a
+ * failure. Nothing read here may turn a 404 into a parse error.
+ */
+ static Nullable!Faixa fromJSON(const JSONValue value)
+ {
+ if (value.type != JSONType.object)
+ return Nullable!Faixa.init;
+
+ Faixa faixa;
+ faixa.localidade = jsonText(value, "localidade");
+ if (faixa.localidade.length == 0)
+ return Nullable!Faixa.init;
+
+ faixa.uf = jsonText(value, "uf");
+ faixa.estado = jsonText(value, "estado");
+ faixa.regiao = jsonText(value, "regiao");
+ faixa.ibge = jsonText(value, "ibge");
+ faixa.cep_ini = jsonText(value, "cep_ini");
+ faixa.cep_fim = jsonText(value, "cep_fim");
+ faixa.ceps = jsonCount(value, "ceps");
+ return nullable(faixa);
+ }
+}
+
+/**
* The response of `GET /health`.
*
* Good for two things: checking that the API answers, and knowing $(B which
@@ -365,6 +451,49 @@ unittest
assert(address.raw["campo_do_futuro"].str == "value");
}
+@("a faixa names the municipality the missing cep would belong to")
+unittest
+{
+ import std.json : parseJSON;
+
+ const faixa = Faixa.fromJSON(parseJSON(`{"localidade":"Areal","uf":"RJ",`
+ ~ `"estado":"Rio de Janeiro","regiao":"Sudeste","ibge":"3300225",`
+ ~ `"cep_ini":"25845001","cep_fim":"25849899","ceps":118}`));
+
+ assert(!faixa.isNull);
+ assert(faixa.get.localidade == "Areal");
+ assert(faixa.get.ibge == "3300225");
+ assert(faixa.get.cep_ini == "25845001");
+ assert(faixa.get.cep_fim == "25849899");
+ assert(faixa.get.ceps == 118);
+ assert(faixa.get.localidadeUf == "Areal/RJ");
+}
+
+@("a faixa that names no municipality is absent, not an error")
+unittest
+{
+ import std.json : parseJSON;
+
+ assert(Faixa.fromJSON(parseJSON(`{}`)).isNull);
+ assert(Faixa.fromJSON(parseJSON(`{"localidade":"","uf":"RJ"}`)).isNull);
+ assert(Faixa.fromJSON(parseJSON(`"Areal"`)).isNull);
+ assert(Faixa.fromJSON(parseJSON(`null`)).isNull);
+ assert(Faixa.fromJSON(JSONValue.init).isNull);
+}
+
+@("a partial faixa keeps what it has")
+unittest
+{
+ import std.json : parseJSON;
+
+ const faixa = Faixa.fromJSON(parseJSON(`{"localidade":"Areal","ceps":"118"}`));
+
+ assert(!faixa.isNull);
+ assert(faixa.get.ceps == 118);
+ assert(faixa.get.cep_ini == "");
+ assert(faixa.get.localidadeUf == "Areal");
+}
+
@("health reads counts as numbers")
unittest
{
diff --git a/source/cepis/package.d b/source/cepis/package.d
index 073d377..9a8c433 100644
--- a/source/cepis/package.d
+++ b/source/cepis/package.d
@@ -41,6 +41,10 @@
* if (error.retriable) reschedule(); // network, server, rate limit
* ---
*
+ * A not-found sometimes knows where the CEP $(I would) have been — see
+ * $(D CepNotFoundException.faixa) and $(D Faixa). That is context for the
+ * error, never an address.
+ *
* Note on field names: the API surface is English, but the $(B data fields keep
* the API's own Portuguese names) ($(D logradouro), $(D bairro),
* $(D localidade)). What you read in the JSON is what you access on the value.
diff --git a/test/cepis_test.d b/test/cepis_test.d
index 3ced878..694da0f 100644
--- a/test/cepis_test.d
+++ b/test/cepis_test.d
@@ -16,7 +16,7 @@ module cepis_test;
import core.sync.mutex : Mutex;
import core.thread : Thread;
import core.time : Duration, MonoTime, dur;
-import std.algorithm : filter, map, startsWith;
+import std.algorithm : canFind, filter, map, startsWith;
import std.array : array, join;
import std.conv : to;
import std.exception : assertThrown;
@@ -35,6 +35,22 @@ private enum string SE_ADDRESS = `{
"ddd": "11", "siafi": "7107"
}`;
+/**
+ * A 404 that knows which municipality the CEP would have belonged to.
+ *
+ * `25845000` is the DNE's own start for Areal and does not exist; the smallest
+ * one that does is `25845001`. Fixed here rather than fetched: production does
+ * not serve `faixa` yet, so a live call would pass for the wrong reason.
+ */
+private enum string AREAL_NOT_FOUND = `{
+ "erro": "cep_nao_encontrado", "mensagem": "CEP não existe na base do DNE",
+ "faixa": {
+ "localidade": "Areal", "uf": "RJ", "estado": "Rio de Janeiro",
+ "regiao": "Sudeste", "ibge": "3300225", "cep_ini": "25845001",
+ "cep_fim": "25849899", "ceps": 118
+ }
+}`;
+
private enum string CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,"
~ "estado,regiao,ibge,gia,ddd,siafi";
@@ -339,6 +355,87 @@ unittest
assert(server.calledTimes("/99999999") == 1);
}
+@("a not-found says which municipality the cep would have belonged to")
+unittest
+{
+ auto server = new TestServer();
+ server.respond("/25845000", Reply(404, AREAL_NOT_FOUND));
+
+ auto client = server.client;
+ bool caught;
+ try
+ client.lookup("25845-000");
+ catch (CepNotFoundException error)
+ {
+ caught = true;
+ assert(error.cep == "25845000");
+ assert(!error.retriable);
+ assert(!error.faixa.isNull);
+
+ const faixa = error.faixa.get;
+ assert(faixa.localidade == "Areal");
+ assert(faixa.uf == "RJ");
+ assert(faixa.estado == "Rio de Janeiro");
+ assert(faixa.regiao == "Sudeste");
+ assert(faixa.ibge == "3300225");
+ assert(faixa.cep_ini == "25845001");
+ assert(faixa.cep_fim == "25849899");
+ assert(faixa.ceps == 118);
+ assert(error.msg.canFind("Areal/RJ"));
+ }
+
+ assert(caught);
+ assert(server.calledTimes("/25845000") == 1);
+}
+
+@("a not-found in a band nobody owns carries no faixa")
+unittest
+{
+ auto server = new TestServer();
+ server.respond("/99999999", Reply(404,
+ `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}`));
+
+ auto client = server.client;
+ bool caught;
+ try
+ client.lookup("99999999");
+ catch (CepNotFoundException error)
+ {
+ caught = true;
+ assert(error.faixa.isNull);
+ assert(error.msg.canFind("99999999"));
+ }
+
+ assert(caught);
+}
+
+@("a malformed faixa is still an ordinary not-found")
+unittest
+{
+ foreach (content; [
+ `{"erro":"cep_nao_encontrado","faixa":{}}`,
+ `{"erro":"cep_nao_encontrado","faixa":{"uf":"RJ"}}`,
+ `{"erro":"cep_nao_encontrado","faixa":"Areal"}`,
+ `{"erro":"cep_nao_encontrado","faixa":null}`, `<html>gateway</html>`, ``
+ ])
+ {
+ auto server = new TestServer();
+ server.respond("/99999999", Reply(404, content));
+
+ auto client = server.client;
+ bool caught;
+ try
+ client.lookup("99999999");
+ catch (CepNotFoundException error)
+ {
+ caught = true;
+ assert(error.faixa.isNull);
+ }
+
+ assert(caught);
+ }
+}
+
@("an invalid cep never leaves the machine")
unittest
{