diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:15:36 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:23:14 -0300 |
| commit | 47c822d0d5e690092617b6170972917058a6d45d (patch) | |
| tree | 583ce076573d29a60dc8453df7089022458b7303 | |
| parent | 323d9a8422f06676d25cdf890c06962b260cb8aa (diff) | |
| download | go-main.tar.gz | |
A 404 that only says "not found" is correct and useless. The CEP that
fails is almost never random: it is someone who knows the city and
guessed the number, and the classic guess is the -000 that 957
municipalities never received.
The API now names the municipality on the 404, so surface it as
Error.Faixa instead of throwing the body away.
| -rw-r--r-- | README.md | 33 | ||||
| -rw-r--r-- | cepis_test.go | 48 | ||||
| -rw-r--r-- | client.go | 12 | ||||
| -rw-r--r-- | errors.go | 16 | ||||
| -rw-r--r-- | models.go | 47 |
5 files changed, 155 insertions, 1 deletions
@@ -201,6 +201,39 @@ if errors.As(err, &cerr) && errors.Is(cerr, cepis.ErrRateLimited) { 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. +### Which city the missing CEP was from + +A CEP that fails is rarely a random number — it is usually someone who knows +the city and guessed the rest. The classic guess is the `-000`: a small town +often has a single city-wide CEP ending that way, but 957 municipalities are +coded street by street and never got one. Areal/RJ answers from `25845-001` up, +never at `25845-000`. + +When the API can name the municipality the number would fall in, it comes back +on the error: + +```go +_, err := cepis.Lookup(ctx, "25845-000") + +var cerr *cepis.Error +if errors.As(err, &cerr) && cerr.Faixa != nil { + fmt.Printf("%s/%s has %d CEPs, from %s to %s\n", + cerr.Faixa.Localidade, cerr.Faixa.UF, cerr.Faixa.CEPs, + cerr.Faixa.CEPIni, cerr.Faixa.CEPFim) + // Areal/RJ has 118 CEPs, from 25845001 to 25849899 +} +``` + +`CEPIni` and `CEPFim` are the smallest and largest CEP that **exist** there, not +the bounds the Correios publish — the published start is the `-000` that just +failed. The range is sparse, which is what `CEPs` is for: those 118 are +scattered across nearly five thousand numbers, so do not read the pair as a list +of valid CEPs. + +`Faixa` is `nil` whenever the API did not send one, including for a CEP in a +band that belongs to no municipality at all. It is an extra on top of an error +that is complete without it. + ## Development Everything runs through `make`: diff --git a/cepis_test.go b/cepis_test.go index 888ded5..2394175 100644 --- a/cepis_test.go +++ b/cepis_test.go @@ -268,6 +268,54 @@ func TestLookupNotFound(t *testing.T) { } } +func TestLookupNotFoundCarriesTheMunicipality(t *testing.T) { + api := newFakeAPI(t) + api.respond("/25845000", ok(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}}`)) + + _, err := newClient(t, api).Lookup(context.Background(), "25845-000") + var cerr *cepis.Error + if !errors.As(err, &cerr) { + t.Fatalf("error %v is not a *cepis.Error", err) + } + if cerr.Faixa == nil { + t.Fatal("Faixa is nil, want the municipality of the range") + } + if cerr.Faixa.Localidade != "Areal" || cerr.Faixa.UF != "RJ" { + t.Errorf("Faixa = %+v, want Areal/RJ", cerr.Faixa) + } + // The published bounds are the CEPs that exist, never the DNE's own start + // — 25845000 is exactly the CEP the caller just got wrong. + if cerr.Faixa.CEPIni != "25845001" || cerr.Faixa.CEPFim != "25849899" { + t.Errorf("range = %s-%s, want 25845001-25849899", + cerr.Faixa.CEPIni, cerr.Faixa.CEPFim) + } + if cerr.Faixa.CEPs != 118 { + t.Errorf("CEPs = %d, want 118", cerr.Faixa.CEPs) + } + if !strings.Contains(cerr.Error(), "Areal/RJ") { + t.Errorf("message %q does not mention the municipality", cerr.Error()) + } +} + +func TestLookupNotFoundWithoutAFaixa(t *testing.T) { + // A CEP in a band the Correios gave to no municipality. The API omits the + // object rather than sending null, and the error stays perfectly usable. + api := newFakeAPI(t) + api.respond("/06000000", ok(404, `{"erro":"cep_nao_encontrado"}`)) + + _, err := newClient(t, api).Lookup(context.Background(), "06000000") + var cerr *cepis.Error + if !errors.As(err, &cerr) || !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("error = %v, want ErrCEPNotFound", err) + } + if cerr.Faixa != nil { + t.Errorf("Faixa = %+v, want nil", cerr.Faixa) + } +} + func TestLookupNotFoundIsNotRetried(t *testing.T) { api := newFakeAPI(t) api.respond("/99999999", ok(404, `{"erro":"cep_nao_encontrado"}`)) @@ -225,11 +225,21 @@ func (c *Client) Lookup(ctx context.Context, cep string) (*Address, error) { switch resp.status { case http.StatusNotFound: + // The body may name the municipality the CEP would fall in; when it + // does, say so in the message too, because most callers only ever + // print the error. + faixa := decodeFaixa(resp.body) + message := "CEP " + number + " does not exist in the DNE database" + if faixa != nil { + message += " (range of " + faixa.Localidade + "/" + faixa.UF + + ", which goes from " + faixa.CEPIni + " to " + faixa.CEPFim + ")" + } return nil, &Error{ Kind: ErrCEPNotFound, CEP: number, Status: resp.status, - Message: "CEP " + number + " does not exist in the DNE database", + Faixa: faixa, + Message: message, } case http.StatusBadRequest: return nil, &Error{ @@ -84,6 +84,22 @@ type Error struct { // the client's own cap is applied to it. RetryAfter time.Duration + // Faixa is the municipality the CEP would belong to, set only on + // [ErrCEPNotFound] and only when the API could name one. It is nil for + // every other kind, and also for a CEP that falls in a numeric band the + // Correios never handed to any municipality. + // + // It turns "this CEP does not exist" into something the caller can act + // on: the city is right, the number is not. + // + // var cerr *cepis.Error + // if errors.As(err, &cerr) && cerr.Faixa != nil { + // fmt.Printf("did you mean somewhere in %s/%s? (%s to %s)\n", + // cerr.Faixa.Localidade, cerr.Faixa.UF, + // cerr.Faixa.CEPIni, cerr.Faixa.CEPFim) + // } + Faixa *Faixa + // Message describes the failure without the "cepis: " prefix. Message string @@ -35,6 +35,33 @@ func (g Geo) IsPrecise() bool { return g.Precisao == "logradouro" } +// Faixa is the municipality a CEP that does not exist would belong to, and the +// range of CEPs that municipality actually has. It arrives on the 404, hanging +// off [Error.Faixa]. +// +// A CEP that fails is rarely a random number. It is usually someone who knows +// the city and guessed the rest, and the classic guess is the "-000": a small +// town often has a single city-wide CEP ending that way, but 957 municipalities +// are coded street by street and never got one. Areal/RJ answers from +// 25845-001 up, never at 25845-000 — so the 404 for 25845000 names Areal. +// +// CEPIni and CEPFim are the smallest and the largest CEP that EXIST in that +// municipality, not the bounds the Correios publish. Do not read the pair as a +// list of valid CEPs: the range is sparse, which is what CEPs is for — Areal's +// 118 CEPs are scattered across nearly five thousand numbers. +type Faixa struct { + Localidade string `json:"localidade"` + UF string `json:"uf"` + Estado string `json:"estado"` + Regiao string `json:"regiao"` + IBGE string `json:"ibge"` + CEPIni string `json:"cep_ini"` + CEPFim string `json:"cep_fim"` + + // CEPs is how many CEPs the municipality actually has inside the range. + CEPs int `json:"ceps"` +} + // Address is a CEP and everything the database knows about it. // // The field names are the API's own, in Portuguese, with only the casing @@ -147,6 +174,26 @@ func decodeGeo(block json.RawMessage) *Geo { } } +// decodeFaixa reads the `faixa` block of a 404 body. Every failure — absent +// block, malformed JSON, a `faixa` without a locality — yields nil: the field +// is an extra on top of an error that is already complete without it, and it +// must never turn a clean ErrCEPNotFound into a parse failure. +func decodeFaixa(body []byte) *Faixa { + if len(body) == 0 { + return nil + } + var envelope struct { + Faixa *Faixa `json:"faixa"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil + } + if envelope.Faixa == nil || envelope.Faixa.Localidade == "" { + return nil + } + return envelope.Faixa +} + func rawString(value json.RawMessage) string { if len(value) == 0 { return "" |