From 83ce95cf10fd9e9d581362453c558239d37ae06e Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:46:40 -0300 Subject: Name the municipality when the CEP does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering only "não encontrei" wastes the trip. A CEP that fails is almost never a random number: it is someone who knows the city and guessed the rest, most often at the "-000" that 957 municipalities never received. The API now names that municipality on the 404, so the reply names it too. It stays an answer to a failure. No 📮, no address block, and no CEP suggested from inside the range — the range is sparse (Areal's 118 CEPs span nearly five thousand numbers), so the reply gives the count and says so instead of letting the two ends read as a list of valid CEPs. cep.is/go v0.1.0 discards the 404 body, so internal/lookup reads it with one extra GET, on that path alone and never inside a retry. Everything that can go wrong there — a slow answer, broken JSON, a faixa naming no municipality — falls back to the reply the bot has always given. The wrapper goes away the day the library exposes the field. --- internal/lookup/lookup.go | 248 ++++++++++++++++++++++++++++++++++++ internal/lookup/lookup_test.go | 279 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 internal/lookup/lookup.go create mode 100644 internal/lookup/lookup_test.go (limited to 'internal/lookup') diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go new file mode 100644 index 0000000..956a731 --- /dev/null +++ b/internal/lookup/lookup.go @@ -0,0 +1,248 @@ +// Package lookup resolves a CEP through the cep.is API. +// +// It is a thin layer over the official client, and it exists for one thing the +// client does not do: a 404 from GET /{cep} may carry a `faixa` block naming +// the municipality that CEP would belong to, and cep.is/go v0.1.0 turns the +// response into [cepis.ErrCEPNotFound] without keeping anything from the body. +// This package puts that block back, as [NotFoundError.Faixa]. +// +// Everything about it is best-effort. The municipality is context on top of an +// error that is already complete without it, so every failure along the way — +// no body, malformed JSON, a slow second request — ends as a plain +// ErrCEPNotFound, exactly as if the API had never sent the block. +package lookup + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + cepis "cep.is/go" +) + +// defaultFaixaTimeout caps the extra request. It is deliberately short and +// deliberately not the timeout of the lookup itself: the CEP is already known +// not to exist, the answer is already writable, and nobody should wait longer +// for the footnote than for the answer. +const defaultFaixaTimeout = 2 * time.Second + +// maxFaixaBody is how much of the 404 body is read. The real one is around 200 +// bytes; anything past this is not the API answering. +const maxFaixaBody = 64 << 10 + +// Faixa is the municipality a CEP that does not exist would fall in, together +// with the range of CEPs that municipality actually has. +// +// 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" that 957 +// municipalities never received. Areal/RJ answers from 25845-001 up and never +// at 25845-000, so the 404 for 25845000 names Areal. +// +// CEPIni and CEPFim are the smallest and the largest CEP that EXIST there, not +// the bounds the Correios publish — the DNE's own start for Areal is the +// 25845000 that just failed. The range is sparse, which is what CEPs is for: +// Areal's 118 CEPs are scattered over nearly five thousand numbers. Never +// present the pair as a list of valid CEPs. +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 has inside the range. + CEPs int `json:"ceps"` +} + +// Place is the municipality as it is written to a user: "Areal/RJ", or just +// "Areal" when the API sent no UF. +func (f *Faixa) Place() string { + if f == nil { + return "" + } + if f.UF == "" { + return f.Localidade + } + return f.Localidade + "/" + f.UF +} + +// NotFoundError is [cepis.ErrCEPNotFound] with the municipality attached. It +// matches the sentinel through errors.Is, so a caller that does not know about +// this type keeps behaving exactly as before: +// +// if errors.Is(err, cepis.ErrCEPNotFound) { ... } +type NotFoundError struct { + // Faixa is the municipality the CEP would belong to. It is never nil in + // an error this package builds: without a faixa there is nothing to add + // and the underlying error is returned untouched. + Faixa *Faixa + + // Err is the error from the client library, wrapped unchanged. + Err error +} + +// Error implements the error interface, reporting the wrapped failure verbatim: +// the faixa is for the user, not for the log line. +func (e *NotFoundError) Error() string { + if e.Err != nil { + return e.Err.Error() + } + return cepis.ErrCEPNotFound.Error() +} + +// Unwrap keeps errors.Is(err, cepis.ErrCEPNotFound) true. +func (e *NotFoundError) Unwrap() error { + if e.Err != nil { + return e.Err + } + return cepis.ErrCEPNotFound +} + +// FaixaOf returns the municipality carried by err, or nil when there is none. +// Any error is safe to pass, including nil. +func FaixaOf(err error) *Faixa { + var notFound *NotFoundError + if errors.As(err, ¬Found) { + return notFound.Faixa + } + return nil +} + +// Client resolves CEPs. It is safe for concurrent use, as the client it wraps +// is. +type Client struct { + api *cepis.Client + httpClient *http.Client + timeout time.Duration +} + +// Option configures a [Client]. +type Option func(*Client) + +// WithTimeout caps the extra request that reads the faixa off a 404. +func WithTimeout(timeout time.Duration) Option { + return func(c *Client) { + if timeout > 0 { + c.timeout = timeout + } + } +} + +// WithHTTPClient supplies the [net/http.Client] used for that request. +func WithHTTPClient(httpClient *http.Client) Option { + return func(c *Client) { + if httpClient != nil { + c.httpClient = httpClient + } + } +} + +// New wraps an already configured cep.is client. +func New(api *cepis.Client, opts ...Option) *Client { + c := &Client{ + api: api, + httpClient: &http.Client{}, + timeout: defaultFaixaTimeout, + } + for _, opt := range opts { + if opt != nil { + opt(c) + } + } + return c +} + +// Lookup resolves a single CEP. +// +// It behaves exactly like [cepis.Client.Lookup], except that a CEP absent from +// the DNE may come back as a [*NotFoundError] naming the municipality. That is +// still a failure and still matches cepis.ErrCEPNotFound; nothing about it says +// an address was found. +func (c *Client) Lookup(ctx context.Context, cep string) (*cepis.Address, error) { + address, err := c.api.Lookup(ctx, cep) + if err == nil || !errors.Is(err, cepis.ErrCEPNotFound) { + return address, err + } + + faixa := c.faixa(ctx, cep) + if faixa == nil { + return nil, err + } + return nil, &NotFoundError{Faixa: faixa, Err: err} +} + +// faixa asks for the CEP once more, only to read the block the library dropped. +// +// One extra GET is the price of not forking the client. It is bounded to the +// 404 path, which is definitive and never retried, so the request cannot land +// in a retry loop; and it returns nil for every problem it meets, because the +// caller already has a complete answer to send without it. +// +// Delete this the day cep.is/go exposes the faixa itself. +func (c *Client) faixa(ctx context.Context, cep string) *Faixa { + number, err := cepis.NormalizeCEP(cep) + if err != nil { + return nil + } + + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.api.BaseURL()+"/"+number, nil) + if err != nil { + return nil + } + req.Header.Set("User-Agent", c.api.UserAgent()) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + + // Anything but a second 404 means the answer changed under us — a CEP + // that now exists, an outage, a captive portal. None of those is a + // municipality. + if resp.StatusCode != http.StatusNotFound { + return nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxFaixaBody)) + if err != nil { + return nil + } + return decodeFaixa(body) +} + +// decodeFaixa reads the `faixa` block of a 404 body. Every failure yields nil: +// an absent block (the API omits it, never sends null), a body that is not +// JSON, a block that names no municipality. A field the API grows later is +// ignored rather than fatal, which is what encoding/json does by default. +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 + } + + faixa := envelope.Faixa + if faixa == nil || strings.TrimSpace(faixa.Localidade) == "" { + return nil + } + if faixa.CEPs < 0 { + faixa.CEPs = 0 + } + return faixa +} diff --git a/internal/lookup/lookup_test.go b/internal/lookup/lookup_test.go new file mode 100644 index 0000000..6eb7b4e --- /dev/null +++ b/internal/lookup/lookup_test.go @@ -0,0 +1,279 @@ +package lookup + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + cepis "cep.is/go" +) + +const areal404 = `{"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}}` + +const plain404 = `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}` + +const address200 = `{"cep":"01001000","logradouro":"Praça da Sé","bairro":"Sé", + "localidade":"São Paulo","uf":"SP","estado":"São Paulo","regiao":"Sudeste","ddd":"11"}` + +// fakeAPI answers every request with the same status and body, and counts how +// many arrived: the count is what proves the extra request happens only on the +// 404 path. +type fakeAPI struct { + server *httptest.Server + requests atomic.Int64 + + // delay is applied from the delayFrom-th request on, which is how the + // second request — the one that reads the faixa — is made slow without + // slowing the lookup that precedes it. + delay time.Duration + delayFrom int64 +} + +func newFakeAPI(t *testing.T, status int, body string) *fakeAPI { + t.Helper() + f := &fakeAPI{delayFrom: 1} + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := f.requests.Add(1) + if f.delay > 0 && n >= f.delayFrom { + select { + case <-time.After(f.delay): + case <-r.Context().Done(): + return + } + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + w.Write([]byte(body)) + })) + t.Cleanup(f.server.Close) + return f +} + +func newClient(t *testing.T, f *fakeAPI, opts ...Option) *Client { + t.Helper() + api, err := cepis.New( + cepis.WithBaseURL(f.server.URL), + cepis.WithMaxAttempts(1), + cepis.WithTimeout(2*time.Second), + ) + if err != nil { + t.Fatalf("cepis.New: %v", err) + } + return New(api, opts...) +} + +func TestNotFoundCarriesTheMunicipality(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, areal404) + _, err := newClient(t, f).Lookup(context.Background(), "25845-000") + + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + faixa := FaixaOf(err) + if faixa == nil { + t.Fatal("the faixa on the 404 was dropped") + } + if faixa.Place() != "Areal/RJ" { + t.Fatalf("place = %q, want Areal/RJ", faixa.Place()) + } + if faixa.CEPs != 118 { + t.Fatalf("ceps = %d, want 118", faixa.CEPs) + } + if faixa.CEPIni != "25845001" || faixa.CEPFim != "25849899" { + t.Fatalf("range = %s..%s, want 25845001..25849899", faixa.CEPIni, faixa.CEPFim) + } + if faixa.IBGE != "3300225" || faixa.Estado != "Rio de Janeiro" || faixa.Regiao != "Sudeste" { + t.Fatalf("faixa lost fields: %+v", faixa) + } +} + +func TestNotFoundWithoutFaixaIsUntouched(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, plain404) + _, err := newClient(t, f).Lookup(context.Background(), "99999999") + + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + if faixa := FaixaOf(err); faixa != nil { + t.Fatalf("invented a municipality: %+v", faixa) + } + var notFound *NotFoundError + if errors.As(err, ¬Found) { + t.Fatal("a 404 without a faixa should come back as the library's own error") + } +} + +// A faixa the bot cannot trust must cost nothing: the reply falls back to the +// plain one, and never to a panic or an unanswered message. +func TestMalformedFaixaFallsBack(t *testing.T) { + bodies := map[string]string{ + "empty body": ``, + "not JSON": `captive portal`, + "truncated": `{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal"`, + "faixa is null": `{"erro":"cep_nao_encontrado","faixa":null}`, + "faixa is a string": `{"erro":"cep_nao_encontrado","faixa":"Areal/RJ"}`, + "faixa is an array": `{"erro":"cep_nao_encontrado","faixa":[{"localidade":"Areal"}]}`, + "no localidade": `{"faixa":{"uf":"RJ","cep_ini":"25845001","cep_fim":"25849899"}}`, + "blank localidade": `{"faixa":{"localidade":" ","uf":"RJ"}}`, + "ceps is a string": `{"faixa":{"localidade":"Areal","uf":"RJ","ceps":"118"}}`, + "whole body is null": `null`, + "whole body is array": `[]`, + } + + for name, body := range bodies { + t.Run(name, func(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, body) + _, err := newClient(t, f).Lookup(context.Background(), "25845000") + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + if faixa := FaixaOf(err); faixa != nil { + t.Fatalf("trusted a broken faixa: %+v", faixa) + } + }) + } +} + +// A negative count is nonsense the formatter would print verbatim, so it is +// flattened to "unknown" while the municipality itself is kept. +func TestNegativeCountIsDropped(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, `{"faixa":{"localidade":"Areal","uf":"RJ","ceps":-3}}`) + _, err := newClient(t, f).Lookup(context.Background(), "25845000") + + faixa := FaixaOf(err) + if faixa == nil { + t.Fatal("the municipality should have survived a bad count") + } + if faixa.CEPs != 0 { + t.Fatalf("ceps = %d, want 0", faixa.CEPs) + } +} + +func TestSuccessCostsOneRequest(t *testing.T) { + f := newFakeAPI(t, http.StatusOK, address200) + address, err := newClient(t, f).Lookup(context.Background(), "01001000") + + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Localidade != "São Paulo" { + t.Fatalf("localidade = %q", address.Localidade) + } + if got := f.requests.Load(); got != 1 { + t.Fatalf("the API saw %d requests for a hit, want 1", got) + } +} + +func TestOtherFailuresAskOnlyOnce(t *testing.T) { + f := newFakeAPI(t, http.StatusInternalServerError, `{"erro":"interno"}`) + _, err := newClient(t, f).Lookup(context.Background(), "50000000") + + if !errors.Is(err, cepis.ErrServer) { + t.Fatalf("err = %v, want a server error", err) + } + if got := f.requests.Load(); got != 1 { + t.Fatalf("the API saw %d requests for a 500, want 1", got) + } +} + +// The faixa is worth one short wait and no more. A slow API still gets an +// answer out to the user, without the municipality. +func TestSlowFaixaIsAbandoned(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, areal404) + client := newClient(t, f, WithTimeout(20*time.Millisecond)) + f.delay, f.delayFrom = 500*time.Millisecond, 2 + + started := time.Now() + _, err := client.Lookup(context.Background(), "25845000") + elapsed := time.Since(started) + + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + if faixa := FaixaOf(err); faixa != nil { + t.Fatalf("used a faixa that arrived too late: %+v", faixa) + } + if elapsed > 400*time.Millisecond { + t.Fatalf("waited %v for the faixa, want it abandoned quickly", elapsed) + } +} + +// A CEP that stopped being a 404 between the two requests is not a +// municipality: the second answer is ignored rather than mixed into the first. +func TestFaixaIgnoresANonNotFoundSecondAnswer(t *testing.T) { + var count atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if count.Add(1) == 1 { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(plain404)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(address200)) + })) + t.Cleanup(server.Close) + + api, err := cepis.New(cepis.WithBaseURL(server.URL), cepis.WithMaxAttempts(1)) + if err != nil { + t.Fatalf("cepis.New: %v", err) + } + + _, err = New(api).Lookup(context.Background(), "25845000") + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + if faixa := FaixaOf(err); faixa != nil { + t.Fatalf("read a municipality out of a 200: %+v", faixa) + } +} + +func TestFaixaOfAcceptsAnything(t *testing.T) { + for name, err := range map[string]error{ + "nil": nil, + "unrelated": errors.New("boom"), + "not found": cepis.ErrCEPNotFound, + "no faixa": &NotFoundError{Err: cepis.ErrCEPNotFound}, + "wrapped": &NotFoundError{Faixa: &Faixa{Localidade: "Areal", UF: "RJ"}, Err: cepis.ErrCEPNotFound}, + "empty type": &NotFoundError{}, + } { + if name == "wrapped" { + if got := FaixaOf(err); got == nil || got.Place() != "Areal/RJ" { + t.Fatalf("%s: FaixaOf = %+v", name, got) + } + continue + } + if got := FaixaOf(err); got != nil { + t.Fatalf("%s: FaixaOf = %+v, want nil", name, got) + } + } +} + +// The zero value still matches the sentinel, so no construction of this type +// can ever produce an error the bot fails to recognise as a not-found. +func TestEmptyNotFoundErrorStillMatches(t *testing.T) { + var err error = &NotFoundError{} + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatal("an empty NotFoundError should still be a not-found") + } + if err.Error() == "" { + t.Fatal("an empty NotFoundError should still say something") + } +} + +func TestPlaceWithoutUF(t *testing.T) { + faixa := &Faixa{Localidade: "Areal"} + if faixa.Place() != "Areal" { + t.Fatalf("place = %q, want Areal", faixa.Place()) + } + var missing *Faixa + if missing.Place() != "" { + t.Fatalf("place = %q, want empty", missing.Place()) + } +} -- cgit v1.3