# 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] # @return [Array] 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