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: // The body may name the municipality the CEP would fall in; when it // does, say so in the message too, because most callers only ever // print the error. faixa := decodeFaixa(resp.body) message := "CEP " + number + " does not exist in the DNE database" if faixa != nil { message += " (range of " + faixa.Localidade + "/" + faixa.UF + ", which goes from " + faixa.CEPIni + " to " + faixa.CEPFim + ")" } return nil, &Error{ Kind: ErrCEPNotFound, CEP: number, Status: resp.status, Faixa: faixa, Message: message, } 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)) }