diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:46:40 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:46:40 -0300 |
| commit | 83ce95cf10fd9e9d581362453c558239d37ae06e (patch) | |
| tree | dcaf1e9258e1c55c2df19f6556e15c39eaf622bb /internal/lookup/lookup.go | |
| parent | 3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (diff) | |
| download | telegram-83ce95cf10fd9e9d581362453c558239d37ae06e.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/lookup.go')
| -rw-r--r-- | internal/lookup/lookup.go | 248 |
1 files changed, 248 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, ¬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 +} |