diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:42:18 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:42:18 -0300 |
| commit | 218bc8fbfbbdc2520130ef43652a6cd22a6e67fb (patch) | |
| tree | 4291a5bb3a235a21b44cfbc51bf1d0c6b9af0c3e /lib | |
| parent | 5598ea80735a51cb7b3690837b2b7d4240331b2d (diff) | |
| download | ocaml-1e24c8acae5286ca3d6a5aa35efaf61508b57b0f.tar.gz | |
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.ml | 57 | ||||
| -rw-r--r-- | lib/cepis.mli | 55 |
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. |