diff options
Diffstat (limited to 'lib/cepis/client.rb')
| -rw-r--r-- | lib/cepis/client.rb | 347 |
1 files changed, 347 insertions, 0 deletions
diff --git a/lib/cepis/client.rb b/lib/cepis/client.rb new file mode 100644 index 0000000..f451061 --- /dev/null +++ b/lib/cepis/client.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +require "csv" +require "json" +require "net/http" +require "time" +require "uri" + +require_relative "errors" +require_relative "models" +require_relative "version" + +module Cepis + # Where the API lives, unless `base_url` says otherwise. + DEFAULT_URL = "https://cep.is" + + # Most CEPs the API accepts in a single batch request. + MAX_BATCH = 500 + + # Statuses worth trying again. 400 and 404 are deliberately absent: both are + # definitive answers about the request, so retrying returns the same thing + # more slowly while burning someone else's quota. + RETRIABLE_STATUS = [429, 500, 502, 503, 504].freeze + + # Failures that mean the request never completed. `Errno::*` are + # `SystemCallError`, and `Net::OpenTimeout`/`Net::ReadTimeout` are + # `Timeout::Error`, so the list is shorter than it looks. + NETWORK_ERRORS = [ + Timeout::Error, SocketError, SystemCallError, IOError, EOFError, + Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, + defined?(OpenSSL::SSL::SSLError) ? OpenSSL::SSL::SSLError : nil + ].compact.freeze + + DIGITS_ONLY = /\A\d{8}\z/.freeze + private_constant :DIGITS_ONLY + + # Return the CEP's 8 digits, accepting the mask. + # + # `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`. + # + # Raises {InvalidCEP} unless exactly 8 digits remain. This runs **before** + # any 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. + # + # @param value [String] + # @return [String] + def self.normalize_cep(value) + raise InvalidCEP, "CEP must be a String, got #{value.class}" unless value.is_a?(String) + + cleaned = value.strip.delete("-. ") + raise InvalidCEP, "CEP must be 8 digits, got #{value.inspect}" unless DIGITS_ONLY.match?(cleaned) + + cleaned + end + + # Client for the cep.is API. + # + # Reuse one instance: the configuration is immutable and no method keeps + # state between calls, so it is safe to share across threads. + # + # client = Cepis::Client.new(timeout: 5.0, total_timeout: 15.0) + # client.lookup("01001-000").localidade # => "São Paulo" + # + # @param base_url [String] where the API lives. Point it at a local instance + # (the API repo brings one up on `http://localhost:8080` with `just up`) or + # at a test server. It is the only parameter that usually changes between + # environments. A trailing slash is stripped, so it cannot produce doubled + # separators that some proxies normalise and others 404. + # @param timeout [Float] seconds to wait **per socket operation** (connect, + # read, write) — not a cap on the whole call. That cap is `total_timeout`. + # @param max_attempts [Integer] how many attempts 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. + # @param backoff_base [Float] base of the exponential wait, in seconds. The + # actual wait is drawn from `[0, base * 2**(n-1)]` — *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. + # @param backoff_max [Float] cap on each individual wait, in seconds. It + # applies to a server-sent `Retry-After` too, or a `Retry-After: 3600` from + # a misconfigured proxy would park the thread for an hour. + # @param total_timeout [Float, nil] cap on the whole call, attempts and waits + # included. `nil` leaves `max_attempts` as the only bound. Set it in code + # serving a web request: without it the worst case is + # `max_attempts * timeout` plus the sum of the backoffs. + # @param user_agent [String, nil] overrides the `User-Agent`. The default + # identifies the library and its version, which is what lets the server + # diagnose an old client misbehaving. + class Client + attr_reader :base_url, :timeout, :max_attempts, :backoff_base, :backoff_max, + :total_timeout, :user_agent + + def initialize(base_url: DEFAULT_URL, timeout: 10.0, max_attempts: 3, + backoff_base: 0.5, backoff_max: 8.0, total_timeout: nil, + user_agent: nil) + raise ArgumentError, "max_attempts must be >= 1" if max_attempts < 1 + raise ArgumentError, "timeout must be > 0" if timeout <= 0 + + @base_url = base_url.to_s.sub(%r{/+\z}, "") + @timeout = timeout + @max_attempts = max_attempts + @backoff_base = backoff_base + @backoff_max = backoff_max + @total_timeout = total_timeout + @user_agent = user_agent || "cepis-ruby/#{VERSION} (+https://cep.is)" + freeze + end + + # Look up a single CEP. + # + # client.lookup("01001-000").localidade # => "São Paulo" + # + # A 400 from the API is surfaced as {InvalidCEP} carrying the API's own + # message: the format already passed local validation, so the server knows + # something this client does not. + # + # @param cep [String] masked or unmasked. + # @return [Address] + # @raise [InvalidCEP] the CEP is not 8 digits (no request is made). + # @raise [CEPNotFound] well-formed, but not in the database. + # @raise [RateLimited] 429 after every attempt. + # @raise [ServerError] 5xx after every attempt. + # @raise [NetworkError] the request never completed. + # @raise [InvalidResponse] a success status with an unreadable body. + def lookup(cep) + number = Cepis.normalize_cep(cep) + status, body = request("/#{number}") + + raise CEPNotFound, "CEP #{number} does not exist in the DNE database" if status == 404 + raise InvalidCEP, error_message(body, "CEP #{number} rejected") if status == 400 + + Address.from_json(parse_json(body)) + end + + # Look up many CEPs in one batch. + # + # Returns an array of **the same length and in the same order** as the + # input. A CEP that does not exist becomes `nil` at its position — it does + # not vanish from the array, and it does not raise. That is the right + # difference from {#lookup}: in a batch of a thousand, one bad CEP must not + # cost the other nine hundred and ninety-nine. + # + # A malformed CEP also becomes `nil`, for the same reason, rather than + # raising {InvalidCEP}. To know *which* one was malformed, validate first + # with {Cepis.normalize_cep}. + # + # Input is chunked into blocks of {MAX_BATCH} (the API's limit) + # automatically. Do not loop over {#lookup}: a loop of 500 calls pays 500 + # TLS handshakes, the batch pays one. + # + # **The batch carries no coordinate.** The endpoint behind it is CSV, and + # the CSV has no `geo` column. Every {Address} returned here has `geo` set + # to `nil`, including for CEPs that do have one. Need `geo`? Use {#lookup}, + # one at a time. + # + # @param ceps [Array<String>] + # @return [Array<Address, nil>] + def lookup_many(ceps) + return [] if ceps.nil? || ceps.empty? + + ceps.each_slice(MAX_BATCH).flat_map { |chunk| lookup_chunk(chunk) } + end + + # Query `GET /health`: does the API answer, and with which database. + # + # Does not raise for an API reporting itself degraded — that arrives in + # {Health#status}, and checking health is exactly when you want the + # diagnosis rather than an exception. Network failures still raise. + # + # @return [Health] + def health + _, body = request("/health", accepted_status: [200, 503]) + Health.from_json(parse_json(body)) + end + + private + + # One chunk of at most {MAX_BATCH} CEPs, in a single request. + # + # A blank CSV row means "not found": the API fills only the `cep` column + # and leaves the rest empty, precisely so it does not misalign whoever is + # consuming the batch in a spreadsheet. + # + # The API guarantees one row per requested CEP. Should that ever fail to + # hold — a proxy truncating, a cut response — the result is padded to the + # input length rather than returned short, because a shifted array silently + # pairs the wrong address with the wrong CEP. + def lookup_chunk(ceps) + query = URI.encode_www_form(ceps: ceps.map(&:to_s).join(",")) + _, body = request("/csv?#{query}") + + rows = parse_csv(decode(body)).map do |row| + values = row.to_h.transform_keys(&:to_s) + next nil if values["localidade"].to_s.strip.empty? + + Address.from_json(values.transform_values { |value| value || "" }) + end + + rows.length == ceps.length ? rows : (rows + Array.new(ceps.length))[0, ceps.length] + end + + def parse_csv(text) + CSV.parse(text.delete_prefix("\uFEFF"), headers: true) + rescue CSV::MalformedCSVError => e + raise InvalidResponse, "response is not valid CSV: #{e.message}" + end + + def parse_json(body) + data = JSON.parse(decode(body)) + raise InvalidResponse, "expected a JSON object, got #{data.class}" unless data.is_a?(Hash) + + data + rescue JSON::ParserError + raise InvalidResponse, + "response is not valid JSON — something between you and the API is " \ + "answering instead of it: #{decode(body)[0, 120].inspect}" + end + + def decode(body) + body.to_s.dup.force_encoding(Encoding::UTF_8).scrub + end + + def error_message(body, fallback) + data = JSON.parse(decode(body)) + data.is_a?(Hash) && data["mensagem"] ? String(data["mensagem"]) : fallback + rescue JSON::ParserError + fallback + end + + # Parse the `Retry-After` header in both RFC 9110 forms. + # + # It may arrive as a number of seconds (`"120"`) or as an HTTP date + # (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the + # common bug, and it makes a client ignore exactly the wait request that + # matters most. + def retry_after_seconds(value) + return nil if value.nil? || value.strip.empty? + + text = value.strip + begin + return [0.0, Float(text)].max + rescue ArgumentError + nil + end + + begin + [0.0, Time.httpdate(text).to_f - Time.now.to_f].max + rescue ArgumentError + nil + end + end + + # How long to wait before the next attempt. + # + # The server's `Retry-After` wins over the local calculation when present: + # it knows when it will be back, we are guessing. It still goes through the + # `backoff_max` cap. Otherwise the wait is drawn from `[0, ceiling]` rather + # than being the ceiling — see `backoff_base` on why that matters. + def backoff(attempt, retry_after) + return [retry_after, @backoff_max].min unless retry_after.nil? + + ceiling = [@backoff_base * (2**(attempt - 1)), @backoff_max].min + rand * ceiling + end + + # Perform the GET, with retries. Returns `[status, body]`. + # + # *Definitive* error statuses (400, 404) are returned normally, for the + # caller to turn into the right error — only transient failures become + # exceptions here, and only once the attempts are exhausted. + def request(path, accepted_status: [200]) + url = "#{@base_url}#{path}" + uri = URI.parse(url) + deadline = @total_timeout && (monotonic + @total_timeout) + + last_status = nil + last_retry_after = nil + last_network_error = nil + attempt = 1 + + while attempt <= @max_attempts + break if deadline && monotonic >= deadline + + begin + response = perform(uri) + status = response.code.to_i + if accepted_status.include?(status) || !RETRIABLE_STATUS.include?(status) + return [status, response.body.to_s] + end + + last_status = status + last_retry_after = retry_after_seconds(response["Retry-After"]) + last_network_error = nil + rescue *NETWORK_ERRORS => e + last_network_error = e + last_status = nil + last_retry_after = nil + end + + break if attempt == @max_attempts + + wait = backoff(attempt, last_retry_after) + if deadline + remaining = deadline - monotonic + break if remaining <= 0 + + wait = [wait, remaining].min + end + sleep(wait) + attempt += 1 + end + + raise_transport_error(url, last_status, last_retry_after, last_network_error) + end + + def perform(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = @timeout + http.read_timeout = @timeout + http.write_timeout = @timeout if http.respond_to?(:write_timeout=) + + get = Net::HTTP::Get.new(uri.request_uri, + "User-Agent" => @user_agent, + "Accept" => "application/json, text/csv;q=0.9, */*;q=0.1", + "Accept-Encoding" => "identity") + http.start { |connection| connection.request(get) } + end + + def raise_transport_error(url, status, retry_after, network_error) + attempts = "(after #{@max_attempts} attempt(s))" + if status == 429 + raise RateLimited.new("rate limit reached at #{url} #{attempts}", + retry_after: retry_after) + end + if status + raise ServerError.new("the API answered #{status} at #{url} #{attempts}", + status: status) + end + + raise NetworkError, "could not reach #{url} #{attempts}: #{network_error}" + end + + def monotonic + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + end +end |