From b402de683eecef4c890081dd72edbcaf57f375e0 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:32:01 -0300 Subject: Expose the band of a CEP that does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CEP that fails a lookup is rarely a random number: it is someone who knows the city and guessed the digits, most often the -000 that 957 municipalities never received. The API now answers that 404 with an optional faixa naming the municipality those numbers belong to, so the caller can say "not found, but you seem to be looking at Areal/RJ" instead of just "not found". It stays an error. Faixa hangs off CEPNotFound and nothing about it promotes a nonexistent CEP into an address. Its cep_ini/cep_fim are the smallest and largest CEP that really exist there, not the published bounds — the DNE's own start for Areal is the CEP that just failed — and the band is sparse, which is what ceps is for; both facts are spelled out in the docs so nobody iterates the pair. Parsing is deliberately toothless: a missing, empty or malformed faixa, or a 404 body that is not JSON at all, all yield nil. Losing the consolation prize must never replace a clean not-found with a parse failure. --- README.md | 44 +++++++++++++++++++++++++++ lib/cepis.rb | 7 +++-- lib/cepis/client.rb | 41 +++++++++++++++++++++++-- lib/cepis/errors.rb | 20 ++++++++++++- lib/cepis/models.rb | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/cepis/version.rb | 2 +- test/cepis_test.rb | 61 +++++++++++++++++++++++++++++++++++++ test/test_helper.rb | 16 ++++++++++ 8 files changed, 270 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 017fe89..0085f1c 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,50 @@ 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. diff --git a/lib/cepis.rb b/lib/cepis.rb index 7b2cc5a..39cd831 100644 --- a/lib/cepis.rb +++ b/lib/cepis.rb @@ -27,8 +27,11 @@ require_relative "cepis/client" # # begin # address = Cepis.lookup(cep_from_form) -# rescue Cepis::CEPNotFound -# # the CEP does not exist: ask the user for another +# rescue Cepis::CEPNotFound => e +# # the CEP does not exist: ask the user for another. e.faixa, when the +# # API sends it, names the municipality whose numbers surround the one +# # that failed — context for the error, never an address. +# puts "did you mean somewhere in #{e.faixa.localidade}?" if e.faixa # rescue Cepis::Error # # network, server, rate limit: retry later # end diff --git a/lib/cepis/client.rb b/lib/cepis/client.rb index f451061..8480315 100644 --- a/lib/cepis/client.rb +++ b/lib/cepis/client.rb @@ -113,10 +113,15 @@ module Cepis # message: the format already passed local validation, so the server knows # something this client does not. # + # A 404 may carry {CEPNotFound#faixa}: the municipality that owns the band + # of numbers the CEP fell into. It is still a 404 and still an exception — + # the context does not make a nonexistent CEP into an address. See {Faixa}. + # # @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 [CEPNotFound] well-formed, but not in the database. Carries + # {CEPNotFound#faixa} when the API named a municipality for the band. # @raise [RateLimited] 429 after every attempt. # @raise [ServerError] 5xx after every attempt. # @raise [NetworkError] the request never completed. @@ -125,7 +130,7 @@ module Cepis number = Cepis.normalize_cep(cep) status, body = request("/#{number}") - raise CEPNotFound, "CEP #{number} does not exist in the DNE database" if status == 404 + raise not_found(number, body) if status == 404 raise InvalidCEP, error_message(body, "CEP #{number} rejected") if status == 400 Address.from_json(parse_json(body)) @@ -219,6 +224,38 @@ module Cepis body.to_s.dup.force_encoding(Encoding::UTF_8).scrub end + # The {CEPNotFound} for a 404, with the band read off the body when it is + # there. + # + # Every step of reading it is allowed to fail into `nil`: an absent + # `faixa` is the ordinary case (the API omits it, it never sends null), + # and a body that is not JSON at all is a proxy's doing, not the caller's. + # Neither may promote a definitive, well-understood not-found into an + # {InvalidResponse} — the reason the lookup failed would be replaced by + # the reason the consolation prize failed to parse. + def not_found(number, body) + faixa = begin + data = JSON.parse(decode(body)) + data.is_a?(Hash) ? Faixa.from_json(data["faixa"]) : nil + rescue JSON::ParserError + nil + end + + CEPNotFound.new(not_found_message(number, faixa), faixa: faixa) + end + + # The message names the municipality when the API named one, because + # "does not exist" plus "the numbers around it are Areal/RJ" is what makes + # the log line diagnosable. The counts stay in the message too, so that + # nobody reads the two CEPs as the ends of a contiguous range. + def not_found_message(number, faixa) + base = "CEP #{number} does not exist in the DNE database" + return base if faixa.nil? + + "#{base} (it falls in the band of #{faixa.localidade}/#{faixa.uf}, which has " \ + "#{faixa.ceps} CEP(s) scattered between #{faixa.cep_ini} and #{faixa.cep_fim})" + end + def error_message(body, fallback) data = JSON.parse(decode(body)) data.is_a?(Hash) && data["mensagem"] ? String(data["mensagem"]) : fallback diff --git a/lib/cepis/errors.rb b/lib/cepis/errors.rb index 7a15841..71b8a29 100644 --- a/lib/cepis/errors.rb +++ b/lib/cepis/errors.rb @@ -27,7 +27,25 @@ module Cepis class InvalidCEP < Error; end # The CEP is well-formed but does not exist in the DNE database. - class CEPNotFound < Error; end + class CEPNotFound < Error + # The municipality that owns the band this CEP falls into, as a {Faixa}, + # or `nil` when no municipality owns it — or when the API did not say. + # + # It is context for the failure, not a fallback address: the CEP asked for + # does not exist, and this only names the neighbourhood of numbers it + # would have belonged to. Useful because a CEP that fails is usually a + # guess by someone who knows the city, so the municipality is the one + # thing about it that tends to be right. + # + # Read {Faixa} before showing `cep_ini`/`cep_fim` to anyone: the band is + # sparse, and the pair is not a range of valid CEPs. + attr_reader :faixa + + def initialize(message, faixa: nil) + super(message) + @faixa = faixa + end + end # The request never completed: DNS, refused connection, timeout, TLS. # diff --git a/lib/cepis/models.rb b/lib/cepis/models.rb index a0181ff..a80fe37 100644 --- a/lib/cepis/models.rb +++ b/lib/cepis/models.rb @@ -145,6 +145,91 @@ module Cepis end end + # The municipality that owns the band a **nonexistent** CEP falls into. + # + # It is context for an error, never an address: it arrives on the 404 of + # {Client#lookup}, hanging off {CEPNotFound#faixa}, and says "this CEP does + # not exist, but the numbers around it belong to Areal/RJ". The lookup still + # failed — nothing here turns it into a result. + # + # It is worth having because 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. + # + # **{#cep_ini} and {#cep_fim} are the smallest and the largest CEP that + # actually exist in the municipality, 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`. {#ceps} is there to say so. Do + # not present the pair as a range of valid CEPs, and do not iterate it: all + # but a fiftieth of it does not exist. + class Faixa + attr_reader :localidade, :uf, :estado, :regiao, :ibge, :cep_ini, :cep_fim, :ceps + + def initialize(localidade:, uf:, estado:, regiao:, ibge:, cep_ini:, cep_fim:, ceps:) + @localidade = localidade + @uf = uf + @estado = estado + @regiao = regiao + @ibge = ibge + @cep_ini = cep_ini + @cep_fim = cep_fim + @ceps = ceps + freeze + end + + # Build from the hash the API returned, or `nil` when the block is + # missing, empty or malformed. + # + # The API **omits** `faixa` — it never sends null — for a CEP falling in a + # band no municipality owns, so absence is the ordinary case and not an + # anomaly. Anything unreadable is treated as absence too: this is a bonus + # on top of a failure that has already happened, and it must never be the + # reason a clean not-found becomes something else. + def self.from_json(data) + return nil unless data.is_a?(Hash) + + block = data.transform_keys(&:to_s) + localidade = String(block["localidade"] || "").strip + return nil if localidade.empty? + + new( + localidade: localidade, + uf: String(block["uf"] || ""), + estado: String(block["estado"] || ""), + regiao: String(block["regiao"] || ""), + ibge: String(block["ibge"] || ""), + cep_ini: String(block["cep_ini"] || ""), + cep_fim: String(block["cep_fim"] || ""), + ceps: Integer(block["ceps"] || 0, exception: false) || 0 + ) + rescue TypeError, ArgumentError + nil + end + + def to_h + { + localidade: @localidade, uf: @uf, estado: @estado, regiao: @regiao, + ibge: @ibge, cep_ini: @cep_ini, cep_fim: @cep_fim, ceps: @ceps + } + end + + def ==(other) + other.is_a?(Faixa) && to_h == other.to_h + end + alias eql? == + + def hash + to_h.hash + end + + def to_s + "#" + end + end + # The response of `GET /health`. # # Good for two things: checking that the API answers, and knowing **which diff --git a/lib/cepis/version.rb b/lib/cepis/version.rb index cb88e06..ad38425 100644 --- a/lib/cepis/version.rb +++ b/lib/cepis/version.rb @@ -2,5 +2,5 @@ module Cepis # Version of this library, as reported in the default `User-Agent`. - VERSION = "0.1.0" + VERSION = "0.2.0" end diff --git a/test/cepis_test.rb b/test/cepis_test.rb index 06a6bf6..6fd3353 100644 --- a/test/cepis_test.rb +++ b/test/cepis_test.rb @@ -91,6 +91,67 @@ class LookupTest < ServerTest assert_includes error.message, "99999999" end + def test_not_found_carries_the_band + body = { "erro" => "cep_nao_encontrado", "mensagem" => "CEP não existe na base do DNE", + "faixa" => AREAL_FAIXA } + routes.respond("/25845000", [404, JSON.generate(body), {}]) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("25845000") } + + assert_equal "Areal", error.faixa.localidade + assert_equal "RJ", error.faixa.uf + assert_equal "3300225", error.faixa.ibge + assert_equal 118, error.faixa.ceps + # The band starts above the CEP that just failed: cep_ini is the smallest + # CEP that exists, not the DNE's published start, which is 25845000. + assert_equal "25845001", error.faixa.cep_ini + assert_equal "25849899", error.faixa.cep_fim + end + + def test_the_band_names_the_municipality_in_the_message + body = { "erro" => "cep_nao_encontrado", "faixa" => AREAL_FAIXA } + routes.respond("/25845000", [404, JSON.generate(body), {}]) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("25845000") } + + assert_includes error.message, "25845000" + assert_includes error.message, "Areal/RJ" + end + + def test_band_is_absent_when_no_municipality_owns_the_numbers + # The API omits `faixa` in that case — it never sends null. + routes.respond( + "/99999999", + [404, JSON.generate({ "erro" => "cep_nao_encontrado", "mensagem" => "gone" }), {}] + ) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } + + assert_nil error.faixa + end + + def test_malformed_band_does_not_break_the_not_found + ["Areal", {}, { "uf" => "RJ" }, { "localidade" => "" }, [], 7].each do |faixa| + body = { "erro" => "cep_nao_encontrado", "faixa" => faixa } + routes.respond("/99999999", [404, JSON.generate(body), {}]) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } + + assert_nil error.faixa, "expected #{faixa.inspect} to be ignored" + assert_includes error.message, "99999999" + end + end + + def test_unreadable_404_body_is_still_a_clean_not_found + routes.respond("/99999999", [404, "captive portal", {}]) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } + + assert_nil error.faixa + end + + def test_band_never_turns_a_not_found_into_an_address + body = { "erro" => "cep_nao_encontrado", "faixa" => AREAL_FAIXA } + routes.respond("/25845000", [404, JSON.generate(body), {}]) + + assert_raises(Cepis::CEPNotFound) { @client.lookup("25845000") } + end + def test_not_found_is_not_retried routes.respond("/99999999", [404, JSON.generate({ "erro" => "x" }), {}]) assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } diff --git a/test/test_helper.rb b/test/test_helper.rb index e12bd9d..48b581d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -24,6 +24,22 @@ SE_ADDRESS = { "siafi" => "7107" }.freeze +# The band of a CEP that does not exist, as the API returns it on the 404. +# +# 25845000 is the DNE's own start for Areal and does not exist: `cep_ini` is +# the smallest CEP that really is there, one number above it. The 118 CEPs are +# scattered across the ~4,900 numbers up to `cep_fim`. +AREAL_FAIXA = { + "localidade" => "Areal", + "uf" => "RJ", + "estado" => "Rio de Janeiro", + "regiao" => "Sudeste", + "ibge" => "3300225", + "cep_ini" => "25845001", + "cep_fim" => "25849899", + "ceps" => 118 +}.freeze + CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado," \ "regiao,ibge,gia,ddd,siafi" -- cgit v1.3