summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:32:15 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:32:15 -0300
commit36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c (patch)
treebfd0a067edb5e802725bab48ebc7a0a09838feb1
downloadruby-eee5809fce9d9bb861529f2fa2240202538186c0.tar.gz
Initial importv0.1.0
-rw-r--r--.gitignore9
-rw-r--r--LICENSE15
-rw-r--r--Makefile55
-rw-r--r--README.md207
-rw-r--r--cepis.gemspec28
-rw-r--r--lib/cepis.rb83
-rw-r--r--lib/cepis/client.rb347
-rw-r--r--lib/cepis/errors.rb67
-rw-r--r--lib/cepis/models.rb193
-rw-r--r--lib/cepis/version.rb6
-rw-r--r--test/cepis_test.rb293
-rw-r--r--test/test_helper.rb166
12 files changed, 1469 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..42c09bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+/pkg/
+*.gem
+/doc/
+/.yardoc/
+/coverage/
+/tmp/
+Gemfile.lock
+.bundle/
+vendor/bundle/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1f9fc17
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,55 @@
+# Development tasks. Everything runs through the Ruby that is on PATH — the
+# library and its test suite have no dependencies beyond the standard library,
+# so there is nothing to install first.
+#
+# make list the targets
+# make test run the suite with minitest
+# make lint syntax and warning checks, no files touched
+# make build build the gem into pkg/
+
+RUBY ?= ruby
+GEM ?= gem
+
+SOURCES = $(shell find lib test -name '*.rb') cepis.gemspec
+TESTS = $(shell find test -name '*_test.rb')
+VERSION = $(shell $(RUBY) -Ilib -e 'require "cepis/version"; print Cepis::VERSION')
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' test run the test suite'
+ @echo ' lint ruby -w -c over the tree, plus rubocop when installed'
+ @echo ' format apply rubocop -A, when installed (writes)'
+ @echo ' build build the gem into pkg/'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build artefacts'
+
+test:
+ @set -e; for f in $(TESTS); do $(RUBY) -Ilib -Itest $$f; done
+
+# Read-only by design, so it is safe in CI and in a pre-commit hook. Ruby has
+# no formatter or linter in the standard library; rubocop is not a dependency
+# of this gem, so it is used only when it happens to be installed.
+lint:
+ @set -e; for f in $(SOURCES); do $(RUBY) -w -c $$f >/dev/null; done
+ @echo 'syntax OK: $(words $(SOURCES)) files'
+ $(RUBY) -w -Ilib -e 'require "cepis"'
+ @if command -v rubocop >/dev/null 2>&1; then rubocop; else \
+ echo 'rubocop not installed: syntax and warning checks only'; fi
+
+format:
+ @if command -v rubocop >/dev/null 2>&1; then rubocop -A; else \
+ echo 'rubocop not installed: nothing to format with'; fi
+
+build:
+ @mkdir -p pkg
+ $(GEM) build cepis.gemspec --output pkg/cepis-$(VERSION).gem
+
+# The order matters: lint is the fastest to fail, build the slowest, so a
+# broken run reports the cheapest problem first.
+check: lint test build
+
+clean:
+ rm -rf pkg doc .yardoc
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..017fe89
--- /dev/null
+++ b/README.md
@@ -0,0 +1,207 @@
+# cepis
+
+Ruby client for the [public CEP API at cep.is](https://cep.is). Official
+Correios data (DNE), refreshed every two weeks. No runtime dependencies:
+`net/http`, `json` and `csv` come with Ruby.
+
+```ruby
+require "cepis"
+
+address = Cepis.lookup("01001-000")
+puts "#{address.logradouro} #{address.localidade} #{address.uf}"
+# Praça da Sé São Paulo SP
+```
+
+## Installation
+
+```sh
+gem install cepis
+```
+
+Or, in a `Gemfile`:
+
+```ruby
+gem "cepis", git: "https://git.cep.is/ruby.git"
+```
+
+Requires Ruby 3.0 or newer.
+
+## Usage
+
+### A single lookup
+
+`lookup` accepts the CEP masked or unmasked and returns a `Cepis::Address`:
+
+```ruby
+a = Cepis.lookup("01310-100")
+
+a.logradouro # "Avenida Paulista"
+a.localidade # "São Paulo"
+a.uf # "SP"
+a.ibge # "3550308"
+a.cep # "01310100" (always unmasked)
+a.formatted_cep # "01310-100"
+```
+
+Fields the database does not have for that CEP come back as empty strings,
+never `nil`.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is `nil`:
+
+```ruby
+a = Cepis.lookup("01310100")
+
+if a.geo
+ puts "#{a.geo.lat} #{a.geo.lon}" # -23.567202 -46.649659
+ puts a.geo.raio_m # 1084
+ puts a.geo.precisao # "logradouro"
+end
+```
+
+**Check `precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`a.geo.precise?` is the shorthand for `precisao == "logradouro"`:
+
+```ruby
+cost = if a.geo&.precise?
+ shipping_from(a.geo.lat, a.geo.lon)
+ else
+ shipping_from_municipality(a.ibge)
+ end
+```
+
+### Many CEPs at once
+
+Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs
+per request:
+
+```ruby
+ceps = %w[01001000 99999999 20040020]
+
+ceps.zip(Cepis.lookup_many(ceps)).each do |requested, address|
+ puts address.nil? ? "#{requested}: not found" : "#{requested}: #{address.localidade}"
+end
+```
+
+The returned array has **the same length and the same order** as the input. A
+CEP that does not exist, or is malformed, becomes `nil` at its position — it
+does not vanish, and it does not raise.
+
+Arrays longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the
+> CSV has no `geo` column. Every `Address` from `lookup_many` has `geo` set to
+> `nil`, including for CEPs that do have one. Need `geo`? Use `lookup`, one at
+> a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version
+of the database** is live:
+
+```ruby
+h = Cepis.health
+
+h.ok? # true
+h.dne # "26071" identifier of the fortnightly database
+h.ceps # 1596168
+h.base_gerada_em # "2026-07-24T03:48:28Z"
+```
+
+An API reporting itself degraded does not raise — that arrives in `h.status`.
+
+## Configuration
+
+For anything beyond the defaults, build a `Cepis::Client` and reuse it. It
+keeps no state between calls and is safe to share across threads:
+
+```ruby
+client = Cepis::Client.new(
+ base_url: "https://cep.is", # point at another instance
+ timeout: 10.0, # seconds per socket operation
+ max_attempts: 3, # counting the first; 1 disables retrying
+ backoff_base: 0.5, # base of the exponential wait
+ backoff_max: 8.0, # cap on each wait
+ total_timeout: nil # cap on the whole call, attempts included
+)
+
+address = client.lookup("01001000")
+```
+
+Retries cover 429, 5xx and network failures, with exponential backoff and
+jitter; 400 and 404 are never retried.
+
+The module-level methods (`Cepis.lookup`, etc.) use a shared default client.
+Do not mutate it: it applies process-wide, including to third-party libraries
+that also use this gem. Need a different configuration? Build your own.
+
+### Pointing at a local instance
+
+`base_url` is the only parameter that usually changes between environments.
+The API repo brings a local instance up with `just up`:
+
+```ruby
+client = Cepis::Client.new(base_url: "http://localhost:8080")
+```
+
+## Errors
+
+Everything derives from `Cepis::Error`, itself a `StandardError`. Below that,
+the split is by **the decision you have to make**, not by the HTTP status that
+caused it:
+
+| error | when | does retrying help? |
+| --- | --- | --- |
+| `Cepis::InvalidCEP` | not 8 digits | no |
+| `Cepis::CEPNotFound` | well-formed, not in the database | no |
+| `Cepis::RateLimited` | 429 | yes, later |
+| `Cepis::ServerError` | 5xx | yes, later |
+| `Cepis::NetworkError` | DNS, connection, timeout, TLS | yes, later |
+| `Cepis::InvalidResponse` | unintelligible response | probably not |
+
+```ruby
+begin
+ address = Cepis.lookup(cep_from_form)
+rescue Cepis::CEPNotFound
+ # ask the user for another CEP
+rescue Cepis::Error
+ # transient failure: reschedule
+end
+```
+
+`RateLimited#retry_after` carries what the server asked for, in seconds, and
+`ServerError#status` the HTTP status of the last attempt.
+
+`InvalidResponse` almost always means something between you and the API is
+answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML
+error page. Useful as a diagnosis: the problem is not in your code.
+
+## Development
+
+Everything runs through `make` and the Ruby on your `PATH`. There is nothing
+to install: the gem and its suite use only the standard library, and the tests
+bring up a real HTTP server on loopback.
+
+```sh
+make # list the targets
+make test # run the suite (minitest)
+make lint # ruby -w -c over the tree, plus a warning-clean load
+make build # build the gem into pkg/
+make check # lint, then test, then build
+```
+
+Ruby ships no linter or formatter, and this gem does not depend on one. `make
+lint` is therefore a syntax and warning check; if `rubocop` happens to be
+installed it is run as well, and `make format` applies it.
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/cepis.gemspec b/cepis.gemspec
new file mode 100644
index 0000000..4c2e242
--- /dev/null
+++ b/cepis.gemspec
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require_relative "lib/cepis/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "cepis"
+ spec.version = Cepis::VERSION
+ spec.authors = ["Murilo Ijanc"]
+ spec.email = ["[email protected]"]
+
+ spec.summary = "Ruby client for the public CEP API at cep.is"
+ spec.description = "Look up Brazilian postal codes against cep.is: official " \
+ "Correios data (DNE), single lookups with coordinates, " \
+ "batches of up to 500, retries with jittered backoff. " \
+ "No runtime dependencies."
+ spec.homepage = "https://cep.is"
+ spec.license = "ISC"
+ spec.required_ruby_version = ">= 3.0"
+
+ spec.metadata = {
+ "homepage_uri" => spec.homepage,
+ "source_code_uri" => "https://git.cep.is/ruby.git",
+ "rubygems_mfa_required" => "true"
+ }
+
+ spec.files = Dir["lib/**/*.rb"] + %w[README.md LICENSE Makefile cepis.gemspec]
+ spec.require_paths = ["lib"]
+end
diff --git a/lib/cepis.rb b/lib/cepis.rb
new file mode 100644
index 0000000..7b2cc5a
--- /dev/null
+++ b/lib/cepis.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require_relative "cepis/version"
+require_relative "cepis/errors"
+require_relative "cepis/models"
+require_relative "cepis/client"
+
+# Ruby client for the public CEP API at https://cep.is.
+#
+# For a script, the module-level methods are enough:
+#
+# require "cepis"
+#
+# address = Cepis.lookup("01001-000")
+# puts "#{address.localidade} #{address.uf}" # São Paulo SP
+#
+# In an application, build a {Cepis::Client} once and reuse it:
+#
+# client = Cepis::Client.new(timeout: 5.0, total_timeout: 15.0)
+# address = client.lookup("01001000")
+#
+# Pointing at a local instance (the API repo brings one up with `just up`):
+#
+# client = Cepis::Client.new(base_url: "http://localhost:8080")
+#
+# Errors are explicit and split by decision — whether retrying helps or not:
+#
+# begin
+# address = Cepis.lookup(cep_from_form)
+# rescue Cepis::CEPNotFound
+# # the CEP does not exist: ask the user for another
+# rescue Cepis::Error
+# # network, server, rate limit: retry later
+# end
+#
+# Note on field names: the API surface is English, but the **data fields keep
+# the API's own Portuguese names** (`logradouro`, `bairro`, `localidade`). What
+# you read in the JSON is what you read on the object.
+module Cepis
+ class << self
+ # The client behind the module-level methods.
+ #
+ # It is built once because {Client} keeps no state between calls and is
+ # safe to share across threads. It applies process-wide, including to
+ # third-party libraries that also use this module — anyone needing
+ # different settings should build their own {Client}.
+ #
+ # @return [Client]
+ def default_client
+ @default_client ||= Client.new
+ end
+
+ # Look up a CEP using the default configuration.
+ #
+ # Shorthand for `Cepis.default_client.lookup(cep)`. See {Client#lookup}.
+ #
+ # @param cep [String]
+ # @return [Address]
+ def lookup(cep)
+ default_client.lookup(cep)
+ end
+
+ # Look up many CEPs in one batch, using the default configuration.
+ #
+ # Shorthand for {Client#lookup_many} — read that one, especially the
+ # warning that batches carry no coordinate.
+ #
+ # @param ceps [Array<String>]
+ # @return [Array<Address, nil>]
+ def lookup_many(ceps)
+ default_client.lookup_many(ceps)
+ end
+
+ # Query the API's health using the default configuration.
+ #
+ # Shorthand for {Client#health}.
+ #
+ # @return [Health]
+ def health
+ default_client.health
+ end
+ end
+end
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
diff --git a/lib/cepis/errors.rb b/lib/cepis/errors.rb
new file mode 100644
index 0000000..7a15841
--- /dev/null
+++ b/lib/cepis/errors.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Cepis
+ # Base of every error raised by this library. Rescue it to handle any
+ # failure with one handler.
+ #
+ # Below it, the split is by **the decision the caller has to make**, not by
+ # the HTTP status that caused it:
+ #
+ # - {InvalidCEP} and {CEPNotFound} are problems with the *request*. Retrying
+ # is pointless — the same CEP will fail again.
+ # - {NetworkError}, {ServerError} and {RateLimited} are problems with
+ # *transport*. Retrying may help, and the client already retried on its own
+ # before raising any of them.
+ #
+ # That split is why {CEPNotFound} is not "the HTTP 404 error": {InvalidCEP}
+ # is raised without any request being made at all, when the CEP is malformed
+ # before it ever leaves the machine.
+ class Error < StandardError; end
+
+ # The CEP is not 8 digits.
+ #
+ # Usually raised **without** a request ever being made — the client checks
+ # the format before going to the network. It is also raised when the API
+ # answers 400 to a CEP that passed local validation, in which case it carries
+ # the API's own message.
+ class InvalidCEP < Error; end
+
+ # The CEP is well-formed but does not exist in the DNE database.
+ class CEPNotFound < Error; end
+
+ # The request never completed: DNS, refused connection, timeout, TLS.
+ #
+ # Raised only after every configured attempt has failed.
+ class NetworkError < Error; end
+
+ # The API answered 5xx on every attempt.
+ class ServerError < Error
+ # HTTP status seen on the last attempt.
+ attr_reader :status
+
+ def initialize(message, status:)
+ super(message)
+ @status = status
+ end
+ end
+
+ # The API (or the edge in front of it) answered 429 on every attempt.
+ class RateLimited < Error
+ # What the server asked for, in seconds, or `nil` if it did not say. The
+ # client already honoured this across the attempts it made; it is exposed
+ # for callers who want to reschedule the work.
+ attr_reader :retry_after
+
+ def initialize(message, retry_after: nil)
+ super(message)
+ @retry_after = retry_after
+ end
+ end
+
+ # A success status came back with a body that cannot be parsed.
+ #
+ # In practice this means something between you and the API is answering
+ # instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error
+ # page. Useful as a diagnosis: the problem is not in your code.
+ class InvalidResponse < Error; end
+end
diff --git a/lib/cepis/models.rb b/lib/cepis/models.rb
new file mode 100644
index 0000000..a0181ff
--- /dev/null
+++ b/lib/cepis/models.rb
@@ -0,0 +1,193 @@
+# frozen_string_literal: true
+
+module Cepis
+ # The CEP's coordinate, when the database has one.
+ #
+ # The point is the **median** of the coordinates of that CEP's addresses, not
+ # the mean: one badly geocoded address 40 km away shifts a mean and does not
+ # move a median.
+ #
+ # Always check {#precisao} before using {#lat}/{#lon} for distance:
+ #
+ # - `"logradouro"`: the point is a stretch of street. {#raio_m} lands around
+ # 100–150 m. Good for maps and for distance.
+ # - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
+ # CEP), and the point is its centroid. {#raio_m} runs from 7 km to 40 km.
+ # Computing shipping from this is wrong by tens of kilometres.
+ #
+ # {#raio_m} is the radius containing 90% of that CEP's addresses. It exists
+ # precisely so that error cannot pass unnoticed.
+ class Geo
+ attr_reader :lat, :lon, :raio_m, :precisao
+
+ def initialize(lat:, lon:, raio_m:, precisao:)
+ @lat = lat
+ @lon = lon
+ @raio_m = raio_m
+ @precisao = precisao
+ freeze
+ end
+
+ # Build from the hash the API returned, or `nil` when the block is missing
+ # or malformed.
+ #
+ # A malformed `geo` is not worth losing the whole address over, since the
+ # rest of the response is still useful.
+ def self.from_json(data)
+ return nil unless data.is_a?(Hash)
+
+ block = data.transform_keys(&:to_s)
+ new(
+ lat: Float(block.fetch("lat")),
+ lon: Float(block.fetch("lon")),
+ raio_m: Float(block.fetch("raio_m")).to_i,
+ precisao: String(block.fetch("precisao"))
+ )
+ rescue KeyError, TypeError, ArgumentError
+ nil
+ end
+
+ # `true` when the coordinate points at a street, not a municipality.
+ #
+ # Shorthand for `precisao == "logradouro"`, which is the check to make
+ # before using the point to measure distance.
+ def precise?
+ @precisao == "logradouro"
+ end
+
+ def to_h
+ { lat: @lat, lon: @lon, raio_m: @raio_m, precisao: @precisao }
+ end
+
+ def ==(other)
+ other.is_a?(Geo) && to_h == other.to_h
+ end
+ alias eql? ==
+
+ def hash
+ to_h.hash
+ end
+
+ def to_s
+ "#<Cepis::Geo #{@lat},#{@lon} ±#{@raio_m}m #{@precisao}>"
+ end
+ end
+
+ # A CEP and everything the database knows about it.
+ #
+ # **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
+ # `localidade`. Everything else in this library is English; the fields are
+ # the one deliberate exception, so that what you read in the JSON at
+ # https://cep.is/01001000 is exactly what you read on the object.
+ #
+ # Fields the database does not have for that CEP come back as empty strings,
+ # never `nil` — that is what the API returns.
+ #
+ # {#geo} is the exception: it is `nil` when there is no coordinate. There the
+ # difference is real, because the API **omits** the whole field in that case
+ # (about 43% of CEPs) rather than sending null.
+ #
+ # {#raw} holds the response exactly as it arrived, uninterpreted, so a field
+ # the API grows before this library is updated is not lost.
+ class Address
+ FIELDS = %i[
+ cep logradouro complemento unidade bairro localidade
+ uf estado regiao ibge gia ddd siafi
+ ].freeze
+
+ attr_reader(*FIELDS)
+ attr_reader :geo, :raw
+
+ def initialize(geo: nil, raw: {}, **fields)
+ FIELDS.each { |name| instance_variable_set(:"@#{name}", fields[name] || "") }
+ @geo = geo
+ @raw = raw.freeze
+ freeze
+ end
+
+ # Build from the hash the API returned.
+ #
+ # Unknown fields are ignored during construction but preserved in {#raw}. A
+ # new field in the API therefore cannot break this library.
+ def self.from_json(data)
+ values = data.transform_keys(&:to_s)
+ fields = FIELDS.each_with_object({}) do |name, acc|
+ acc[name] = String(values[name.to_s] || "")
+ end
+ new(geo: Geo.from_json(values["geo"]), raw: values, **fields)
+ end
+
+ # The CEP with its mask: `01001-000`.
+ #
+ # The API always returns it unmasked; formatting is presentation, and it
+ # lives here so it does not become a repeated interpolation in every
+ # project that uses this.
+ def formatted_cep
+ "#{@cep[0, 5]}-#{@cep[5, 3]}"
+ end
+
+ def to_h
+ FIELDS.each_with_object({}) { |name, acc| acc[name] = public_send(name) }
+ .merge(geo: @geo&.to_h)
+ end
+
+ def ==(other)
+ other.is_a?(Address) && to_h == other.to_h
+ end
+ alias eql? ==
+
+ def hash
+ to_h.hash
+ end
+
+ def to_s
+ "#<Cepis::Address #{formatted_cep} #{@logradouro} #{@localidade}/#{@uf}>"
+ end
+ end
+
+ # The response of `GET /health`.
+ #
+ # Good for two things: checking that the API answers, and knowing **which
+ # version of the database** is live. The DNE database changes every two
+ # weeks; {#dne} identifies it.
+ class Health
+ attr_reader :status, :dne, :ceps, :geo, :base_gerada_em
+
+ def initialize(status:, dne:, ceps:, geo:, base_gerada_em:)
+ @status = status
+ @dne = dne
+ @ceps = ceps
+ @geo = geo
+ @base_gerada_em = base_gerada_em
+ freeze
+ end
+
+ # Build from the hash the API returned.
+ def self.from_json(data)
+ values = data.transform_keys(&:to_s)
+ new(
+ status: String(values["status"] || ""),
+ dne: String(values["dne"] || ""),
+ ceps: values["ceps"].to_i,
+ geo: values["geo"].to_i,
+ base_gerada_em: String(values["base_gerada_em"] || "")
+ )
+ end
+
+ # `true` when the API reports itself healthy.
+ def ok?
+ @status == "ok"
+ end
+
+ def to_h
+ {
+ status: @status, dne: @dne, ceps: @ceps, geo: @geo,
+ base_gerada_em: @base_gerada_em
+ }
+ end
+
+ def to_s
+ "#<Cepis::Health #{@status} dne=#{@dne} ceps=#{@ceps}>"
+ end
+ end
+end
diff --git a/lib/cepis/version.rb b/lib/cepis/version.rb
new file mode 100644
index 0000000..cb88e06
--- /dev/null
+++ b/lib/cepis/version.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Cepis
+ # Version of this library, as reported in the default `User-Agent`.
+ VERSION = "0.1.0"
+end
diff --git a/test/cepis_test.rb b/test/cepis_test.rb
new file mode 100644
index 0000000..06a6bf6
--- /dev/null
+++ b/test/cepis_test.rb
@@ -0,0 +1,293 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+# The suite brings up a real HTTP server on loopback and points the client at
+# it. No test touches the network, and no test stubs Net::HTTP: the code path
+# exercised is the one that runs in production, request construction and error
+# body reading included.
+#
+# It doubles as the test that `base_url` works — if it did not, none of this
+# would run.
+
+class NormalizationTest < Minitest::Test
+ def test_accepts_masked_and_unmasked
+ ["01001000", "01001-000", " 01001-000 ", "01001.000"].each do |value|
+ assert_equal "01001000", Cepis.normalize_cep(value)
+ end
+ end
+
+ def test_rejects_bad_format
+ ["", "123", "0100100a", "010010000", "abcdefgh"].each do |value|
+ assert_raises(Cepis::InvalidCEP) { Cepis.normalize_cep(value) }
+ end
+ end
+
+ def test_rejects_wrong_type
+ assert_raises(Cepis::InvalidCEP) { Cepis.normalize_cep(1_001_000) }
+ end
+
+ def test_one_rescue_catches_everything
+ assert_raises(Cepis::Error) { Cepis.normalize_cep("123") }
+ end
+end
+
+class LookupTest < ServerTest
+ def test_successful_lookup
+ routes.respond("/01001000", [200, JSON.generate(SE_ADDRESS), {}])
+ address = @client.lookup("01001-000")
+
+ assert_equal "São Paulo", address.localidade
+ assert_equal "SP", address.uf
+ assert_equal "01001-000", address.formatted_cep
+ assert_nil address.geo
+ end
+
+ def test_reads_the_coordinate_when_present
+ body = SE_ADDRESS.merge(
+ "geo" => { "lat" => -23.5, "lon" => -46.6, "raio_m" => 120, "precisao" => "logradouro" }
+ )
+ routes.respond("/01310100", [200, JSON.generate(body), {}])
+ address = @client.lookup("01310100")
+
+ assert_in_delta(-23.5, address.geo.lat)
+ assert_in_delta(-46.6, address.geo.lon)
+ assert_equal 120, address.geo.raio_m
+ assert address.geo.precise?
+ end
+
+ def test_municipality_coordinate_is_not_precise
+ body = SE_ADDRESS.merge(
+ "geo" => { "lat" => -23.5, "lon" => -46.6, "raio_m" => 22_000, "precisao" => "localidade" }
+ )
+ routes.respond("/01001000", [200, JSON.generate(body), {}])
+
+ refute @client.lookup("01001000").geo.precise?
+ end
+
+ def test_malformed_geo_does_not_lose_the_address
+ body = SE_ADDRESS.merge("geo" => { "lat" => "not a number" })
+ routes.respond("/01001000", [200, JSON.generate(body), {}])
+ address = @client.lookup("01001000")
+
+ assert_nil address.geo
+ assert_equal "São Paulo", address.localidade
+ end
+
+ def test_new_api_field_does_not_break
+ body = SE_ADDRESS.merge("campo_do_futuro" => "value")
+ routes.respond("/01001000", [200, JSON.generate(body), {}])
+
+ assert_equal "value", @client.lookup("01001000").raw["campo_do_futuro"]
+ end
+
+ def test_not_found
+ routes.respond(
+ "/99999999",
+ [404, JSON.generate({ "erro" => "cep_nao_encontrado", "mensagem" => "gone" }), {}]
+ )
+ error = assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") }
+
+ assert_includes error.message, "99999999"
+ end
+
+ def test_not_found_is_not_retried
+ routes.respond("/99999999", [404, JSON.generate({ "erro" => "x" }), {}])
+ assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") }
+
+ assert_equal 1, routes.count("/99999999")
+ end
+
+ def test_invalid_cep_never_leaves_the_machine
+ assert_raises(Cepis::InvalidCEP) { @client.lookup("123") }
+
+ assert_empty routes.calls
+ end
+
+ def test_api_400_becomes_invalid_cep
+ routes.respond(
+ "/00000000",
+ [400, JSON.generate({ "erro" => "cep_invalido", "mensagem" => "rejected by the API" }), {}]
+ )
+ error = assert_raises(Cepis::InvalidCEP) { @client.lookup("00000000") }
+
+ assert_includes error.message, "rejected by the API"
+ end
+
+ def test_response_that_is_not_json
+ routes.respond("/01001000", [200, "<html>captive portal</html>", {}])
+
+ assert_raises(Cepis::InvalidResponse) { @client.lookup("01001000") }
+ end
+end
+
+class RetryTest < ServerTest
+ def test_retries_on_500_then_succeeds
+ routes.respond("/01001000", [
+ [500, JSON.generate({ "erro" => "interno" }), {}],
+ [200, JSON.generate(SE_ADDRESS), {}]
+ ])
+
+ assert_equal "São Paulo", @client.lookup("01001000").localidade
+ assert_equal 2, routes.count("/01001000")
+ end
+
+ def test_exhausts_attempts_then_raises
+ routes.respond("/01001000", [503, JSON.generate({ "erro" => "interno" }), {}])
+ error = assert_raises(Cepis::ServerError) { @client.lookup("01001000") }
+
+ assert_equal 503, error.status
+ assert_equal 3, routes.count("/01001000")
+ end
+
+ def test_max_attempts_one_disables_retry
+ client = Cepis::Client.new(base_url: @server.url, max_attempts: 1, timeout: 5.0)
+ routes.respond("/01001000", [500, "{}", {}])
+
+ assert_raises(Cepis::ServerError) { client.lookup("01001000") }
+ assert_equal 1, routes.count("/01001000")
+ end
+
+ def test_429_becomes_rate_limited_with_retry_after
+ routes.respond("/01001000", [429, "{}", { "Retry-After" => "7" }])
+ error = assert_raises(Cepis::RateLimited) { @client.lookup("01001000") }
+
+ assert_in_delta 7.0, error.retry_after
+ end
+
+ def test_retry_after_accepts_an_http_date
+ routes.respond(
+ "/01001000",
+ [429, "{}", { "Retry-After" => (Time.now + 4).httpdate }]
+ )
+ error = assert_raises(Cepis::RateLimited) { @client.lookup("01001000") }
+
+ assert_operator error.retry_after, :>, 0
+ assert_operator error.retry_after, :<=, 5
+ end
+
+ def test_retry_after_honours_the_cap
+ routes.respond("/01001000", [429, "{}", { "Retry-After" => "3600" }])
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ assert_raises(Cepis::RateLimited) { @client.lookup("01001000") }
+
+ assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, :<, 5.0
+ end
+
+ def test_network_error_when_nothing_listens
+ client = Cepis::Client.new(
+ base_url: "http://127.0.0.1:1", max_attempts: 2, backoff_base: 0.001, timeout: 1.0
+ )
+
+ assert_raises(Cepis::NetworkError) { client.lookup("01001000") }
+ end
+
+ def test_total_timeout_bounds_the_whole_call
+ client = Cepis::Client.new(
+ base_url: @server.url, max_attempts: 50, backoff_base: 0.05,
+ backoff_max: 0.05, total_timeout: 0.3
+ )
+ routes.respond("/01001000", [500, "{}", {}])
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+
+ assert_raises(Cepis::ServerError) { client.lookup("01001000") }
+ assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, :<, 3.0
+ assert_operator routes.count("/01001000"), :<, 50
+ end
+end
+
+class BatchTest < ServerTest
+ def test_preserves_order_and_marks_missing
+ body = csv_body([
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo," \
+ "Sudeste,3550308,1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ," \
+ "Rio de Janeiro,Sudeste,3304557,,21,6001"
+ ])
+ routes.respond("/csv?ceps=01001000%2C99999999%2C20040020", [200, body, {}])
+ result = @client.lookup_many(%w[01001000 99999999 20040020])
+
+ assert_equal 3, result.length
+ assert_equal "São Paulo", result[0].localidade
+ assert_nil result[1]
+ assert_equal "RJ", result[2].uf
+ end
+
+ def test_batch_carries_no_coordinate
+ body = csv_body(["01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ routes.respond("/csv?ceps=01001000", [200, body, {}])
+
+ assert_nil @client.lookup_many(["01001000"]).first.geo
+ end
+
+ def test_field_with_comma_is_parsed
+ body = csv_body(['01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1'])
+ routes.respond("/csv?ceps=01001000", [200, body, {}])
+
+ assert_equal "Rua Um, Dois", @client.lookup_many(["01001000"]).first.logradouro
+ end
+
+ def test_empty_list_makes_no_request
+ assert_empty @client.lookup_many([])
+ assert_empty routes.calls
+ end
+
+ def test_chunks_above_the_api_limit
+ ceps = Array.new(501) { |i| format("%08d", i) }
+ rows = ceps.map { |cep| "#{cep},,,,,X,SP,São Paulo,Sudeste,1,,11,1" }
+ routes.handler = lambda do |_path, call|
+ [200, csv_body(call == 1 ? rows[0, 500] : rows[500..]), {}]
+ end
+ result = @client.lookup_many(ceps)
+
+ assert_equal 2, routes.calls.length
+ assert_equal 501, result.length
+ end
+
+ def test_truncated_response_does_not_shift
+ body = csv_body(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ routes.respond("/csv?ceps=01001000%2C20040020", [200, body, {}])
+ result = @client.lookup_many(%w[01001000 20040020])
+
+ assert_equal 2, result.length
+ assert_nil result[1]
+ end
+end
+
+class HealthTest < ServerTest
+ def test_reads_health
+ body = JSON.generate(
+ { "status" => "ok", "dne" => "26071", "ceps" => 1_596_168,
+ "geo" => 894_489, "base_gerada_em" => "2026-07-24T03:48:28Z" }
+ )
+ routes.respond("/health", [200, body, {}])
+ health = @client.health
+
+ assert health.ok?
+ assert_equal "26071", health.dne
+ assert_equal 1_596_168, health.ceps
+ end
+
+ def test_degraded_does_not_raise
+ body = JSON.generate({ "status" => "degradado", "dne" => "", "ceps" => 0, "geo" => 0 })
+ routes.respond("/health", [503, body, {}])
+
+ refute @client.health.ok?
+ end
+end
+
+class ConfigurationTest < Minitest::Test
+ def test_trailing_slash_is_not_duplicated
+ assert_equal "http://x", Cepis::Client.new(base_url: "http://x/").base_url
+ end
+
+ def test_invalid_parameters
+ assert_raises(ArgumentError) { Cepis::Client.new(max_attempts: 0) }
+ assert_raises(ArgumentError) { Cepis::Client.new(timeout: 0) }
+ end
+
+ def test_user_agent_identifies_the_library
+ assert_includes Cepis::Client.new.user_agent, "cepis-ruby/#{Cepis::VERSION}"
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644
index 0000000..e12bd9d
--- /dev/null
+++ b/test/test_helper.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require "json"
+require "minitest/autorun"
+require "socket"
+
+require "cepis"
+
+# A CEP as the API returns it, used as the fixture across the suite. The field
+# names stay Portuguese because they mirror the API's JSON.
+SE_ADDRESS = {
+ "cep" => "01001000",
+ "logradouro" => "Praça da Sé",
+ "complemento" => "lado ímpar",
+ "unidade" => "",
+ "bairro" => "Sé",
+ "localidade" => "São Paulo",
+ "uf" => "SP",
+ "estado" => "São Paulo",
+ "regiao" => "Sudeste",
+ "ibge" => "3550308",
+ "gia" => "1004",
+ "ddd" => "11",
+ "siafi" => "7107"
+}.freeze
+
+CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado," \
+ "regiao,ibge,gia,ddd,siafi"
+
+# Per-test programmable routes, plus a request counter.
+class Router
+ attr_reader :calls
+ attr_accessor :handler
+
+ def initialize
+ @routes = {}
+ @calls = []
+ @handler = nil
+ @lock = Mutex.new
+ end
+
+ # Register the response for a path.
+ #
+ # The response may be a `[status, body, headers]` triple or an array of them
+ # — in which case each request consumes the next one, which is how retrying
+ # is tested. The last entry repeats once the list runs out.
+ def respond(path, response)
+ @lock.synchronize { @routes[path] = response }
+ end
+
+ # Record the call and return the response registered for `path`.
+ def next_response(path)
+ @lock.synchronize do
+ @calls << path
+ return @handler.call(path, @calls.length) if @handler
+
+ response = @routes[path]
+ return [404, JSON.generate({ "erro" => "cep_nao_encontrado" }), {}] if response.nil?
+ return response unless response.first.is_a?(Array)
+
+ response.length > 1 ? response.shift : response.first
+ end
+ end
+
+ # How many requests so far began with `prefix`.
+ def count(prefix)
+ @lock.synchronize { @calls.count { |path| path.start_with?(prefix) } }
+ end
+end
+
+# A throwaway HTTP/1.1 server on a random loopback port.
+#
+# Deliberately built on TCPServer rather than on a web server gem: the suite
+# must run against a stock Ruby, with nothing installed.
+class FakeServer
+ REASONS = {
+ 200 => "OK", 400 => "Bad Request", 404 => "Not Found",
+ 429 => "Too Many Requests", 500 => "Internal Server Error",
+ 502 => "Bad Gateway", 503 => "Service Unavailable"
+ }.freeze
+
+ attr_reader :router
+
+ def initialize
+ @router = Router.new
+ @server = TCPServer.new("127.0.0.1", 0)
+ @thread = Thread.new { accept_loop }
+ end
+
+ def url
+ "http://127.0.0.1:#{@server.addr[1]}"
+ end
+
+ def stop
+ @server.close
+ @thread.join(5)
+ end
+
+ private
+
+ def accept_loop
+ loop do
+ socket = begin
+ @server.accept
+ rescue IOError, Errno::EBADF
+ break
+ end
+ Thread.new(socket) { |client| serve(client) }
+ end
+ end
+
+ def serve(socket)
+ request_line = socket.gets
+ return if request_line.nil?
+
+ path = request_line.split(" ")[1]
+ while (line = socket.gets)
+ break if line == "\r\n"
+ end
+
+ status, body, headers = @router.next_response(path)
+ write_response(socket, status, body.to_s.b, headers || {})
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
+ nil
+ ensure
+ begin
+ socket.close
+ rescue IOError
+ nil
+ end
+ end
+
+ def write_response(socket, status, body, headers)
+ head = +"HTTP/1.1 #{status} #{REASONS.fetch(status, 'Unknown')}\r\n"
+ headers.each { |key, value| head << "#{key}: #{value}\r\n" }
+ head << "Content-Type: text/plain; charset=utf-8\r\n"
+ head << "Content-Length: #{body.bytesize}\r\n"
+ head << "Connection: close\r\n\r\n"
+ socket.write(head.b + body)
+ end
+end
+
+# Fixture with a fake server and a client whose backoff is near zero.
+#
+# The backoff is tiny because what is under test is the retry logic, not
+# `sleep`'s ability to sleep.
+class ServerTest < Minitest::Test
+ def setup
+ @server = FakeServer.new
+ @client = Cepis::Client.new(
+ base_url: @server.url, timeout: 5.0, backoff_base: 0.001, backoff_max: 0.01
+ )
+ end
+
+ def teardown
+ @server.stop
+ end
+
+ def routes
+ @server.router
+ end
+
+ def csv_body(rows)
+ "#{CSV_HEADER}\n#{rows.join("\n")}\n"
+ end
+end