summaryrefslogtreecommitdiffstats
path: root/lib
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 /lib
parent5598ea80735a51cb7b3690837b2b7d4240331b2d (diff)
downloadocaml-218bc8fbfbbdc2520130ef43652a6cd22a6e67fb.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.
Diffstat (limited to 'lib')
-rw-r--r--lib/cepis.ml57
-rw-r--r--lib/cepis.mli55
2 files changed, 102 insertions, 10 deletions
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.