summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:42:18 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:42:18 -0300
commit218bc8fbfbbdc2520130ef43652a6cd22a6e67fb (patch)
tree4291a5bb3a235a21b44cfbc51bf1d0c6b9af0c3e
parent5598ea80735a51cb7b3690837b2b7d4240331b2d (diff)
downloadocaml-1e24c8acae5286ca3d6a5aa35efaf61508b57b0f.tar.gz
Carry the municipality range on a not-found CEPHEADv0.2.0main
A CEP that fails a lookup 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 answers those 404s with a faixa naming the municipality whose band the number falls into, so a caller can ask "did you mean somewhere in Areal/RJ?" instead of returning a bare "not found". Cep_not_found becomes an inline record so the context rides along with the message, matching how Rate_limited and Server_error already carry theirs. It is still a 404: faixa is context on the failure and never promotes it to an address, and its bounds are the smallest and largest CEP that actually exist there — sparse across the interval, which is what the ceps count is for — not the bounds the Correios publish. Parsing is deliberately lenient. A missing, null, malformed or municipality-less faixa yields None rather than an error: a 404 is a clean answer, and no shape of this field should turn it into an Invalid_response.
-rw-r--r--README.md36
-rw-r--r--cepis.opam2
-rw-r--r--dune-project2
-rw-r--r--lib/cepis.ml57
-rw-r--r--lib/cepis.mli55
-rw-r--r--test/test_cepis.ml67
6 files changed, 205 insertions, 14 deletions
diff --git a/README.md b/README.md
index 4e1a18f..94b5daa 100644
--- a/README.md
+++ b/README.md
@@ -157,7 +157,7 @@ decision you have to make**, not by the HTTP status that caused it:
| error | when | does retrying help? |
| --- | --- | --- |
| `Invalid_cep` | not 8 digits | no |
-| `Cep_not_found` | well-formed, not in the database | no |
+| `Cep_not_found` | well-formed, not in the database; may carry `faixa` | no |
| `Rate_limited` | 429; carries `retry_after` | yes, later |
| `Server_error` | 5xx; carries `status` | yes, later |
| `Network_error` | DNS, connection, timeout, TLS | yes, later |
@@ -170,6 +170,40 @@ match Cepis.lookup cep_from_form with
| Error _ -> reschedule ()
```
+### Not found: which city it would have been
+
+A failing CEP 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. So `Cep_not_found` carries a `faixa` naming the
+municipality whose band that number falls into, when it falls into one:
+
+```ocaml
+match Cepis.lookup "25845000" with
+| Error (Cepis.Cep_not_found { faixa = Some f; _ }) ->
+ Printf.printf "not found — did you mean somewhere in %s/%s? (%d CEPs, %s to %s)\n"
+ f.Cepis.localidade f.Cepis.uf f.Cepis.ceps f.Cepis.cep_ini f.Cepis.cep_fim
+ (* not found — did you mean somewhere in Areal/RJ? (118 CEPs, 25845001 to 25849899) *)
+| Error (Cepis.Cep_not_found { faixa = None; _ }) -> ask_for_another_cep ()
+| _ -> ()
+```
+
+Three things this is not:
+
+- **It is still not found.** The lookup failed; `faixa` is context on the
+ error, never an address. Nothing here says the CEP exists.
+- **`cep_ini`/`cep_fim` are not the Correios' published bounds.** They are the
+ smallest and largest CEP that *actually exist* in that municipality. The
+ DNE's own start for Areal is `25845000` — exactly the CEP that just failed.
+- **The pair is not a list of valid CEPs.** 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. Rendering it as "valid from … to …"
+ would be wrong for about 98% of the interval.
+
+`faixa` is `None` when the CEP lands in a band no municipality owns — the API
+omits the field rather than sending null — and also whenever it cannot be read.
+A 404 is a clean answer, and no shape of `faixa` turns it into an
+`Invalid_response`.
+
`Invalid_response` 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/cepis.opam b/cepis.opam
index 9b497ad..0dc39f6 100644
--- a/cepis.opam
+++ b/cepis.opam
@@ -1,6 +1,6 @@
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
-version: "0.1.0"
+version: "0.2.0"
synopsis: "Client for the public CEP API at cep.is"
description:
"Look up Brazilian postal codes (CEP) against the public API at https://cep.is, which serves official Correios data (DNE) refreshed every two weeks. Single lookups carry a coordinate when the database has one; batches resolve up to 500 CEPs per request."
diff --git a/dune-project b/dune-project
index 3e7fa0f..cf8de14 100644
--- a/dune-project
+++ b/dune-project
@@ -2,7 +2,7 @@
(name cepis)
-(version 0.1.0)
+(version 0.2.0)
(generate_opam_files true)
diff --git a/lib/cepis.ml b/lib/cepis.ml
index a168d37..be69f2e 100644
--- a/lib/cepis.ml
+++ b/lib/cepis.ml
@@ -1,4 +1,4 @@
-let version = "0.1.0"
+let version = "0.2.0"
let default_url = "https://cep.is"
let max_batch = 500
@@ -39,21 +39,38 @@ type health = {
let is_ok h = String.equal h.status "ok"
+type faixa = {
+ localidade : string;
+ uf : string;
+ estado : string;
+ regiao : string;
+ ibge : string;
+ cep_ini : string;
+ cep_fim : string;
+ ceps : int;
+}
+
type error =
| Invalid_cep of string
- | Cep_not_found of string
+ | Cep_not_found of { message : string; faixa : faixa option }
| Rate_limited of { message : string; retry_after : float option }
| Server_error of { message : string; status : int }
| Network_error of string
| Invalid_response of string
let error_message = function
- | Invalid_cep m | Cep_not_found m | Network_error m | Invalid_response m -> m
- | Rate_limited { message; _ } | Server_error { message; _ } -> message
+ | Invalid_cep m | Network_error m | Invalid_response m -> m
+ | Cep_not_found { message; _ }
+ | Rate_limited { message; _ }
+ | Server_error { message; _ } ->
+ message
let string_of_error = function
| Invalid_cep m -> "invalid CEP: " ^ m
- | Cep_not_found m -> "CEP not found: " ^ m
+ | Cep_not_found { message; faixa = None } -> "CEP not found: " ^ message
+ | Cep_not_found { message; faixa = Some f } ->
+ Printf.sprintf "CEP not found: %s (that number falls in %s/%s)" message
+ f.localidade f.uf
| Rate_limited { message; retry_after = Some s } ->
Printf.sprintf "rate limited (retry after %gs): %s" s message
| Rate_limited { message; retry_after = None } -> "rate limited: " ^ message
@@ -125,6 +142,24 @@ let geo_of_json = function
| _ -> None)
| _ -> None
+let faixa_of_json : Yojson.Safe.t -> faixa option = function
+ | `Assoc fields -> (
+ match string_field fields "localidade" with
+ | "" -> None
+ | localidade ->
+ Some
+ {
+ localidade;
+ uf = string_field fields "uf";
+ estado = string_field fields "estado";
+ regiao = string_field fields "regiao";
+ ibge = string_field fields "ibge";
+ cep_ini = string_field fields "cep_ini";
+ cep_fim = string_field fields "cep_fim";
+ ceps = int_field fields "ceps";
+ })
+ | _ -> None
+
let address_of_fields fields =
{
cep = string_field fields "cep";
@@ -190,6 +225,12 @@ let api_message body fallback =
| _ -> fallback
| exception _ -> fallback
+let faixa_of_body body =
+ match Yojson.Safe.from_string body with
+ | `Assoc fields -> faixa_of_json (field fields "faixa")
+ | _ -> None
+ | exception _ -> None
+
let months =
[|
"jan";
@@ -481,7 +522,11 @@ module Client = struct
if code = 404 then
Error
(Cep_not_found
- (Printf.sprintf "CEP %s does not exist in the DNE database" number))
+ {
+ message =
+ Printf.sprintf "CEP %s does not exist in the DNE database" number;
+ faixa = faixa_of_body body;
+ })
else if code = 400 then
Error
(Invalid_cep
diff --git a/lib/cepis.mli b/lib/cepis.mli
index 3deeb0b..6920a9d 100644
--- a/lib/cepis.mli
+++ b/lib/cepis.mli
@@ -116,6 +116,37 @@ val is_ok : health -> bool
(** {1 Errors} *)
+type faixa = {
+ localidade : string;
+ uf : string;
+ estado : string;
+ regiao : string;
+ ibge : string;
+ cep_ini : string;
+ cep_fim : string;
+ ceps : int;
+}
+(** The municipality that owns the band a {b nonexistent} CEP falls into.
+
+ This is {b context for an error, not an address}. It arrives on
+ {!Cep_not_found}, the lookup still failed, and nothing here says the CEP
+ exists — it says which city it {i would} belong to if it did. Never promote
+ it to a result: the number the caller typed is not deliverable.
+
+ It exists because a failing CEP 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
+ {b actually exist} in that municipality — not the bounds the Correios
+ publish. For Areal/RJ the DNE's own start is [25845000], which is precisely
+ the CEP that just failed; [cep_ini] is [25845001].
+
+ The range is {b sparse}, and that is what [ceps] is for. Areal's 118 CEPs
+ are scattered across the ~4,900 numbers between [25845001] and [25849899].
+ Presenting the pair as "valid from … to …" would be wrong for about 98% of
+ the interval: it is a bounding box, not a list. *)
+
(** Everything that can go wrong, split by
{b the decision the caller has to make} rather than by the HTTP status that
caused it.
@@ -129,9 +160,17 @@ type error =
(** The CEP is not 8 digits. Usually produced {b without} a request ever
being made — the client checks the format before going to the network.
Retrying is pointless. *)
- | Cep_not_found of string
+ | Cep_not_found of { message : string; faixa : faixa option }
(** The CEP is well-formed but does not exist in the DNE database.
- Retrying is pointless. *)
+ Retrying is pointless.
+
+ [faixa] names the municipality whose band that number falls into, when
+ it falls into one — enough to answer "did you mean Areal/RJ?" instead
+ of a bare "not found". It is [None] when the CEP lands in a band no
+ municipality owns, and also whenever the field cannot be read: a 404
+ is a clean answer, and no shape of [faixa] turns it into a
+ {!Invalid_response}. See {!faixa} for what its bounds do and do not
+ mean. *)
| Rate_limited of { message : string; retry_after : float option }
(** The API (or the edge in front of it) answered 429 on every attempt.
[retry_after] is what the server asked for, in seconds, or [None] if
@@ -155,7 +194,11 @@ val error_message : error -> string
(** The human-readable message carried by an error, without its kind. *)
val string_of_error : error -> string
-(** The error rendered for a log line: kind, then message. *)
+(** The error rendered for a log line: kind, then message.
+
+ A {!Cep_not_found} carrying a {!faixa} names the municipality, because "CEP
+ 25845000 does not exist" and "…and it would have been in Areal/RJ" are two
+ very different lines to find in a log. *)
(** {1 Validation} *)
@@ -234,7 +277,11 @@ module Client : sig
A 400 from the API surfaces as {!Invalid_cep} carrying the API's own
message: the format already passed local validation, so the server knows
- something this client does not. *)
+ something this client does not.
+
+ A 404 surfaces as {!Cep_not_found}, and it may carry a {!faixa} naming the
+ municipality the number would have belonged to. That is context on the
+ failure; it never becomes an [Ok]. *)
val lookup_many : t -> string list -> (address option list, error) result
(** [lookup_many client ceps] resolves many CEPs in one batch.
diff --git a/test/test_cepis.ml b/test/test_cepis.ml
index cc98c4a..a1c575f 100644
--- a/test/test_cepis.ml
+++ b/test/test_cepis.ml
@@ -180,6 +180,15 @@ let is_error name predicate = function
| Ok _ -> check name false
| Error e -> check name (predicate e)
+let contains haystack needle =
+ let n = String.length haystack and m = String.length needle in
+ let rec go i =
+ if i + m > n then false
+ else if String.sub haystack i m = needle then true
+ else go (i + 1)
+ in
+ go 0
+
let test_normalisation () =
List.iter
(fun value ->
@@ -262,11 +271,65 @@ let test_not_found () =
with_server (fun srv client ->
respond srv "/99999999"
[ reply 404 {|{"erro":"cep_nao_encontrado","mensagem":"gone"}|} ];
+ let result = Cepis.Client.lookup client "99999999" in
is_error "404 becomes Cep_not_found"
(function Cepis.Cep_not_found _ -> true | _ -> false)
- (Cepis.Client.lookup client "99999999");
+ result;
+ is_error "a 404 without faixa yields None"
+ (function
+ | Cepis.Cep_not_found { faixa = None; _ } -> true | _ -> false)
+ result;
check "404 is not retried" (count srv "/99999999" = 1))
+let areal_404 =
+ {|{"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}}|}
+
+let test_not_found_with_faixa () =
+ with_server (fun srv client ->
+ respond srv "/25845000" [ reply 404 areal_404 ];
+ match Cepis.Client.lookup client "25845000" with
+ | Ok _ -> check "a faixa does not turn a 404 into an address" false
+ | Error (Cepis.Cep_not_found { faixa = Some f; _ } as e) ->
+ check "faixa names the municipality" (f.Cepis.localidade = "Areal");
+ check "faixa carries the uf" (f.Cepis.uf = "RJ");
+ check "faixa carries the ibge code" (f.Cepis.ibge = "3300225");
+ check "faixa carries the region" (f.Cepis.regiao = "Sudeste");
+ check "faixa bounds are the CEPs that exist, not the DNE's"
+ (f.Cepis.cep_ini = "25845001" && f.Cepis.cep_fim = "25849899");
+ check "faixa counts how sparse the range is" (f.Cepis.ceps = 118);
+ check "the rendered error mentions the municipality"
+ (contains (Cepis.string_of_error e) "Areal");
+ check "the rendered error still says not found"
+ (contains (Cepis.string_of_error e) "CEP not found")
+ | Error _ -> check "a 404 with faixa is still Cep_not_found" false)
+
+let test_not_found_faixa_is_defensive () =
+ with_server (fun srv client ->
+ List.iter
+ (fun (path, body, label) ->
+ respond srv path [ reply 404 body ];
+ is_error
+ (Printf.sprintf "%s yields no faixa, never a parse failure" label)
+ (function
+ | Cepis.Cep_not_found { faixa = None; _ } -> true | _ -> false)
+ (Cepis.Client.lookup client (String.sub path 1 8)))
+ [
+ ( "/25845002",
+ {|{"erro":"cep_nao_encontrado","faixa":null}|},
+ "an explicit null" );
+ ( "/25845003",
+ {|{"erro":"cep_nao_encontrado","faixa":{}}|},
+ "an empty object" );
+ ( "/25845004",
+ {|{"erro":"cep_nao_encontrado","faixa":"Areal"}|},
+ "a bare string" );
+ ( "/25845005",
+ {|{"erro":"cep_nao_encontrado","faixa":{"uf":"RJ","ceps":"muitos"}}|},
+ "a faixa with no municipality" );
+ ("/25845006", "<html>captive portal</html>", "a body that is not JSON");
+ ("/25845007", "", "an empty body");
+ ])
+
let test_local_validation_makes_no_request () =
with_server (fun srv client ->
is_error "malformed CEP is rejected locally"
@@ -544,6 +607,8 @@ let () =
test_malformed_geo ();
test_unknown_field ();
test_not_found ();
+ test_not_found_with_faixa ();
+ test_not_found_faixa_is_defensive ();
test_local_validation_makes_no_request ();
test_api_400 ();
test_not_json ();