summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--LICENSE15
-rw-r--r--Makefile50
-rw-r--r--README.md219
-rw-r--r--cepis.go103
-rw-r--r--cepis_test.go694
-rw-r--r--client.go572
-rw-r--r--errors.go139
-rw-r--r--go.mod3
-rw-r--r--models.go214
10 files changed, 2015 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9563e6a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+*.exe
+*.test
+*.out
+/bin/
+/dist/
+coverage.html
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ab805de
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,50 @@
+# Development tasks. Everything runs through the `go` tool, so there is
+# nothing to install first.
+#
+# make list the targets
+# make test run the suite
+# make lint gofmt check + go vet, no files touched
+# make build compile every package
+GO ?= go
+
+.DEFAULT_GOAL := help
+.PHONY: help test test-race lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the test suite'
+ @echo ' test-race run it under the race detector'
+ @echo ' lint gofmt -l + go vet (read-only)'
+ @echo ' format apply gofmt (writes)'
+ @echo ' build compile every package'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build and test artefacts'
+
+test:
+ $(GO) test ./...
+
+test-race:
+ $(GO) test -race -count=1 ./...
+
+# Read-only by design, so it is safe in CI and in a pre-commit hook: it reports
+# what `make format` would change without changing anything.
+lint:
+ @files=$$(gofmt -l .); \
+ if [ -n "$$files" ]; then \
+ echo 'gofmt would rewrite:'; echo "$$files"; exit 1; \
+ fi
+ $(GO) vet ./...
+
+format:
+ gofmt -w .
+
+build:
+ $(GO) build ./...
+
+# The order matters: lint is the fastest to fail, so a broken run reports the
+# cheapest problem first.
+check: lint test build
+
+clean:
+ $(GO) clean -testcache
+ rm -f coverage.out coverage.html
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..63f0e96
--- /dev/null
+++ b/README.md
@@ -0,0 +1,219 @@
+# cepis
+
+Go client for the [public CEP API at cep.is](https://cep.is). Official Correios
+data (DNE), refreshed every two weeks. No dependencies beyond the standard
+library.
+
+```go
+addr, err := cepis.Lookup(ctx, "01001-000")
+fmt.Println(addr.Logradouro, addr.Localidade, addr.UF)
+// Praça da Sé São Paulo SP
+```
+
+## Installation
+
+```sh
+go get cep.is/go
+```
+
+Requires Go 1.21 or newer.
+
+```go
+import cepis "cep.is/go"
+```
+
+> `cep.is/go` is a vanity import path and needs a `go-import` meta tag served by
+> cep.is; that route does not exist yet, so `go get` will not resolve until it
+> does.
+
+## Usage
+
+### A single lookup
+
+`Lookup` accepts the CEP masked or unmasked and returns an `*Address`:
+
+```go
+a, err := cepis.Lookup(ctx, "01310-100")
+
+a.Logradouro // "Avenida Paulista"
+a.Localidade // "São Paulo"
+a.UF // "SP"
+a.IBGE // "3550308"
+a.CEP // "01310100" (always unmasked)
+a.FormattedCEP() // "01310-100"
+```
+
+Fields the database does not have for that CEP come back as empty strings,
+never null. `a.Field("name")` reads any field of the raw response, including
+one the API grew after this release.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `Geo` is nil:
+
+```go
+if a.Geo != nil {
+ fmt.Println(a.Geo.Lat, a.Geo.Lon) // -23.567202 -46.649659
+ fmt.Println(a.Geo.RaioM) // 1084
+ fmt.Println(a.Geo.Precisao) // "logradouro"
+}
+```
+
+**Check `Precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `Precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`a.Geo.IsPrecise()` is the shorthand for `Precisao == "logradouro"`:
+
+```go
+if a.Geo != nil && a.Geo.IsPrecise() {
+ cost = shippingFrom(a.Geo.Lat, a.Geo.Lon)
+} else {
+ cost = shippingFromMunicipality(a.IBGE)
+}
+```
+
+### Many CEPs at once
+
+Do not loop over `Lookup`. Use `LookupMany`, which resolves up to 500 CEPs per
+request:
+
+```go
+ceps := []string{"01001000", "99999999", "20040020"}
+result, err := cepis.LookupMany(ctx, ceps)
+
+for i, addr := range result {
+ if addr == nil {
+ fmt.Printf("%s: not found\n", ceps[i])
+ } else {
+ fmt.Printf("%s: %s\n", ceps[i], addr.Localidade)
+ }
+}
+```
+
+The result has **the same length and the same order** as the input. A CEP that
+does not exist, or is malformed, is nil at its position — it does not vanish,
+and it is not an error.
+
+Slices longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the CSV
+> has no geo column. Every `Address` from `LookupMany` has a nil `Geo`,
+> including for CEPs that do have one. Need `Geo`? Use `Lookup`, one at a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version of
+the database** is live:
+
+```go
+h, err := cepis.CheckHealth(ctx)
+
+h.OK() // true
+h.DNE // "26071" identifier of the fortnightly database
+h.CEPs // 1596168
+h.BaseGeradaEm // "2026-07-24T03:48:28Z"
+```
+
+An API reporting itself degraded is not an error — that arrives in `h.Status`.
+
+## Configuration
+
+For anything beyond the defaults, build a `Client` and reuse it. It keeps no
+state between calls and is safe to share across goroutines:
+
+```go
+client, err := cepis.New(
+ cepis.WithBaseURL("https://cep.is"), // point at another instance
+ cepis.WithTimeout(10*time.Second), // cap on one attempt
+ cepis.WithMaxAttempts(3), // counting the first; 1 disables retrying
+ cepis.WithBackoff(500*time.Millisecond, 8*time.Second),
+ cepis.WithTotalTimeout(15*time.Second), // cap on the whole call
+ cepis.WithUserAgent("myapp/1.0"),
+ cepis.WithHTTPClient(&http.Client{}),
+)
+
+addr, err := client.Lookup(ctx, "01001000")
+```
+
+`WithTimeout` bounds a single attempt — connection, response and body read
+together. A call may make several attempts with waits between them; the cap on
+all of that is `WithTotalTimeout`. A deadline on the context passed to a call is
+honoured too, and the earlier of the two wins.
+
+Retries cover 429, 5xx and network failures, with exponential backoff and full
+jitter; 400 and 404 are never retried. See the [package
+documentation](https://pkg.go.dev/cep.is/go) for each option.
+
+The package-level functions (`cepis.Lookup`, etc.) use a shared default client,
+reachable as `cepis.Default()`. It applies process-wide, third-party libraries
+included. Need a different configuration? Build your own.
+
+### Pointing at a local instance
+
+`WithBaseURL` is the only setting that usually changes between environments. The
+API repo brings a local instance up with `just up`:
+
+```go
+client, err := cepis.New(cepis.WithBaseURL("http://localhost:8080"))
+```
+
+## Errors
+
+Every error matches `cepis.Err`. Below that, the split is by **the decision you
+have to make**, not by the HTTP status that caused it:
+
+| error | when | does retrying help? |
+| --- | --- | --- |
+| `ErrInvalidCEP` | not 8 digits | no |
+| `ErrCEPNotFound` | well-formed, not in the database | no |
+| `ErrRateLimited` | 429 | yes, later |
+| `ErrServer` | 5xx | yes, later |
+| `ErrNetwork` | DNS, connection, timeout, TLS | yes, later |
+| `ErrInvalidResponse` | unintelligible response | probably not |
+| `ErrInvalidConfig` | a bad option given to `New` | no |
+
+```go
+addr, err := cepis.Lookup(ctx, cepFromForm)
+switch {
+case errors.Is(err, cepis.ErrCEPNotFound):
+ // ask the user for another CEP
+case errors.Is(err, cepis.Err):
+ // transient failure: reschedule
+}
+```
+
+The concrete type behind every one of them is `*cepis.Error`, which carries the
+CEP, the HTTP status and the server's `Retry-After`:
+
+```go
+var cerr *cepis.Error
+if errors.As(err, &cerr) && errors.Is(cerr, cepis.ErrRateLimited) {
+ reschedule(cerr.RetryAfter)
+}
+```
+
+`ErrInvalidResponse` almost always means something between you and the API is
+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.
+
+## Development
+
+Everything runs through `make`:
+
+```sh
+make # list the targets
+make test # run the suite
+make test-race # run it under the race detector
+make lint # gofmt -l + go vet, touches nothing
+make build # compile every package
+make check # lint, then test, then build
+```
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/cepis.go b/cepis.go
new file mode 100644
index 0000000..b596c12
--- /dev/null
+++ b/cepis.go
@@ -0,0 +1,103 @@
+// Package cepis is a Go client for the public CEP API at https://cep.is.
+// Official Correios data (DNE), refreshed every two weeks.
+//
+// For a script, the package-level functions are enough — they share one
+// default [Client] with the default configuration:
+//
+// addr, err := cepis.Lookup(ctx, "01001-000")
+// if err != nil {
+// return err
+// }
+// fmt.Println(addr.Logradouro, addr.Localidade, addr.UF)
+//
+// In an application, build a client once and reuse it:
+//
+// client, err := cepis.New(
+// cepis.WithTimeout(5*time.Second),
+// cepis.WithTotalTimeout(15*time.Second),
+// )
+//
+// Pointing at a local instance (the API repo brings one up with `just up`):
+//
+// client, err := cepis.New(cepis.WithBaseURL("http://localhost:8080"))
+//
+// Errors are split by the decision the caller has to make — whether retrying
+// helps or not — and all of them match [Err]:
+//
+// switch {
+// case errors.Is(err, cepis.ErrCEPNotFound):
+// // the CEP does not exist: ask the user for another
+// case errors.Is(err, cepis.Err):
+// // network, server, rate limit: retry later
+// }
+//
+// The API surface is English, but the data fields keep the API's own
+// Portuguese names — Logradouro, Bairro, Localidade. What you read in the JSON
+// is what you access on the struct.
+package cepis
+
+import (
+ "context"
+ "strings"
+)
+
+var defaultClient = mustDefault()
+
+func mustDefault() *Client {
+ client, err := New()
+ if err != nil {
+ panic("cepis: default client is not constructible: " + err.Error())
+ }
+ return client
+}
+
+// Default returns the client behind the package-level functions. It uses the
+// default configuration and is safe to share across goroutines. It cannot be
+// reconfigured on purpose: it applies process-wide, third-party libraries
+// included. Anyone needing different settings should build their own with
+// [New].
+func Default() *Client { return defaultClient }
+
+// Lookup resolves a CEP using the default client. See [Client.Lookup].
+func Lookup(ctx context.Context, cep string) (*Address, error) {
+ return defaultClient.Lookup(ctx, cep)
+}
+
+// LookupMany resolves many CEPs in one batch using the default client. See
+// [Client.LookupMany], especially the note that batches carry no coordinate.
+func LookupMany(ctx context.Context, ceps []string) ([]*Address, error) {
+ return defaultClient.LookupMany(ctx, ceps)
+}
+
+// CheckHealth queries the API's health using the default client. See
+// [Client.Health]. It is not called Health because that name belongs to the
+// type it returns.
+func CheckHealth(ctx context.Context) (*Health, error) {
+ return defaultClient.Health(ctx)
+}
+
+// NormalizeCEP returns the CEP's 8 digits, accepting the mask: "01001-000",
+// "01001000" and " 01001-000 " all become "01001000".
+//
+// It fails with [ErrInvalidCEP] unless exactly 8 digits remain. Every lookup
+// runs it before making a request: a 7-digit CEP is the caller's bug, and
+// spending a round trip for the server to say so wastes both sides' time.
+func NormalizeCEP(value string) (string, error) {
+ cleaned := strings.Map(func(r rune) rune {
+ switch r {
+ case '-', '.', ' ', '\t', '\n', '\r':
+ return -1
+ }
+ return r
+ }, value)
+
+ if len(cleaned) != 8 {
+ return "", newError(ErrInvalidCEP, "CEP must be 8 digits, got %q", value)
+ }
+ for _, r := range cleaned {
+ if r < '0' || r > '9' {
+ return "", newError(ErrInvalidCEP, "CEP must be 8 digits, got %q", value)
+ }
+ }
+ return cleaned, nil
+}
diff --git a/cepis_test.go b/cepis_test.go
new file mode 100644
index 0000000..888ded5
--- /dev/null
+++ b/cepis_test.go
@@ -0,0 +1,694 @@
+package cepis_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ cepis "cep.is/go"
+)
+
+const seAddressJSON = `{"cep":"01001000","logradouro":"Praça da Sé",` +
+ `"complemento":"lado ímpar","unidade":"","bairro":"Sé",` +
+ `"localidade":"São Paulo","uf":"SP","estado":"São Paulo","regiao":"Sudeste",` +
+ `"ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107"}`
+
+const csvHeader = "cep,logradouro,complemento,unidade,bairro,localidade,uf," +
+ "estado,regiao,ibge,gia,ddd,siafi"
+
+type stub struct {
+ status int
+ body string
+ header map[string]string
+}
+
+type fakeAPI struct {
+ mu sync.Mutex
+ routes map[string][]stub
+ calls []string
+ custom func(path string, n int) stub
+ server *httptest.Server
+}
+
+func newFakeAPI(t *testing.T) *fakeAPI {
+ t.Helper()
+ api := &fakeAPI{routes: map[string][]stub{}}
+ api.server = httptest.NewServer(http.HandlerFunc(api.serve))
+ t.Cleanup(api.server.Close)
+ return api
+}
+
+func (a *fakeAPI) serve(w http.ResponseWriter, r *http.Request) {
+ a.mu.Lock()
+ a.calls = append(a.calls, r.URL.RequestURI())
+ count := len(a.calls)
+ var answer stub
+ if a.custom != nil {
+ answer = a.custom(r.URL.RequestURI(), count)
+ } else {
+ answers, ok := a.routes[r.URL.RequestURI()]
+ switch {
+ case !ok || len(answers) == 0:
+ answer = stub{status: 404, body: `{"erro":"cep_nao_encontrado"}`}
+ case len(answers) == 1:
+ answer = answers[0]
+ default:
+ answer = answers[0]
+ a.routes[r.URL.RequestURI()] = answers[1:]
+ }
+ }
+ a.mu.Unlock()
+
+ for key, value := range answer.header {
+ w.Header().Set(key, value)
+ }
+ w.WriteHeader(answer.status)
+ fmt.Fprint(w, answer.body)
+}
+
+func (a *fakeAPI) respond(path string, answers ...stub) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ a.routes[path] = answers
+}
+
+func (a *fakeAPI) countPrefix(prefix string) int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ total := 0
+ for _, call := range a.calls {
+ if strings.HasPrefix(call, prefix) {
+ total++
+ }
+ }
+ return total
+}
+
+func (a *fakeAPI) totalCalls() int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return len(a.calls)
+}
+
+func newClient(t *testing.T, api *fakeAPI, opts ...cepis.Option) *cepis.Client {
+ t.Helper()
+ base := []cepis.Option{
+ cepis.WithBaseURL(api.server.URL),
+ cepis.WithTimeout(5 * time.Second),
+ cepis.WithBackoff(time.Millisecond, 10*time.Millisecond),
+ }
+ client, err := cepis.New(append(base, opts...)...)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return client
+}
+
+func ok(status int, body string) stub {
+ return stub{status: status, body: body}
+}
+
+func TestNormalizeCEPAcceptsMaskedAndUnmasked(t *testing.T) {
+ for _, value := range []string{"01001000", "01001-000", " 01001-000 ", "01001.000"} {
+ got, err := cepis.NormalizeCEP(value)
+ if err != nil {
+ t.Fatalf("NormalizeCEP(%q): %v", value, err)
+ }
+ if got != "01001000" {
+ t.Errorf("NormalizeCEP(%q) = %q, want 01001000", value, got)
+ }
+ }
+}
+
+func TestNormalizeCEPRejectsBadFormat(t *testing.T) {
+ for _, value := range []string{"", "123", "0100100a", "010010000", "abcdefgh"} {
+ if _, err := cepis.NormalizeCEP(value); !errors.Is(err, cepis.ErrInvalidCEP) {
+ t.Errorf("NormalizeCEP(%q) error = %v, want ErrInvalidCEP", value, err)
+ }
+ }
+}
+
+func TestErrorsShareOneRoot(t *testing.T) {
+ _, err := cepis.NormalizeCEP("123")
+ if !errors.Is(err, cepis.Err) {
+ t.Fatalf("error %v does not match cepis.Err", err)
+ }
+}
+
+func TestLookupSuccess(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", ok(200, seAddressJSON))
+
+ address, err := newClient(t, api).Lookup(context.Background(), "01001-000")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if address.Localidade != "São Paulo" || address.UF != "SP" {
+ t.Errorf("got %q/%q, want São Paulo/SP", address.Localidade, address.UF)
+ }
+ if address.IBGE != "3550308" || address.DDD != "11" {
+ t.Errorf("got ibge %q ddd %q", address.IBGE, address.DDD)
+ }
+ if address.FormattedCEP() != "01001-000" {
+ t.Errorf("FormattedCEP = %q", address.FormattedCEP())
+ }
+ if address.Geo != nil {
+ t.Errorf("Geo = %+v, want nil", address.Geo)
+ }
+}
+
+func TestLookupSendsHeaders(t *testing.T) {
+ var agent, accept string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ agent, accept = r.Header.Get("User-Agent"), r.Header.Get("Accept")
+ fmt.Fprint(w, seAddressJSON)
+ }))
+ defer server.Close()
+
+ client, err := cepis.New(cepis.WithBaseURL(server.URL))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if _, err := client.Lookup(context.Background(), "01001000"); err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if !strings.HasPrefix(agent, "cepis-go/") {
+ t.Errorf("User-Agent = %q", agent)
+ }
+ if accept == "" {
+ t.Error("Accept header not sent")
+ }
+}
+
+func TestLookupReadsTheCoordinate(t *testing.T) {
+ api := newFakeAPI(t)
+ body := withGeo(t, `{"lat":-23.5,"lon":-46.6,"raio_m":120,"precisao":"logradouro"}`)
+ api.respond("/01310100", ok(200, body))
+
+ address, err := newClient(t, api).Lookup(context.Background(), "01310100")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if address.Geo == nil {
+ t.Fatal("Geo is nil")
+ }
+ if address.Geo.Lat != -23.5 || address.Geo.Lon != -46.6 || address.Geo.RaioM != 120 {
+ t.Errorf("Geo = %+v", *address.Geo)
+ }
+ if !address.Geo.IsPrecise() {
+ t.Error("IsPrecise = false, want true for precisao=logradouro")
+ }
+}
+
+func TestLookupMunicipalityCoordinateIsNotPrecise(t *testing.T) {
+ api := newFakeAPI(t)
+ body := withGeo(t, `{"lat":-23.5,"lon":-46.6,"raio_m":22000,"precisao":"localidade"}`)
+ api.respond("/01001000", ok(200, body))
+
+ address, err := newClient(t, api).Lookup(context.Background(), "01001000")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if address.Geo == nil || address.Geo.IsPrecise() {
+ t.Errorf("Geo = %+v, want an imprecise coordinate", address.Geo)
+ }
+}
+
+func TestLookupMalformedGeoDoesNotLoseTheAddress(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", ok(200, withGeo(t, `{"lat":"not a number"}`)))
+
+ address, err := newClient(t, api).Lookup(context.Background(), "01001000")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if address.Geo != nil {
+ t.Errorf("Geo = %+v, want nil", address.Geo)
+ }
+ if address.Localidade != "São Paulo" {
+ t.Errorf("Localidade = %q, the address was lost", address.Localidade)
+ }
+}
+
+func TestLookupToleratesUnknownFields(t *testing.T) {
+ api := newFakeAPI(t)
+ body := strings.TrimSuffix(seAddressJSON, "}") + `,"campo_do_futuro":"value"}`
+ api.respond("/01001000", ok(200, body))
+
+ address, err := newClient(t, api).Lookup(context.Background(), "01001000")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if got := address.Field("campo_do_futuro"); got != "value" {
+ t.Errorf("Field(campo_do_futuro) = %q, want value", got)
+ }
+}
+
+func TestLookupNotFound(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/99999999", ok(404, `{"erro":"cep_nao_encontrado","mensagem":"gone"}`))
+
+ _, err := newClient(t, api).Lookup(context.Background(), "99999999")
+ if !errors.Is(err, cepis.ErrCEPNotFound) {
+ t.Fatalf("error = %v, want ErrCEPNotFound", err)
+ }
+ var cerr *cepis.Error
+ if !errors.As(err, &cerr) {
+ t.Fatalf("error %v is not a *cepis.Error", err)
+ }
+ if cerr.CEP != "99999999" || cerr.Status != 404 {
+ t.Errorf("Error = %+v", cerr)
+ }
+}
+
+func TestLookupNotFoundIsNotRetried(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/99999999", ok(404, `{"erro":"cep_nao_encontrado"}`))
+
+ if _, err := newClient(t, api).Lookup(context.Background(), "99999999"); err == nil {
+ t.Fatal("expected an error")
+ }
+ if got := api.countPrefix("/99999999"); got != 1 {
+ t.Errorf("%d requests, want 1: a 404 is definitive", got)
+ }
+}
+
+func TestLookupInvalidCEPNeverLeavesTheMachine(t *testing.T) {
+ api := newFakeAPI(t)
+
+ _, err := newClient(t, api).Lookup(context.Background(), "123")
+ if !errors.Is(err, cepis.ErrInvalidCEP) {
+ t.Fatalf("error = %v, want ErrInvalidCEP", err)
+ }
+ if got := api.totalCalls(); got != 0 {
+ t.Errorf("%d requests made, want 0", got)
+ }
+}
+
+func TestLookupAPI400BecomesInvalidCEP(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/00000000", ok(400, `{"erro":"cep_invalido","mensagem":"rejected by the API"}`))
+
+ _, err := newClient(t, api).Lookup(context.Background(), "00000000")
+ if !errors.Is(err, cepis.ErrInvalidCEP) {
+ t.Fatalf("error = %v, want ErrInvalidCEP", err)
+ }
+ if !strings.Contains(err.Error(), "rejected by the API") {
+ t.Errorf("error %q does not carry the API message", err)
+ }
+ if got := api.countPrefix("/00000000"); got != 1 {
+ t.Errorf("%d requests, want 1: a 400 is definitive", got)
+ }
+}
+
+func TestLookupResponseThatIsNotJSON(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", ok(200, "<html>captive portal</html>"))
+
+ _, err := newClient(t, api).Lookup(context.Background(), "01001000")
+ if !errors.Is(err, cepis.ErrInvalidResponse) {
+ t.Fatalf("error = %v, want ErrInvalidResponse", err)
+ }
+}
+
+func TestRetriesOn500ThenSucceeds(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", ok(500, `{"erro":"interno"}`), ok(200, seAddressJSON))
+
+ address, err := newClient(t, api).Lookup(context.Background(), "01001000")
+ if err != nil {
+ t.Fatalf("Lookup: %v", err)
+ }
+ if address.Localidade != "São Paulo" {
+ t.Errorf("Localidade = %q", address.Localidade)
+ }
+ if got := api.countPrefix("/01001000"); got != 2 {
+ t.Errorf("%d requests, want 2", got)
+ }
+}
+
+func TestRetryExhaustsAttempts(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", ok(503, `{"erro":"interno"}`))
+
+ _, err := newClient(t, api).Lookup(context.Background(), "01001000")
+ if !errors.Is(err, cepis.ErrServer) {
+ t.Fatalf("error = %v, want ErrServer", err)
+ }
+ var cerr *cepis.Error
+ if errors.As(err, &cerr); cerr.Status != 503 {
+ t.Errorf("Status = %d, want 503", cerr.Status)
+ }
+ if got := api.countPrefix("/01001000"); got != 3 {
+ t.Errorf("%d requests, want the documented default of 3", got)
+ }
+}
+
+func TestMaxAttemptsOneDisablesRetry(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", ok(500, "{}"))
+
+ client := newClient(t, api, cepis.WithMaxAttempts(1))
+ if _, err := client.Lookup(context.Background(), "01001000"); !errors.Is(err, cepis.ErrServer) {
+ t.Fatalf("error = %v, want ErrServer", err)
+ }
+ if got := api.countPrefix("/01001000"); got != 1 {
+ t.Errorf("%d requests, want 1", got)
+ }
+}
+
+func TestRateLimitedCarriesRetryAfter(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", stub{status: 429, body: "{}", header: map[string]string{"Retry-After": "7"}})
+
+ _, err := newClient(t, api).Lookup(context.Background(), "01001000")
+ if !errors.Is(err, cepis.ErrRateLimited) {
+ t.Fatalf("error = %v, want ErrRateLimited", err)
+ }
+ var cerr *cepis.Error
+ if errors.As(err, &cerr); cerr.RetryAfter != 7*time.Second {
+ t.Errorf("RetryAfter = %v, want 7s", cerr.RetryAfter)
+ }
+}
+
+func TestRetryAfterHonoursTheCap(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", stub{status: 429, body: "{}", header: map[string]string{"Retry-After": "3600"}})
+
+ started := time.Now()
+ if _, err := newClient(t, api).Lookup(context.Background(), "01001000"); err == nil {
+ t.Fatal("expected an error")
+ }
+ if elapsed := time.Since(started); elapsed > 5*time.Second {
+ t.Errorf("waited %v: an absurd Retry-After must not park the caller", elapsed)
+ }
+}
+
+func TestRetryAfterAcceptsAnHTTPDate(t *testing.T) {
+ api := newFakeAPI(t)
+ when := time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat)
+ api.respond("/01001000", stub{status: 429, body: "{}", header: map[string]string{"Retry-After": when}})
+
+ started := time.Now()
+ _, err := newClient(t, api).Lookup(context.Background(), "01001000")
+ if !errors.Is(err, cepis.ErrRateLimited) {
+ t.Fatalf("error = %v, want ErrRateLimited", err)
+ }
+ var cerr *cepis.Error
+ errors.As(err, &cerr)
+ if cerr.RetryAfter < 30*time.Second {
+ t.Errorf("RetryAfter = %v, the HTTP date form was not parsed", cerr.RetryAfter)
+ }
+ if elapsed := time.Since(started); elapsed > 5*time.Second {
+ t.Errorf("waited %v: the cap must apply to the date form too", elapsed)
+ }
+}
+
+func TestNetworkErrorWhenNothingListens(t *testing.T) {
+ api := newFakeAPI(t)
+ url := api.server.URL
+ api.server.Close()
+
+ client, err := cepis.New(
+ cepis.WithBaseURL(url),
+ cepis.WithMaxAttempts(2),
+ cepis.WithBackoff(time.Millisecond, 10*time.Millisecond),
+ cepis.WithTimeout(time.Second),
+ )
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if _, err := client.Lookup(context.Background(), "01001000"); !errors.Is(err, cepis.ErrNetwork) {
+ t.Fatalf("error = %v, want ErrNetwork", err)
+ }
+}
+
+func TestTotalTimeoutBoundsTheWholeCall(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", ok(500, "{}"))
+
+ client := newClient(t, api,
+ cepis.WithMaxAttempts(50),
+ cepis.WithBackoff(50*time.Millisecond, 50*time.Millisecond),
+ cepis.WithTotalTimeout(300*time.Millisecond),
+ )
+
+ started := time.Now()
+ if _, err := client.Lookup(context.Background(), "01001000"); err == nil {
+ t.Fatal("expected an error")
+ }
+ if elapsed := time.Since(started); elapsed > 3*time.Second {
+ t.Errorf("took %v, the deadline did not cut the run short", elapsed)
+ }
+ if got := api.countPrefix("/01001000"); got >= 50 {
+ t.Errorf("%d requests: the deadline did not cut the run short", got)
+ }
+}
+
+func TestContextCancellationStopsTheCall(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/01001000", ok(500, "{}"))
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ client := newClient(t, api,
+ cepis.WithMaxAttempts(50),
+ cepis.WithBackoff(30*time.Millisecond, 30*time.Millisecond),
+ )
+ if _, err := client.Lookup(ctx, "01001000"); err == nil {
+ t.Fatal("expected an error")
+ }
+ if got := api.countPrefix("/01001000"); got >= 50 {
+ t.Errorf("%d requests: the cancelled context was ignored", got)
+ }
+}
+
+func csvBody(rows ...string) string {
+ return csvHeader + "\n" + strings.Join(rows, "\n") + "\n"
+}
+
+func TestBatchPreservesOrderAndMarksMissing(t *testing.T) {
+ api := newFakeAPI(t)
+ body := csvBody(
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001",
+ )
+ api.respond("/csv?ceps=01001000%2C99999999%2C20040020", ok(200, body))
+
+ result, err := newClient(t, api).LookupMany(
+ context.Background(),
+ []string{"01001000", "99999999", "20040020"},
+ )
+ if err != nil {
+ t.Fatalf("LookupMany: %v", err)
+ }
+ if len(result) != 3 {
+ t.Fatalf("len = %d, want 3", len(result))
+ }
+ if result[0] == nil || result[0].Localidade != "São Paulo" {
+ t.Errorf("result[0] = %+v", result[0])
+ }
+ if result[1] != nil {
+ t.Errorf("result[1] = %+v, want nil for a missing CEP", result[1])
+ }
+ if result[2] == nil || result[2].UF != "RJ" {
+ t.Errorf("result[2] = %+v", result[2])
+ }
+ if result[0].Geo != nil {
+ t.Error("a batch must never carry a coordinate")
+ }
+}
+
+func TestBatchParsesQuotedFields(t *testing.T) {
+ api := newFakeAPI(t)
+ body := csvBody(`01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1`)
+ api.respond("/csv?ceps=01001000", ok(200, body))
+
+ result, err := newClient(t, api).LookupMany(context.Background(), []string{"01001000"})
+ if err != nil {
+ t.Fatalf("LookupMany: %v", err)
+ }
+ if result[0] == nil || result[0].Logradouro != "Rua Um, Dois" {
+ t.Errorf("result[0] = %+v", result[0])
+ }
+}
+
+func TestBatchEmptyListMakesNoRequest(t *testing.T) {
+ api := newFakeAPI(t)
+
+ result, err := newClient(t, api).LookupMany(context.Background(), nil)
+ if err != nil {
+ t.Fatalf("LookupMany: %v", err)
+ }
+ if len(result) != 0 {
+ t.Errorf("len = %d, want 0", len(result))
+ }
+ if got := api.totalCalls(); got != 0 {
+ t.Errorf("%d requests made, want 0", got)
+ }
+}
+
+func TestBatchChunksAboveTheAPILimit(t *testing.T) {
+ api := newFakeAPI(t)
+
+ ceps := make([]string, 501)
+ for i := range ceps {
+ ceps[i] = fmt.Sprintf("%08d", i)
+ }
+ row := func(cep string) string {
+ return cep + ",,,,,X,SP,São Paulo,Sudeste,1,,11,1"
+ }
+ first := make([]string, 0, cepis.MaxBatch)
+ for _, cep := range ceps[:cepis.MaxBatch] {
+ first = append(first, row(cep))
+ }
+ api.custom = func(_ string, n int) stub {
+ if n == 1 {
+ return ok(200, csvBody(first...))
+ }
+ return ok(200, csvBody(row(ceps[cepis.MaxBatch])))
+ }
+
+ result, err := newClient(t, api).LookupMany(context.Background(), ceps)
+ if err != nil {
+ t.Fatalf("LookupMany: %v", err)
+ }
+ if got := api.totalCalls(); got != 2 {
+ t.Errorf("%d requests, want 2: 501 CEPs exceed the API limit of 500", got)
+ }
+ if len(result) != 501 {
+ t.Errorf("len = %d, want 501", len(result))
+ }
+ if result[500] == nil || result[500].CEP != ceps[500] {
+ t.Errorf("result[500] = %+v", result[500])
+ }
+}
+
+func TestBatchTruncatedResponseDoesNotShift(t *testing.T) {
+ api := newFakeAPI(t)
+ body := csvBody("01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1")
+ api.respond("/csv?ceps=01001000%2C20040020", ok(200, body))
+
+ result, err := newClient(t, api).LookupMany(
+ context.Background(),
+ []string{"01001000", "20040020"},
+ )
+ if err != nil {
+ t.Fatalf("LookupMany: %v", err)
+ }
+ if len(result) != 2 {
+ t.Fatalf("len = %d, want 2: padding beats shifting", len(result))
+ }
+ if result[1] != nil {
+ t.Errorf("result[1] = %+v, want nil", result[1])
+ }
+}
+
+func TestBatchMalformedCEPBecomesNil(t *testing.T) {
+ api := newFakeAPI(t)
+ body := csvBody(
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ "nope,,,,,,,,,,,,",
+ )
+ api.respond("/csv?ceps=01001000%2Cnope", ok(200, body))
+
+ result, err := newClient(t, api).LookupMany(context.Background(), []string{"01001000", "nope"})
+ if err != nil {
+ t.Fatalf("LookupMany: %v", err)
+ }
+ if result[1] != nil {
+ t.Errorf("result[1] = %+v, want nil", result[1])
+ }
+}
+
+func TestHealth(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/health", ok(200, `{"status":"ok","dne":"26071","ceps":1596168,`+
+ `"geo":894489,"base_gerada_em":"2026-07-24T03:48:28Z"}`))
+
+ health, err := newClient(t, api).Health(context.Background())
+ if err != nil {
+ t.Fatalf("Health: %v", err)
+ }
+ if !health.OK() {
+ t.Error("OK = false")
+ }
+ if health.DNE != "26071" || health.CEPs != 1596168 || health.Geo != 894489 {
+ t.Errorf("Health = %+v", *health)
+ }
+ if health.BaseGeradaEm != "2026-07-24T03:48:28Z" {
+ t.Errorf("BaseGeradaEm = %q", health.BaseGeradaEm)
+ }
+}
+
+func TestHealthDegradedIsNotAnError(t *testing.T) {
+ api := newFakeAPI(t)
+ api.respond("/health", ok(503, `{"status":"degradado","dne":"","ceps":0,"geo":0}`))
+
+ health, err := newClient(t, api).Health(context.Background())
+ if err != nil {
+ t.Fatalf("Health: %v", err)
+ }
+ if health.OK() {
+ t.Error("OK = true, want false for a degraded API")
+ }
+ if got := api.countPrefix("/health"); got != 1 {
+ t.Errorf("%d requests, want 1: a reported degradation is an answer, not a failure", got)
+ }
+}
+
+func TestTrailingSlashIsNotDuplicated(t *testing.T) {
+ client, err := cepis.New(cepis.WithBaseURL("http://example.test/"))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if client.BaseURL() != "http://example.test" {
+ t.Errorf("BaseURL = %q", client.BaseURL())
+ }
+}
+
+func TestInvalidConfiguration(t *testing.T) {
+ cases := map[string]cepis.Option{
+ "zero attempts": cepis.WithMaxAttempts(0),
+ "zero timeout": cepis.WithTimeout(0),
+ "empty base": cepis.WithBaseURL(" "),
+ "nil http": cepis.WithHTTPClient(nil),
+ }
+ for name, opt := range cases {
+ if _, err := cepis.New(opt); !errors.Is(err, cepis.ErrInvalidConfig) {
+ t.Errorf("%s: error = %v, want ErrInvalidConfig", name, err)
+ }
+ }
+}
+
+func TestDefaultClientIdentifiesTheLibrary(t *testing.T) {
+ if agent := cepis.Default().UserAgent(); !strings.HasPrefix(agent, "cepis-go/"+cepis.Version) {
+ t.Errorf("User-Agent = %q", agent)
+ }
+ if cepis.Default().BaseURL() != cepis.DefaultBaseURL {
+ t.Errorf("BaseURL = %q", cepis.Default().BaseURL())
+ }
+}
+
+func withGeo(t *testing.T, geo string) string {
+ t.Helper()
+ var payload map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(seAddressJSON), &payload); err != nil {
+ t.Fatalf("fixture: %v", err)
+ }
+ payload["geo"] = json.RawMessage(geo)
+ body, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatalf("fixture: %v", err)
+ }
+ return string(body)
+}
diff --git a/client.go b/client.go
new file mode 100644
index 0000000..53ada45
--- /dev/null
+++ b/client.go
@@ -0,0 +1,572 @@
+package cepis
+
+import (
+ "bytes"
+ "context"
+ "encoding/csv"
+ "encoding/json"
+ "errors"
+ "io"
+ "math/rand"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Version is the version of this library, as reported in the User-Agent.
+const Version = "0.1.0"
+
+// DefaultBaseURL is where the public API lives.
+const DefaultBaseURL = "https://cep.is"
+
+// MaxBatch is the API's ceiling on CEPs per batch request. [Client.LookupMany]
+// splits longer input into chunks of this size on its own.
+const MaxBatch = 500
+
+const acceptHeader = "application/json, text/csv;q=0.9, */*;q=0.1"
+
+func isRetriable(status int) bool {
+ switch status {
+ case http.StatusTooManyRequests,
+ http.StatusInternalServerError,
+ http.StatusBadGateway,
+ http.StatusServiceUnavailable,
+ http.StatusGatewayTimeout:
+ return true
+ }
+ return false
+}
+
+// Client talks to the cep.is API.
+//
+// Build one with [New] and reuse it: the configuration is fixed after
+// construction and no method keeps state between calls, so a single Client is
+// safe to share across goroutines. Reuse also lets the underlying
+// [net/http.Client] pool connections, which a fresh one per call cannot do.
+//
+// For thousands of CEPs do not write a loop over [Client.Lookup] — use
+// [Client.LookupMany], which resolves up to [MaxBatch] of them per request.
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+ timeout time.Duration
+ maxAttempts int
+ backoffBase time.Duration
+ backoffMax time.Duration
+ totalTimeout time.Duration
+ userAgent string
+}
+
+// Option configures a [Client]. Options are applied in order by [New], and an
+// invalid value is reported as an error rather than silently ignored.
+type Option func(*Client) error
+
+// WithBaseURL points the client at another instance of the API — a local one
+// for development (the API repo brings one up on http://localhost:8080 with
+// `just up`), or a test server. It is the only setting that usually changes
+// between environments. A trailing slash is stripped, so it cannot produce the
+// doubled separator that some proxies normalise and others answer 404 to.
+func WithBaseURL(baseURL string) Option {
+ return func(c *Client) error {
+ trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ if trimmed == "" {
+ return newError(ErrInvalidConfig, "base URL must not be empty")
+ }
+ c.baseURL = trimmed
+ return nil
+ }
+}
+
+// WithTimeout caps a single attempt: connection, response and body read
+// together. It is not a cap on the whole call, because a call may make several
+// attempts with waits between them — that cap is [WithTotalTimeout].
+func WithTimeout(timeout time.Duration) Option {
+ return func(c *Client) error {
+ if timeout <= 0 {
+ return newError(ErrInvalidConfig, "timeout must be > 0")
+ }
+ c.timeout = timeout
+ return nil
+ }
+}
+
+// WithMaxAttempts sets how many attempts a call makes in total, counting the
+// first. 1 disables retrying. The default of 3 covers the common case (one
+// transient failure) without turning a real outage into a long wait.
+func WithMaxAttempts(attempts int) Option {
+ return func(c *Client) error {
+ if attempts < 1 {
+ return newError(ErrInvalidConfig, "max attempts must be >= 1")
+ }
+ c.maxAttempts = attempts
+ return nil
+ }
+}
+
+// WithBackoff sets the exponential wait between attempts.
+//
+// base is the first ceiling: attempt n waits a random duration drawn from
+// [0, min(base*2^(n-1), max)] — full jitter. The randomness is not decoration.
+// Without it, N clients that fail together come back together and reproduce
+// the spike that took the service down.
+//
+// max caps each individual wait. It applies to a server-sent Retry-After too,
+// or a `Retry-After: 3600` from a misconfigured proxy would park the caller
+// for an hour.
+func WithBackoff(base, max time.Duration) Option {
+ return func(c *Client) error {
+ if base < 0 || max < 0 {
+ return newError(ErrInvalidConfig, "backoff must not be negative")
+ }
+ c.backoffBase = base
+ c.backoffMax = max
+ return nil
+ }
+}
+
+// WithTotalTimeout caps the whole call, attempts and waits included. Zero
+// leaves the attempt count as the only bound. Set it in code serving a web
+// request: without it the worst case is max attempts times the per-attempt
+// timeout, plus the sum of the backoffs.
+//
+// A deadline already carried by the context passed to a call is honoured
+// regardless, and the earlier of the two wins.
+func WithTotalTimeout(timeout time.Duration) Option {
+ return func(c *Client) error {
+ if timeout < 0 {
+ return newError(ErrInvalidConfig, "total timeout must not be negative")
+ }
+ c.totalTimeout = timeout
+ return nil
+ }
+}
+
+// WithUserAgent overrides the User-Agent header. The default identifies the
+// library and its version, which is what lets the server diagnose an old
+// client misbehaving.
+func WithUserAgent(userAgent string) Option {
+ return func(c *Client) error {
+ if strings.TrimSpace(userAgent) == "" {
+ return newError(ErrInvalidConfig, "user agent must not be empty")
+ }
+ c.userAgent = userAgent
+ return nil
+ }
+}
+
+// WithHTTPClient supplies the [net/http.Client] used for every request — for a
+// custom transport, a proxy, or instrumentation. Its own Timeout field is left
+// untouched but is redundant here: this library bounds each attempt through
+// the request context.
+func WithHTTPClient(httpClient *http.Client) Option {
+ return func(c *Client) error {
+ if httpClient == nil {
+ return newError(ErrInvalidConfig, "http client must not be nil")
+ }
+ c.httpClient = httpClient
+ return nil
+ }
+}
+
+// New builds a client. With no options it targets the public API with the
+// defaults: 10s per attempt, 3 attempts, 0.5s to 8s of jittered backoff and no
+// deadline on the call as a whole.
+func New(opts ...Option) (*Client, error) {
+ c := &Client{
+ baseURL: DefaultBaseURL,
+ httpClient: &http.Client{},
+ timeout: 10 * time.Second,
+ maxAttempts: 3,
+ backoffBase: 500 * time.Millisecond,
+ backoffMax: 8 * time.Second,
+ userAgent: "cepis-go/" + Version + " (+https://cep.is)",
+ }
+ for _, opt := range opts {
+ if opt == nil {
+ continue
+ }
+ if err := opt(c); err != nil {
+ return nil, err
+ }
+ }
+ return c, nil
+}
+
+// BaseURL reports where this client sends its requests, without a trailing
+// slash.
+func (c *Client) BaseURL() string { return c.baseURL }
+
+// UserAgent reports the User-Agent this client sends.
+func (c *Client) UserAgent() string { return c.userAgent }
+
+// Lookup resolves a single CEP, masked or unmasked.
+//
+// addr, err := client.Lookup(ctx, "01001-000")
+// addr.Localidade // "São Paulo"
+//
+// A CEP that is not 8 digits fails with [ErrInvalidCEP] before any request is
+// made. One that is well-formed but absent from the database fails with
+// [ErrCEPNotFound]. Neither is retried: both are definitive. A 400 from the
+// API also becomes [ErrInvalidCEP], carrying the API's own message — the
+// format already passed local validation, so the server knows something this
+// client does not.
+func (c *Client) Lookup(ctx context.Context, cep string) (*Address, error) {
+ number, err := NormalizeCEP(cep)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := c.do(ctx, "/"+number)
+ if err != nil {
+ return nil, err
+ }
+
+ switch resp.status {
+ case http.StatusNotFound:
+ return nil, &Error{
+ Kind: ErrCEPNotFound,
+ CEP: number,
+ Status: resp.status,
+ Message: "CEP " + number + " does not exist in the DNE database",
+ }
+ case http.StatusBadRequest:
+ return nil, &Error{
+ Kind: ErrInvalidCEP,
+ CEP: number,
+ Status: resp.status,
+ Message: apiMessage(resp.body, "CEP "+number+" rejected by the API"),
+ }
+ }
+
+ var address Address
+ if err := json.Unmarshal(resp.body, &address); err != nil {
+ return nil, &Error{
+ Kind: ErrInvalidResponse,
+ CEP: number,
+ Status: resp.status,
+ Message: "response is not valid JSON, something between you and the API is " +
+ "answering instead of it: " + preview(resp.body),
+ Cause: err,
+ }
+ }
+ return &address, nil
+}
+
+// LookupMany resolves many CEPs in as few requests as possible.
+//
+// The result has the same length and the same order as the input. A CEP that
+// does not exist is nil at its position — it does not vanish from the slice
+// and it is not an error. That is the deliberate difference from
+// [Client.Lookup]: in a batch of a thousand, one bad CEP must not cost the
+// other nine hundred and ninety-nine. A malformed CEP is nil for the same
+// reason; validate with [NormalizeCEP] first if you need to know which one it
+// was.
+//
+// Input longer than [MaxBatch] is chunked automatically.
+//
+// Batches carry no coordinate. The endpoint behind them is CSV, and the CSV
+// has no geo column, so every Address returned here has a nil Geo — including
+// the CEPs that do have one. Need Geo? Use [Client.Lookup], one at a time.
+func (c *Client) LookupMany(ctx context.Context, ceps []string) ([]*Address, error) {
+ if len(ceps) == 0 {
+ return []*Address{}, nil
+ }
+
+ result := make([]*Address, 0, len(ceps))
+ for start := 0; start < len(ceps); start += MaxBatch {
+ end := start + MaxBatch
+ if end > len(ceps) {
+ end = len(ceps)
+ }
+ chunk, err := c.lookupChunk(ctx, ceps[start:end])
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, chunk...)
+ }
+ return result, nil
+}
+
+// Health queries GET /health: does the API answer, and with which database.
+//
+// An API reporting itself degraded is not an error — that arrives in
+// Health.Status, and checking health is exactly when you want the diagnosis
+// rather than a failure. Network failures still fail.
+func (c *Client) Health(ctx context.Context) (*Health, error) {
+ resp, err := c.do(ctx, "/health", http.StatusOK, http.StatusServiceUnavailable)
+ if err != nil {
+ return nil, err
+ }
+
+ var health Health
+ if err := json.Unmarshal(resp.body, &health); err != nil {
+ return nil, &Error{
+ Kind: ErrInvalidResponse,
+ Status: resp.status,
+ Message: "health response is not valid JSON: " + preview(resp.body),
+ Cause: err,
+ }
+ }
+ return &health, nil
+}
+
+func (c *Client) lookupChunk(ctx context.Context, ceps []string) ([]*Address, error) {
+ query := url.Values{}
+ query.Set("ceps", strings.Join(ceps, ","))
+
+ resp, err := c.do(ctx, "/csv?"+query.Encode())
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := parseCSV(resp.body)
+ if err != nil {
+ return nil, &Error{
+ Kind: ErrInvalidResponse,
+ Status: resp.status,
+ Message: "batch response is not valid CSV: " + preview(resp.body),
+ Cause: err,
+ }
+ }
+
+ if len(rows) > len(ceps) {
+ rows = rows[:len(ceps)]
+ }
+ for len(rows) < len(ceps) {
+ rows = append(rows, nil)
+ }
+ return rows, nil
+}
+
+func parseCSV(body []byte) ([]*Address, error) {
+ reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(body, []byte("\xef\xbb\xbf"))))
+ reader.FieldsPerRecord = -1
+
+ records, err := reader.ReadAll()
+ if err != nil {
+ return nil, err
+ }
+ if len(records) == 0 {
+ return nil, errors.New("empty CSV: not even a header row")
+ }
+
+ header := records[0]
+ rows := make([]*Address, 0, len(records)-1)
+ for _, record := range records[1:] {
+ fields := make(map[string]string, len(header))
+ for i, name := range header {
+ if i < len(record) {
+ fields[name] = record[i]
+ } else {
+ fields[name] = ""
+ }
+ }
+ if strings.TrimSpace(fields["localidade"]) == "" {
+ rows = append(rows, nil)
+ continue
+ }
+ address, err := addressFromFields(fields)
+ if err != nil {
+ return nil, err
+ }
+ rows = append(rows, address)
+ }
+ return rows, nil
+}
+
+func addressFromFields(fields map[string]string) (*Address, error) {
+ encoded, err := json.Marshal(fields)
+ if err != nil {
+ return nil, err
+ }
+ var address Address
+ if err := json.Unmarshal(encoded, &address); err != nil {
+ return nil, err
+ }
+ return &address, nil
+}
+
+type response struct {
+ status int
+ body []byte
+ header http.Header
+}
+
+func (c *Client) do(ctx context.Context, path string, accepted ...int) (*response, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if c.totalTimeout > 0 {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, c.totalTimeout)
+ defer cancel()
+ }
+
+ target := c.baseURL + path
+ if _, err := url.Parse(target); err != nil {
+ return nil, &Error{
+ Kind: ErrNetwork,
+ Message: "cannot build a request for " + target,
+ Cause: err,
+ }
+ }
+
+ var (
+ lastStatus int
+ lastRetryAfter time.Duration
+ hasRetryAfter bool
+ lastCause error
+ )
+
+ for attempt := 1; attempt <= c.maxAttempts; attempt++ {
+ if err := ctx.Err(); err != nil {
+ if lastStatus == 0 && lastCause == nil {
+ lastCause = err
+ }
+ break
+ }
+
+ resp, err := c.attempt(ctx, target)
+ switch {
+ case err != nil:
+ lastCause, lastStatus, lastRetryAfter, hasRetryAfter = err, 0, 0, false
+ case isAccepted(resp.status, accepted) || !isRetriable(resp.status):
+ return resp, nil
+ default:
+ lastCause, lastStatus = nil, resp.status
+ lastRetryAfter, hasRetryAfter = retryAfter(resp.header)
+ }
+
+ if attempt == c.maxAttempts {
+ break
+ }
+ if !sleepContext(ctx, c.backoff(attempt, lastRetryAfter, hasRetryAfter)) {
+ break
+ }
+ }
+
+ attempts := " (after " + strconv.Itoa(c.maxAttempts) + " attempt(s))"
+ switch {
+ case lastStatus == http.StatusTooManyRequests:
+ return nil, &Error{
+ Kind: ErrRateLimited,
+ Status: lastStatus,
+ RetryAfter: lastRetryAfter,
+ Message: "rate limit reached at " + target + attempts,
+ }
+ case lastStatus != 0:
+ return nil, &Error{
+ Kind: ErrServer,
+ Status: lastStatus,
+ Message: "the API answered " + strconv.Itoa(lastStatus) + " at " + target + attempts,
+ }
+ default:
+ return nil, &Error{
+ Kind: ErrNetwork,
+ Message: "could not reach " + target + attempts,
+ Cause: lastCause,
+ }
+ }
+}
+
+func (c *Client) attempt(ctx context.Context, target string) (*response, error) {
+ ctx, cancel := context.WithTimeout(ctx, c.timeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", c.userAgent)
+ req.Header.Set("Accept", acceptHeader)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ return &response{status: resp.StatusCode, body: body, header: resp.Header}, nil
+}
+
+func (c *Client) backoff(attempt int, serverAsked time.Duration, hasRetryAfter bool) time.Duration {
+ if hasRetryAfter {
+ if serverAsked > c.backoffMax {
+ return c.backoffMax
+ }
+ return serverAsked
+ }
+ ceiling := c.backoffBase << (attempt - 1)
+ if ceiling > c.backoffMax || ceiling <= 0 {
+ ceiling = c.backoffMax
+ }
+ if ceiling <= 0 {
+ return 0
+ }
+ return time.Duration(rand.Int63n(int64(ceiling) + 1))
+}
+
+func isAccepted(status int, accepted []int) bool {
+ if len(accepted) == 0 {
+ return status == http.StatusOK
+ }
+ for _, candidate := range accepted {
+ if status == candidate {
+ return true
+ }
+ }
+ return false
+}
+
+func retryAfter(header http.Header) (time.Duration, bool) {
+ if header == nil {
+ return 0, false
+ }
+ value := strings.TrimSpace(header.Get("Retry-After"))
+ if value == "" {
+ return 0, false
+ }
+ if seconds, err := strconv.ParseFloat(value, 64); err == nil {
+ if seconds <= 0 {
+ return 0, true
+ }
+ return time.Duration(seconds * float64(time.Second)), true
+ }
+ target, err := http.ParseTime(value)
+ if err != nil {
+ return 0, false
+ }
+ wait := time.Until(target)
+ if wait < 0 {
+ return 0, true
+ }
+ return wait, true
+}
+
+func sleepContext(ctx context.Context, wait time.Duration) bool {
+ if wait <= 0 {
+ return ctx.Err() == nil
+ }
+ timer := time.NewTimer(wait)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ return true
+ case <-ctx.Done():
+ return false
+ }
+}
+
+func preview(body []byte) string {
+ const limit = 120
+ if len(body) > limit {
+ return strconv.Quote(string(body[:limit])) + "..."
+ }
+ return strconv.Quote(string(body))
+}
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..98a2385
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,139 @@
+package cepis
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// Err is the root of every error this package returns. Callers who only care
+// that something went wrong can match one value:
+//
+// if errors.Is(err, cepis.Err) { ... }
+//
+// Below it, the split is by the decision the caller has to make, not by the
+// HTTP status that caused it. [ErrInvalidCEP] and [ErrCEPNotFound] are
+// problems with the request, and retrying them is pointless — the same CEP
+// fails again. [ErrNetwork], [ErrServer] and [ErrRateLimited] are problems
+// with transport, so retrying may help; the client already retried on its own
+// before returning any of them.
+var Err = errors.New("cepis")
+
+// The kinds of failure, each wrapping [Err]. Match them with errors.Is:
+//
+// if errors.Is(err, cepis.ErrCEPNotFound) { ... }
+var (
+ // ErrInvalidCEP means the CEP is not 8 digits. Usually returned without
+ // any request being made: the client validates the format before going
+ // to the network. It is also what a 400 from the API becomes — the
+ // format passed local validation, so the server knows something this
+ // client does not.
+ ErrInvalidCEP = fmt.Errorf("%w: invalid CEP", Err)
+
+ // ErrCEPNotFound means the CEP is well-formed but absent from the DNE
+ // database. Looking a CEP up is conceptually a key lookup, and this is
+ // the missing key.
+ ErrCEPNotFound = fmt.Errorf("%w: CEP not found", Err)
+
+ // ErrRateLimited means the API (or the edge in front of it) answered 429
+ // on every attempt. Error.RetryAfter carries what the server asked for.
+ ErrRateLimited = fmt.Errorf("%w: rate limited", Err)
+
+ // ErrServer means the API answered 5xx on every attempt. Error.Status
+ // holds the status seen on the last one.
+ ErrServer = fmt.Errorf("%w: server error", Err)
+
+ // ErrNetwork means the request never completed: DNS, refused connection,
+ // timeout, TLS. Returned only after every configured attempt failed.
+ ErrNetwork = fmt.Errorf("%w: network error", Err)
+
+ // ErrInvalidConfig means an [Option] was given a value the client cannot
+ // work with. Only [New] returns it.
+ ErrInvalidConfig = fmt.Errorf("%w: invalid configuration", Err)
+
+ // ErrInvalidResponse means a success status came back with a body that
+ // cannot be parsed. In practice something between you and the API is
+ // 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.
+ ErrInvalidResponse = fmt.Errorf("%w: invalid response", Err)
+)
+
+// Error is the concrete type behind every failure in this package. It carries
+// the context a caller may need to act on — which CEP, which status, how long
+// the server asked to be left alone — and it matches its kind through
+// errors.Is:
+//
+// var cerr *cepis.Error
+// if errors.As(err, &cerr) && errors.Is(cerr, cepis.ErrRateLimited) {
+// reschedule(cerr.RetryAfter)
+// }
+type Error struct {
+ // Kind is one of the package sentinels, and what errors.Is matches.
+ Kind error
+
+ // CEP is the normalised CEP the call was about, when there was one.
+ CEP string
+
+ // Status is the last HTTP status seen, or 0 when no response arrived.
+ Status int
+
+ // RetryAfter is what the server asked for through the Retry-After
+ // header, or 0 when it did not say. It is reported as received, before
+ // the client's own cap is applied to it.
+ RetryAfter time.Duration
+
+ // Message describes the failure without the "cepis: " prefix.
+ Message string
+
+ // Cause is the underlying error, when one exists — typically the
+ // net/http error behind [ErrNetwork].
+ Cause error
+}
+
+// Error implements the error interface.
+func (e *Error) Error() string {
+ msg := e.Message
+ if msg == "" {
+ if e.Kind != nil {
+ msg = e.Kind.Error()
+ } else {
+ msg = Err.Error()
+ }
+ } else {
+ msg = "cepis: " + msg
+ }
+ if e.Cause != nil {
+ return msg + ": " + e.Cause.Error()
+ }
+ return msg
+}
+
+// Unwrap exposes both the kind and the underlying cause, so errors.Is matches
+// the package sentinels and errors.As reaches a net.Error underneath.
+func (e *Error) Unwrap() []error {
+ switch {
+ case e.Kind != nil && e.Cause != nil:
+ return []error{e.Kind, e.Cause}
+ case e.Kind != nil:
+ return []error{e.Kind}
+ case e.Cause != nil:
+ return []error{e.Cause}
+ }
+ return nil
+}
+
+func newError(kind error, format string, args ...any) *Error {
+ return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)}
+}
+
+func apiMessage(body []byte, fallback string) string {
+ var payload struct {
+ Mensagem string `json:"mensagem"`
+ }
+ if err := json.Unmarshal(body, &payload); err == nil && payload.Mensagem != "" {
+ return payload.Mensagem
+ }
+ return fallback
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..cefbef7
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module cep.is/go
+
+go 1.21
diff --git a/models.go b/models.go
new file mode 100644
index 0000000..3d21bf8
--- /dev/null
+++ b/models.go
@@ -0,0 +1,214 @@
+package cepis
+
+import (
+ "encoding/json"
+ "strconv"
+)
+
+// Geo is the CEP's coordinate, when the database has one.
+//
+// The point is the median of the coordinates of that CEP's addresses, not the
+// mean: one badly geocoded address 40 km away shifts a mean and does not move
+// a median.
+//
+// Always check Precisao before using Lat/Lon for distance:
+//
+// - "logradouro": the point is a stretch of street. RaioM lands around
+// 100–150 m. Good for maps and for distance.
+// - "localidade": the CEP is the whole municipality (a city-wide general
+// CEP), and the point is its centroid. RaioM runs from 7 km to 40 km.
+// Computing shipping from this is wrong by tens of kilometres.
+//
+// RaioM is the radius, in metres, containing 90% of that CEP's addresses. It
+// exists precisely so that error cannot pass unnoticed.
+type Geo struct {
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ RaioM int `json:"raio_m"`
+ Precisao string `json:"precisao"`
+}
+
+// IsPrecise reports whether the coordinate points at a street rather than at a
+// whole municipality. Shorthand for Precisao == "logradouro", which is the
+// check to make before using the point to measure distance.
+func (g Geo) IsPrecise() bool {
+ return g.Precisao == "logradouro"
+}
+
+// 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
+// adapted to Go: what you read in the JSON at https://cep.is/01001000 is what
+// you access here. The values are Portuguese either way — they come from the
+// Correios database.
+//
+// Fields the database does not have for that CEP come back as empty strings,
+// never null. Geo is the exception: it is nil when there is no coordinate,
+// because the API omits the whole object in that case (about 43% of CEPs)
+// rather than sending null.
+//
+// Raw holds the response exactly as it arrived, uninterpreted, so a field the
+// API grows before this library is updated is not lost.
+type Address struct {
+ CEP string `json:"cep"`
+ Logradouro string `json:"logradouro"`
+ Complemento string `json:"complemento"`
+ Unidade string `json:"unidade"`
+ Bairro string `json:"bairro"`
+ Localidade string `json:"localidade"`
+ UF string `json:"uf"`
+ Estado string `json:"estado"`
+ Regiao string `json:"regiao"`
+ IBGE string `json:"ibge"`
+ GIA string `json:"gia"`
+ DDD string `json:"ddd"`
+ Siafi string `json:"siafi"`
+ Geo *Geo `json:"geo,omitempty"`
+
+ // Raw is the response as it arrived, field by field, including fields
+ // this version of the library does not know about.
+ Raw map[string]json.RawMessage `json:"-"`
+}
+
+// FormattedCEP returns the CEP with its mask: "01001-000".
+//
+// The API always returns it unmasked; formatting is presentation, and it lives
+// here so it does not become a repeated Sprintf in every project.
+func (a *Address) FormattedCEP() string {
+ if len(a.CEP) != 8 {
+ return a.CEP
+ }
+ return a.CEP[:5] + "-" + a.CEP[5:]
+}
+
+// Field returns a field of the raw response as a string, "" when the API did
+// not send it. Use it to read a field the API grew after this library was
+// released, without waiting for a new version.
+func (a *Address) Field(name string) string {
+ if a.Raw == nil {
+ return ""
+ }
+ return rawString(a.Raw[name])
+}
+
+// UnmarshalJSON decodes the API's JSON leniently.
+//
+// Unknown fields are ignored during decoding but preserved in Raw, so a new
+// field in the API cannot break this library. A malformed geo block yields a
+// nil Geo rather than failing the whole document: the rest of the address is
+// still useful and it is not worth losing over a coordinate.
+func (a *Address) UnmarshalJSON(data []byte) error {
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return err
+ }
+
+ *a = Address{
+ CEP: rawString(raw["cep"]),
+ Logradouro: rawString(raw["logradouro"]),
+ Complemento: rawString(raw["complemento"]),
+ Unidade: rawString(raw["unidade"]),
+ Bairro: rawString(raw["bairro"]),
+ Localidade: rawString(raw["localidade"]),
+ UF: rawString(raw["uf"]),
+ Estado: rawString(raw["estado"]),
+ Regiao: rawString(raw["regiao"]),
+ IBGE: rawString(raw["ibge"]),
+ GIA: rawString(raw["gia"]),
+ DDD: rawString(raw["ddd"]),
+ Siafi: rawString(raw["siafi"]),
+ Geo: decodeGeo(raw["geo"]),
+ Raw: raw,
+ }
+ return nil
+}
+
+func decodeGeo(block json.RawMessage) *Geo {
+ if len(block) == 0 {
+ return nil
+ }
+ var probe struct {
+ Lat *float64 `json:"lat"`
+ Lon *float64 `json:"lon"`
+ RaioM *int `json:"raio_m"`
+ Precisao *string `json:"precisao"`
+ }
+ if err := json.Unmarshal(block, &probe); err != nil {
+ return nil
+ }
+ if probe.Lat == nil || probe.Lon == nil || probe.RaioM == nil || probe.Precisao == nil {
+ return nil
+ }
+ return &Geo{
+ Lat: *probe.Lat,
+ Lon: *probe.Lon,
+ RaioM: *probe.RaioM,
+ Precisao: *probe.Precisao,
+ }
+}
+
+func rawString(value json.RawMessage) string {
+ if len(value) == 0 {
+ return ""
+ }
+ var text string
+ if err := json.Unmarshal(value, &text); err == nil {
+ return text
+ }
+ var number json.Number
+ if err := json.Unmarshal(value, &number); err == nil {
+ return number.String()
+ }
+ return ""
+}
+
+// Health is the answer of GET /health.
+//
+// Good for two things: checking that the API answers, and knowing which
+// version of the database is live. The DNE database changes every two weeks;
+// DNE identifies it.
+type Health struct {
+ Status string `json:"status"`
+ DNE string `json:"dne"`
+ CEPs int64 `json:"ceps"`
+ Geo int64 `json:"geo"`
+ BaseGeradaEm string `json:"base_gerada_em"`
+
+ // Raw is the response as it arrived, field by field.
+ Raw map[string]json.RawMessage `json:"-"`
+}
+
+// OK reports whether the API describes itself as healthy.
+func (h *Health) OK() bool {
+ return h.Status == "ok"
+}
+
+// UnmarshalJSON decodes the health payload leniently, keeping the response in
+// Raw and tolerating fields this version does not know about.
+func (h *Health) UnmarshalJSON(data []byte) error {
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return err
+ }
+ *h = Health{
+ Status: rawString(raw["status"]),
+ DNE: rawString(raw["dne"]),
+ CEPs: rawInt(raw["ceps"]),
+ Geo: rawInt(raw["geo"]),
+ BaseGeradaEm: rawString(raw["base_gerada_em"]),
+ Raw: raw,
+ }
+ return nil
+}
+
+func rawInt(value json.RawMessage) int64 {
+ text := rawString(value)
+ if text == "" {
+ return 0
+ }
+ number, err := strconv.ParseFloat(text, 64)
+ if err != nil {
+ return 0
+ }
+ return int64(number)
+}