# 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. ### The band of a CEP that does not exist A CEP that fails is rarely a random number: it is usually someone who knows the city and guessed the digits. The classic guess is the `-000`, which 957 municipalities never received. So the 404 tries to say something useful. When the numbers around the one you asked for belong to a municipality, `CEPNotFound#faixa` names it: ```ruby begin Cepis.lookup("25845-000") rescue Cepis::CEPNotFound => e if e.faixa puts e.faixa.localidade # "Areal" puts e.faixa.uf # "RJ" puts e.faixa.ibge # "3300225" puts e.faixa.ceps # 118 puts e.faixa.cep_ini # "25845001" puts e.faixa.cep_fim # "25849899" end end ``` Three things about it, all of which are easy to get wrong: - **It is still a 404.** `faixa` is context for the failure, not a fallback address. The CEP asked for does not exist; do not save it, do not ship to it. It is for the error message, the log line and the "did you mean somewhere in Areal?" prompt. - **`cep_ini` and `cep_fim` are the smallest and the largest CEP that actually exist there**, not the bounds the Correios publish. The DNE's own start for Areal is `25845000` — exactly the CEP that just failed, which is why the published bounds would be useless here. - **The band is sparse.** Areal's 118 CEPs are scattered across the ~4,900 numbers between `25845001` and `25849899`. That is what `ceps` is for. The pair is not a range of valid CEPs, and iterating it produces about fifty nonexistent numbers for each real one. `faixa` is `nil` when the CEP falls in a band no municipality owns — the API **omits** the field there, it never sends null — and also when the 404 body is unreadable. Reading it can never fail loudly: a not-found stays a clean `CEPNotFound`, never an `InvalidResponse`. `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).