summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:15:36 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:23:14 -0300
commit47c822d0d5e690092617b6170972917058a6d45d (patch)
tree583ce076573d29a60dc8453df7089022458b7303
parent323d9a8422f06676d25cdf890c06962b260cb8aa (diff)
downloadgo-main.tar.gz
Carry the municipality of a CEP that does not existHEADv0.2.0main
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.md33
-rw-r--r--cepis_test.go48
-rw-r--r--client.go12
-rw-r--r--errors.go16
-rw-r--r--models.go47
5 files changed, 155 insertions, 1 deletions
diff --git a/README.md b/README.md
index 970bbd1..8f0f07e 100644
--- a/README.md
+++ b/README.md
@@ -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"}`))
diff --git a/client.go b/client.go
index 53ada45..f1e3d90 100644
--- a/client.go
+++ b/client.go
@@ -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{
diff --git a/errors.go b/errors.go
index 98a2385..08f909a 100644
--- a/errors.go
+++ b/errors.go
@@ -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
diff --git a/models.go b/models.go
index 3d21bf8..f5eabaa 100644
--- a/models.go
+++ b/models.go
@@ -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 ""