summaryrefslogtreecommitdiffstats
path: root/internal/lookup
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:46:40 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:46:40 -0300
commit83ce95cf10fd9e9d581362453c558239d37ae06e (patch)
treedcaf1e9258e1c55c2df19f6556e15c39eaf622bb /internal/lookup
parent3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (diff)
downloadtelegram-6a856ec45a9182237efa0f779b52a8bf6ac0e7dc.tar.gz
Name the municipality when the CEP does not existv0.2.0
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.
Diffstat (limited to 'internal/lookup')
-rw-r--r--internal/lookup/lookup.go248
-rw-r--r--internal/lookup/lookup_test.go279
2 files changed, 527 insertions, 0 deletions
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, &notFound) {
+ 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, &notFound) {
+ 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": `<html>captive portal</html>`,
+ "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())
+ }
+}