summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:21:38 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:21:38 -0300
commitf9f1d248f3a3a2b781507195e00527db1e39f8d3 (patch)
treef99b24f5923e436339b8e8ddb8af6ac854488439
parent075683ff44a254e99adbad8a0372ef80ff06151a (diff)
downloadelixir-main.tar.gz
Carry the municipality on a not-found CEPHEADv0.2.0main
A CEP that fails is rarely a random number: it is usually someone who knows the city and guessed, most often the -000 that 957 municipalities never received. The API now answers 404 with an optional faixa naming the municipality that number would have belonged to, so the caller can say "you are in Areal, but not at that number" instead of a dead end. It stays an error. Cepis.Faixa hangs off Cepis.NotFoundError as context for the failure, never as an address, and parsing is deliberately tolerant: an absent, truncated or unreadable faixa yields nil rather than costing the caller a clean definitive answer. The bounds are the smallest and largest CEP that actually exist there, not the range the Correios publish, and almost nothing between them is valid — hence ceps and density/1, so nobody reads the pair as a list of numbers to try.
-rw-r--r--README.md41
-rw-r--r--lib/cepis.ex50
-rw-r--r--lib/cepis/error.ex12
-rw-r--r--lib/cepis/faixa.ex124
-rw-r--r--mix.exs2
-rw-r--r--test/cepis/lookup_test.exs74
-rw-r--r--test/cepis_test.exs20
-rw-r--r--test/support/server_case.ex20
8 files changed, 335 insertions, 8 deletions
diff --git a/README.md b/README.md
index bb79b2a..837251a 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ Correios data (DNE), refreshed every two weeks.
```elixir
def deps do
- [{:cepis, git: "https://git.cep.is/elixir.git", tag: "v0.1.0"}]
+ [{:cepis, git: "https://git.cep.is/elixir.git", tag: "v0.2.0"}]
end
```
@@ -185,6 +185,45 @@ end
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 failing CEP is rarely random: it is usually someone who knows the city and
+guessed the number, most often the `-000`, which 957 municipalities never
+received. So `Cepis.NotFoundError` carries a `faixa` naming the municipality
+that number *would* have belonged to, when there is one:
+
+```elixir
+{:error, %Cepis.NotFoundError{faixa: faixa}} = Cepis.lookup("25845-000")
+
+if faixa do
+ faixa.localidade # "Areal"
+ faixa.uf # "RJ"
+ faixa.ibge # "3300225"
+ faixa.ceps # 118
+ faixa.cep_ini # "25845001"
+ faixa.cep_fim # "25849899"
+end
+```
+
+This is **still a not-found**. The CEP is not in the database, `lookup/2` still
+returns `{:error, _}`, and `faixa` is context for the failure — never an
+address. What it buys you is a better answer to the user: "you are in Areal,
+but not at that number", instead of a dead end.
+
+Two things about it:
+
+- `cep_ini` and `cep_fim` are the smallest and the largest CEP that **actually
+ exist** in the municipality, 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.** Almost nothing between the two bounds is a valid
+ CEP: Areal has 118 across thousands of numbers. That is what `ceps` is for,
+ and `Cepis.Faixa.density/1` measures it. Never expand the pair into a list of
+ CEPs to try.
+
+`faixa` is `nil` whenever the number falls in a band no municipality owns — the
+API omits the field there rather than sending null — so every caller has to
+handle its absence.
+
## Development
Everything runs through `make`, which shells out to `mix`:
diff --git a/lib/cepis.ex b/lib/cepis.ex
index 249e3f1..95d0579 100644
--- a/lib/cepis.ex
+++ b/lib/cepis.ex
@@ -27,7 +27,7 @@ defmodule Cepis do
the JSON is what you access on the struct. See `Cepis.Address`.
"""
- alias Cepis.{Address, Client, CSV, Health, HTTP}
+ alias Cepis.{Address, Client, CSV, Faixa, Health, HTTP}
alias Cepis.{InvalidCEPError, InvalidResponseError, NotFoundError}
@max_batch 500
@@ -93,11 +93,30 @@ defmodule Cepis do
message: the format already passed local validation, so the server knows
something this client does not.
+ ## When the CEP does not exist
+
+ A failing CEP is rarely random — it is usually someone who knows the city and
+ guessed the number, most often the `-000`, which 957 municipalities never
+ received. So `Cepis.NotFoundError` may carry a `Cepis.Faixa` naming the
+ municipality that number *would* have belonged to:
+
+ {:error, %Cepis.NotFoundError{faixa: faixa}} = Cepis.lookup("25845000")
+
+ if faixa do
+ # "Areal", "RJ" — the CEP is still not in the database
+ {faixa.localidade, faixa.uf}
+ end
+
+ It is still a not-found, and `faixa` is context for the failure rather than
+ an address. It is `nil` whenever the number falls in a band no municipality
+ owns.
+
## Errors
* `Cepis.InvalidCEPError` — not 8 digits (no request is made), or the API
rejected it.
- * `Cepis.NotFoundError` — well-formed, but not in the database.
+ * `Cepis.NotFoundError` — well-formed, but not in the database. May carry
+ the municipality in `faixa`.
* `Cepis.RateLimitError` — 429 after every attempt.
* `Cepis.ServerError` — 5xx after every attempt.
* `Cepis.NetworkError` — the request never completed.
@@ -197,11 +216,14 @@ defmodule Cepis do
@spec health!(Client.t() | keyword()) :: Health.t()
def health!(client \\ []), do: unwrap(health(client))
- defp from_response(404, _body, number) do
+ defp from_response(404, body, number) do
+ faixa = faixa(body)
+
{:error,
%NotFoundError{
cep: number,
- message: "CEP #{number} does not exist in the DNE database"
+ faixa: faixa,
+ message: not_found_message(number, faixa)
}}
end
@@ -213,6 +235,26 @@ defmodule Cepis do
with {:ok, data} <- decode(body), do: {:ok, Address.from_map(data)}
end
+ # A 404 body is a courtesy, not a contract: if it is absent, truncated or not
+ # JSON at all, the not-found still stands on its own. Nothing here may turn a
+ # clean definitive answer into a parse failure.
+ defp faixa(body) do
+ case decode(body) do
+ {:ok, data} -> Faixa.from_map(Map.get(data, "faixa"))
+ {:error, _reason} -> nil
+ end
+ end
+
+ defp not_found_message(number, nil) do
+ "CEP #{number} does not exist in the DNE database"
+ end
+
+ defp not_found_message(number, %Faixa{} = faixa) do
+ "CEP #{number} does not exist in the DNE database — the range belongs to " <>
+ "#{faixa.localidade}/#{faixa.uf}, which has #{faixa.ceps} CEPs between " <>
+ "#{faixa.cep_ini} and #{faixa.cep_fim}"
+ end
+
defp lookup_chunk(client, ceps) do
query = URI.encode_query(%{"ceps" => Enum.map_join(ceps, ",", &to_string/1)})
diff --git a/lib/cepis/error.ex b/lib/cepis/error.ex
index bd86b65..2391c8c 100644
--- a/lib/cepis/error.ex
+++ b/lib/cepis/error.ex
@@ -21,11 +21,19 @@ defmodule Cepis.NotFoundError do
`cep` holds the normalised 8 digits that were looked up. Retrying does not
help: the answer is definitive.
+
+ `faixa` is a `Cepis.Faixa` naming the municipality that CEP *would* have
+ belonged to, or `nil`. It is context for the failure and **not** an address —
+ this is still a not-found, and the CEP is still not in the database. It is
+ `nil` whenever the number falls in a band no municipality owns, which the API
+ signals by omitting the field rather than sending null.
"""
- defexception [:message, :cep]
+ alias Cepis.Faixa
+
+ defexception [:message, :cep, :faixa]
- @type t :: %__MODULE__{message: String.t(), cep: String.t()}
+ @type t :: %__MODULE__{message: String.t(), cep: String.t(), faixa: Faixa.t() | nil}
end
defmodule Cepis.NetworkError do
diff --git a/lib/cepis/faixa.ex b/lib/cepis/faixa.ex
new file mode 100644
index 0000000..ab85e8d
--- /dev/null
+++ b/lib/cepis/faixa.ex
@@ -0,0 +1,124 @@
+defmodule Cepis.Faixa do
+ @moduledoc """
+ The municipality a CEP that does **not** exist would have belonged to.
+
+ It arrives on `Cepis.NotFoundError`, and nowhere else. **It is context for an
+ error, not an address**: the lookup failed, the CEP is not in the DNE, and
+ nothing here says otherwise. Using it as a fallback address is the one wrong
+ use.
+
+ It is worth having because a failing CEP is rarely random — it is almost
+ always someone who knows the city and guessed the number, most often the
+ `-000`, which 957 municipalities never received. Naming the municipality
+ turns "does not exist" into "you are in Areal, but not at that number", which
+ is the difference between a dead end and a form the user can fix.
+
+ **Field names are the API's own, in Portuguese**, as everywhere else in this
+ library: `localidade`, `uf`, `estado`, `regiao`, `ibge`, `cep_ini`,
+ `cep_fim`, `ceps`.
+
+ > #### The range is sparse {: .warning}
+ >
+ > `cep_ini` and `cep_fim` are the smallest and the largest CEP that
+ > **actually exist** in that municipality — not the bounds the Correios
+ > publish. The DNE's own start for Areal is `25845000`, which is exactly the
+ > CEP that just failed; echoing it back would be useless. The numbers between
+ > the two bounds are mostly not CEPs at all: Areal's 118 are scattered across
+ > thousands of them. That is what `ceps` is for, and what `density/1`
+ > measures. Never expand the pair into a list of valid CEPs.
+
+ The API **omits** `faixa`, never sends it as null, when the CEP falls in a
+ numeric band no municipality owns. So `nil` is an ordinary answer, not a
+ failure, and every caller has to handle it.
+ """
+
+ defstruct localidade: "",
+ uf: "",
+ estado: "",
+ regiao: "",
+ ibge: "",
+ cep_ini: "",
+ cep_fim: "",
+ ceps: 0
+
+ @type t :: %__MODULE__{
+ localidade: String.t(),
+ uf: String.t(),
+ estado: String.t(),
+ regiao: String.t(),
+ ibge: String.t(),
+ cep_ini: String.t(),
+ cep_fim: String.t(),
+ ceps: non_neg_integer()
+ }
+
+ @doc """
+ The share of the numbers between `cep_ini` and `cep_fim` that are real CEPs.
+
+ About `0.024` for Areal — 118 CEPs over the 4899 numbers between `25845001`
+ and `25849899`. It exists so the sparsity is measurable instead of being a
+ footnote nobody reads before iterating over the interval:
+
+ if Cepis.Faixa.density(faixa) < 0.5 do
+ # guessing another number in the range is not going to work
+ suggest_search_by_street(faixa.localidade, faixa.uf)
+ end
+
+ Returns `0.0` when the bounds are not two readable numbers.
+ """
+ @spec density(t()) :: float()
+ def density(%__MODULE__{ceps: ceps, cep_ini: cep_ini, cep_fim: cep_fim}) do
+ with {first, ""} <- Integer.parse(cep_ini),
+ {last, ""} <- Integer.parse(cep_fim),
+ true <- last >= first do
+ ceps / (last - first + 1)
+ else
+ _ -> 0.0
+ end
+ end
+
+ @doc false
+ @spec from_map(term()) :: t() | nil
+ def from_map(%{"localidade" => localidade} = data)
+ when is_binary(localidade) and localidade != "" do
+ %__MODULE__{
+ localidade: localidade,
+ uf: text(data, "uf"),
+ estado: text(data, "estado"),
+ regiao: text(data, "regiao"),
+ ibge: text(data, "ibge"),
+ cep_ini: text(data, "cep_ini"),
+ cep_fim: text(data, "cep_fim"),
+ ceps: count(data, "ceps")
+ }
+ end
+
+ def from_map(_), do: nil
+
+ defp text(data, key) do
+ case Map.get(data, key) do
+ value when is_binary(value) -> value
+ value when is_number(value) -> to_string(value)
+ _ -> ""
+ end
+ end
+
+ defp count(data, key) do
+ case Map.get(data, key) do
+ value when is_integer(value) ->
+ value
+
+ value when is_float(value) ->
+ trunc(value)
+
+ value when is_binary(value) ->
+ case Integer.parse(value) do
+ {number, _} -> number
+ :error -> 0
+ end
+
+ _ ->
+ 0
+ end
+ end
+end
diff --git a/mix.exs b/mix.exs
index 8a1625c..d67e5d4 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,7 +1,7 @@
defmodule Cepis.MixProject do
use Mix.Project
- @version "0.1.0"
+ @version "0.2.0"
@source_url "https://git.cep.is/elixir.git"
def project do
diff --git a/test/cepis/lookup_test.exs b/test/cepis/lookup_test.exs
index b68ecbe..39d9d31 100644
--- a/test/cepis/lookup_test.exs
+++ b/test/cepis/lookup_test.exs
@@ -87,6 +87,80 @@ defmodule Cepis.LookupTest do
assert Exception.message(error) =~ "99999999"
end
+ test "a not-found names the municipality the CEP would belong to", %{
+ server: server,
+ client: client
+ } do
+ body = not_found_fixture(%{"faixa" => faixa_fixture()})
+ FakeServer.respond(server, "/25845000", {404, json(body), []})
+
+ assert {:error, %Cepis.NotFoundError{cep: "25845000", faixa: faixa} = error} =
+ Cepis.lookup("25845-000", client)
+
+ 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 Exception.message(error) =~ "Areal/RJ"
+ assert Exception.message(error) =~ "25845000 does not exist"
+ end
+
+ test "a not-found in a band no municipality owns has no faixa", %{
+ server: server,
+ client: client
+ } do
+ FakeServer.respond(server, "/99999999", {404, json(not_found_fixture()), []})
+
+ assert {:error, %Cepis.NotFoundError{faixa: nil} = error} = Cepis.lookup("99999999", client)
+ assert Exception.message(error) == "CEP 99999999 does not exist in the DNE database"
+ end
+
+ test "a malformed faixa does not become a parse failure", %{server: server, client: client} do
+ bodies = [
+ not_found_fixture(%{"faixa" => %{}}),
+ not_found_fixture(%{"faixa" => %{"uf" => "RJ"}}),
+ not_found_fixture(%{"faixa" => %{"localidade" => ""}}),
+ not_found_fixture(%{"faixa" => "Areal"}),
+ # the API omits the field rather than nulling it, but a null must not
+ # cost the caller the error either
+ not_found_fixture(%{"faixa" => :null})
+ ]
+
+ for {body, index} <- Enum.with_index(bodies) do
+ path = "/2584500#{index}"
+ FakeServer.respond(server, path, {404, json(body), []})
+
+ assert {:error, %Cepis.NotFoundError{faixa: nil}} =
+ Cepis.lookup(String.trim_leading(path, "/"), client)
+ end
+ end
+
+ test "a 404 body that is not JSON is still a clean not-found", %{
+ server: server,
+ client: client
+ } do
+ FakeServer.respond(server, "/99999999", {404, "<html>gateway</html>", []})
+
+ assert {:error, %Cepis.NotFoundError{cep: "99999999", faixa: nil}} =
+ Cepis.lookup("99999999", client)
+ end
+
+ test "a faixa keeps whatever the API sends, even partially", %{server: server, client: client} do
+ body = not_found_fixture(%{"faixa" => %{"localidade" => "Areal", "ceps" => "118"}})
+ FakeServer.respond(server, "/25845000", {404, json(body), []})
+
+ assert {:error, %Cepis.NotFoundError{faixa: faixa}} = Cepis.lookup("25845000", client)
+ assert faixa.localidade == "Areal"
+ assert faixa.ceps == 118
+ assert faixa.cep_ini == ""
+ assert Cepis.Faixa.density(faixa) == 0.0
+ end
+
test "a 404 is never retried", %{server: server, client: client} do
FakeServer.respond(server, "/99999999", {404, json(%{"erro" => "cep_nao_encontrado"}), []})
diff --git a/test/cepis_test.exs b/test/cepis_test.exs
index a6bedb1..df9256a 100644
--- a/test/cepis_test.exs
+++ b/test/cepis_test.exs
@@ -67,6 +67,26 @@ defmodule CepisTest do
end
end
+ describe "Cepis.Faixa.density/1" do
+ test "measures how sparse the range really is" do
+ faixa = %Cepis.Faixa{cep_ini: "25845001", cep_fim: "25849899", ceps: 118}
+
+ assert_in_delta Cepis.Faixa.density(faixa), 0.024, 0.001
+ assert Cepis.Faixa.density(faixa) < 1.0
+ end
+
+ test "is 0.0 when the bounds are not two readable numbers" do
+ assert Cepis.Faixa.density(%Cepis.Faixa{}) == 0.0
+ assert Cepis.Faixa.density(%Cepis.Faixa{cep_ini: "25845001", ceps: 118}) == 0.0
+ assert Cepis.Faixa.density(%Cepis.Faixa{cep_ini: "x", cep_fim: "y", ceps: 118}) == 0.0
+ end
+
+ test "is 0.0 when the bounds are inverted" do
+ faixa = %Cepis.Faixa{cep_ini: "25849899", cep_fim: "25845001", ceps: 118}
+ assert Cepis.Faixa.density(faixa) == 0.0
+ end
+ end
+
describe "Cepis.Address.formatted_cep/1" do
test "adds the mask" do
assert Cepis.Address.formatted_cep(%Cepis.Address{cep: "01001000"}) == "01001-000"
diff --git a/test/support/server_case.ex b/test/support/server_case.ex
index a64bb05..8af08cb 100644
--- a/test/support/server_case.ex
+++ b/test/support/server_case.ex
@@ -44,8 +44,28 @@ defmodule Cepis.ServerCase do
@csv_header "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+ @faixa %{
+ "localidade" => "Areal",
+ "uf" => "RJ",
+ "estado" => "Rio de Janeiro",
+ "regiao" => "Sudeste",
+ "ibge" => "3300225",
+ "cep_ini" => "25845001",
+ "cep_fim" => "25849899",
+ "ceps" => 118
+ }
+
def address_fixture(extra \\ %{}), do: Map.merge(@address, extra)
+ def faixa_fixture(extra \\ %{}), do: Map.merge(@faixa, extra)
+
+ def not_found_fixture(extra \\ %{}) do
+ Map.merge(
+ %{"erro" => "cep_nao_encontrado", "mensagem" => "CEP não existe na base do DNE"},
+ extra
+ )
+ end
+
def json(term), do: IO.iodata_to_binary(:json.encode(term))
def csv(rows), do: Enum.join([@csv_header | rows], "\n") <> "\n"