summaryrefslogtreecommitdiffstats
path: root/client.go
diff options
context:
space:
mode:
Diffstat (limited to 'client.go')
-rw-r--r--client.go572
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))
+}