diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:13:27 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:13:27 -0300 |
| commit | 625cab052606cb3c2a7c4e008c43cf536b6404ce (patch) | |
| tree | 80d126ce42750d29eec42d9fef98a68341bb2dfa /client.go | |
| download | go-625cab052606cb3c2a7c4e008c43cf536b6404ce.tar.gz | |
Initial import
Diffstat (limited to 'client.go')
| -rw-r--r-- | client.go | 572 |
1 files changed, 572 insertions, 0 deletions
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)) +} |