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 | |
| parent | 5598ea80735a51cb7b3690837b2b7d4240331b2d (diff) | |
| download | ocaml-218bc8fbfbbdc2520130ef43652a6cd22a6e67fb.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.
| -rw-r--r-- | README.md | 36 | ||||
| -rw-r--r-- | cepis.opam | 2 | ||||
| -rw-r--r-- | dune-project | 2 | ||||
| -rw-r--r-- | lib/cepis.ml | 57 | ||||
| -rw-r--r-- | lib/cepis.mli | 55 | ||||
| -rw-r--r-- | test/test_cepis.ml | 67 |
6 files changed, 205 insertions, 14 deletions
@@ -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. @@ -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 (); |