diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:22:17 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:23:14 -0300 |
| commit | 516254878b0b8f92a8ef4543bf5d0601aac3466d (patch) | |
| tree | bbe750f8863fe2ee16bbee405caf0c2e58694dfe | |
| parent | 0757164507a1f9be8b9f61292a9069759f1da034 (diff) | |
| download | kotlin-516254878b0b8f92a8ef4543bf5d0601aac3466d.tar.gz | |
A CEP that fails is almost never 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 names the municipality that number would belong
to, so the client surfaces it as CepNotFoundException.faixa instead of
throwing away the one piece of information that makes the failure useful.
It stays an exception. The block is context for a 404, not an address, and
cep_ini/cep_fim are the smallest and largest CEP that actually exist there
rather than the range the Correios publish -- the published start for Areal
is the very CEP that just failed. The range is sparse (118 CEPs across some
9,000 numbers), which is what ceps is for.
Parsing is deliberately total: absent, null, empty or malformed all yield
null. Losing a clean not-found to a parse error over an optional field would
be a bad trade.
| -rw-r--r-- | README.md | 49 | ||||
| -rw-r--r-- | build.gradle.kts | 2 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Cepis.kt | 2 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Client.kt | 29 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Errors.kt | 15 | ||||
| -rw-r--r-- | src/main/kotlin/org/cepis/Models.kt | 78 | ||||
| -rw-r--r-- | src/test/kotlin/org/cepis/LookupTest.kt | 61 | ||||
| -rw-r--r-- | src/test/kotlin/org/cepis/ServerTest.kt | 13 |
8 files changed, 241 insertions, 8 deletions
@@ -27,7 +27,7 @@ repositories { } dependencies { - implementation("org.cepis:cepis:0.1.0") + implementation("org.cepis:cepis:0.2.0") } ``` @@ -145,7 +145,7 @@ val client = backoffBase = Duration.ofMillis(500), // base of the exponential wait backoffMax = Duration.ofSeconds(8), // cap on each wait totalTimeout = null, // cap on the whole call, attempts included - userAgent = null, // defaults to cepis-kotlin/0.1.0 + userAgent = null, // defaults to cepis-kotlin/0.2.0 ) val address = client.lookup("01001000") @@ -176,7 +176,7 @@ is by **the decision you have to make**, not by the HTTP status that caused it: | error | when | does retrying help? | | --- | --- | --- | | `InvalidCepException` | not 8 digits | no | -| `CepNotFoundException` | well-formed, not in the database | no | +| `CepNotFoundException` | well-formed, not in the database; may carry `faixa` | no | | `RateLimitedException` | 429; carries `retryAfter` | yes, later | | `ServerErrorException` | 5xx; carries `status` | yes, later | | `NetworkException` | DNS, connection, timeout, TLS | yes, later | @@ -192,6 +192,49 @@ try { } ``` +### When the CEP 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 `CepNotFoundException` carries a `faixa` +naming the municipality the number would have belonged to: + +```kotlin +try { + lookup("25845-000") +} catch (e: CepNotFoundException) { + val f = e.faixa + if (f != null) { + println("${f.localidade}/${f.uf}") // Areal/RJ + println(f.ibge) // "3300225" + println("${f.cepIni}..${f.cepFim}") // 25845001..25849899 + println(f.ceps) // 118 + } +} +``` + +Three things to keep straight: + +- **It is still a 404.** `faixa` is context for the failure, not an address. + The CEP does not exist; do not let the block turn a not-found into a + success. +- **`cepIni` and `cepFim` are the smallest and the largest CEP that actually + exist** in that municipality, not the bounds the Correios publish. The DNE's + own start for Areal is `25845000` — precisely the CEP that just failed, so + echoing it back would help nobody. +- **The range is sparse.** Areal's 118 CEPs are spread over some 4,900 + numbers; that is what `ceps` is for. Never present the pair as a list of + valid CEPs — "try something between 25845001 and 25849899" is wrong advice + 99% of the time. + +`faixa` is **omitted, never null**, when the number falls in a band no +municipality owns, so `null` is a normal outcome and not an error. A missing, +malformed or empty block simply yields `null`: it can never cost you the +`CepNotFoundException` itself. + +`lookupMany` is unaffected — the CSV endpoint behind it has no such column, +and a CEP that does not exist is still `null` at its position. + A nonsensical `Client` configuration throws a plain `IllegalArgumentException` instead: that is a programming error, not a lookup failure. diff --git a/build.gradle.kts b/build.gradle.kts index 31ce4a7..1813169 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "org.cepis" -version = "0.1.0" +version = "0.2.0" repositories { mavenCentral() diff --git a/src/main/kotlin/org/cepis/Cepis.kt b/src/main/kotlin/org/cepis/Cepis.kt index b9e0e34..2cd3516 100644 --- a/src/main/kotlin/org/cepis/Cepis.kt +++ b/src/main/kotlin/org/cepis/Cepis.kt @@ -11,7 +11,7 @@ const val DEFAULT_BASE_URL: String = "https://cep.is" const val MAX_BATCH: Int = 500 /** This library's version, as it appears in the default `User-Agent`. */ -const val VERSION: String = "0.1.0" +const val VERSION: String = "0.2.0" private val DIGITS_ONLY = Regex("""^\d{8}$""") diff --git a/src/main/kotlin/org/cepis/Client.kt b/src/main/kotlin/org/cepis/Client.kt index e315b74..6a054ae 100644 --- a/src/main/kotlin/org/cepis/Client.kt +++ b/src/main/kotlin/org/cepis/Client.kt @@ -95,6 +95,10 @@ class Client( * API's own message: the format already passed local validation, so the * server knows something this client does not. * + * A 404 may carry a [Faixa] in [CepNotFoundException.faixa] naming the + * municipality the CEP would have belonged to. It is still a 404 — the + * block is context for the failure, not an address. + * * @throws InvalidCepException the CEP is not 8 digits (no request is made). * @throws CepNotFoundException well-formed, but not in the database. * @throws RateLimitedException 429 after every attempt. @@ -107,7 +111,12 @@ class Client( val reply = request("/$number") if (reply.status == 404) { - throw CepNotFoundException("CEP $number does not exist in the DNE database") + val faixa = faixaFrom(reply.body) + val where = faixa?.let { " — that number falls in ${it.localidade}/${it.uf}" } ?: "" + throw CepNotFoundException( + "CEP $number does not exist in the DNE database$where", + faixa, + ) } if (reply.status == 400) { throw InvalidCepException(errorMessage(reply.body, "CEP $number rejected")) @@ -210,6 +219,24 @@ class Client( return parsed as Map<String, Any?> } + /** + * The `faixa` block of a 404 body, or `null`. + * + * Swallows everything: a body that is not JSON, is not an object, or has + * no usable `faixa`. The lookup has already failed and the caller is going + * to get [CepNotFoundException] either way — losing that exception to a + * parse error over an optional field would be a bad trade. + */ + private fun faixaFrom(body: ByteArray): Faixa? { + val parsed = + try { + Json.parse(body.toString(StandardCharsets.UTF_8)) + } catch (e: JsonSyntaxException) { + return null + } + return Faixa.fromJson((parsed as? Map<*, *>)?.get("faixa")) + } + private fun errorMessage( body: ByteArray, fallback: String, diff --git a/src/main/kotlin/org/cepis/Errors.kt b/src/main/kotlin/org/cepis/Errors.kt index 8d3f1ff..d80df57 100644 --- a/src/main/kotlin/org/cepis/Errors.kt +++ b/src/main/kotlin/org/cepis/Errors.kt @@ -40,8 +40,19 @@ sealed class CepisException( */ class InvalidCepException(message: String) : CepisException(message) -/** The CEP is well-formed but does not exist in the DNE database. */ -class CepNotFoundException(message: String) : CepisException(message) +/** + * The CEP is well-formed but does not exist in the DNE database. + * + * @property faixa the municipality the CEP would have belonged to, when the + * API can name one, and `null` when the number falls in a band no + * municipality owns. It is **context for the failure, not an address**: this + * is still a 404 and there is still no CEP. Read [Faixa] before showing its + * range to anyone — the two endpoints are not a list of valid CEPs. + */ +class CepNotFoundException( + message: String, + val faixa: Faixa? = null, +) : CepisException(message) /** * The request never completed: DNS, refused connection, timeout, TLS. diff --git a/src/main/kotlin/org/cepis/Models.kt b/src/main/kotlin/org/cepis/Models.kt index 058244e..7841082 100644 --- a/src/main/kotlin/org/cepis/Models.kt +++ b/src/main/kotlin/org/cepis/Models.kt @@ -39,6 +39,84 @@ data class Geo( } /** + * The municipality a **nonexistent** CEP would have belonged to. + * + * This is context for an error, never an address. It arrives on the 404 of + * [Client.lookup], in [CepNotFoundException.faixa], and it does not make the + * CEP exist: the lookup still failed. + * + * It is there 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. + * + * Two things about the range that are easy to get wrong: + * + * - [cepIni] and [cepFim] are the smallest and the largest CEP that **actually + * exist** in that municipality, not the bounds the Correios publish. For + * Areal the DNE's own start is `25845000` — which is exactly the CEP that + * just failed. Echoing that back would be useless, so the API does not. + * - The range is **sparse**. Areal's 118 CEPs are spread across some 4,900 + * numbers. [cepIni]/[cepFim] are two endpoints, not a list of valid CEPs, + * and [ceps] is there to keep that from being forgotten. + * + * @property localidade the municipality's name. + * @property uf its two-letter state code. + * @property estado the state's full name. + * @property regiao the region of Brazil it sits in. + * @property ibge the municipality's IBGE code — the field to use to look the + * place up somewhere else. + * @property cepIni the smallest CEP that exists there. The API calls this + * field `cep_ini`. + * @property cepFim the largest CEP that exists there. The API calls this field + * `cep_fim`. + * @property ceps how many CEPs exist between those two endpoints. Almost + * always far fewer than the numeric distance between them. + */ +data class Faixa( + val localidade: String = "", + val uf: String = "", + val estado: String = "", + val regiao: String = "", + val ibge: String = "", + val cepIni: String = "", + val cepFim: String = "", + val ceps: Int = 0, +) { + companion object { + /** + * Build from the `faixa` block of a 404 body, or `null`. + * + * Takes `Any?` rather than a map, and returns `null` for anything that + * is not a usable block — absent, not an object, empty, or without a + * municipality name. The API **omits** `faixa` (it never sends null) + * when the CEP falls in a numeric band no municipality owns, and that + * is the common case this has to survive. + * + * Nothing here throws, deliberately. The caller is already handling a + * failed lookup; a surprise in this block must not turn a clean + * [CepNotFoundException] into a parse error. + */ + fun fromJson(block: Any?): Faixa? { + if (block !is Map<*, *>) return null + @Suppress("UNCHECKED_CAST") + val fields = block as Map<String, Any?> + val localidade = fields.text("localidade") + if (localidade.isEmpty()) return null + return Faixa( + localidade = localidade, + uf = fields.text("uf"), + estado = fields.text("estado"), + regiao = fields.text("regiao"), + ibge = fields.text("ibge"), + cepIni = fields.text("cep_ini"), + cepFim = fields.text("cep_fim"), + ceps = fields.number("ceps")?.toInt() ?: 0, + ) + } + } +} + +/** * A CEP and everything the database knows about it. * * **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`, diff --git a/src/test/kotlin/org/cepis/LookupTest.kt b/src/test/kotlin/org/cepis/LookupTest.kt index c266025..e106f70 100644 --- a/src/test/kotlin/org/cepis/LookupTest.kt +++ b/src/test/kotlin/org/cepis/LookupTest.kt @@ -79,6 +79,67 @@ internal class LookupTest : ServerTest() { } @Test + fun `not found names the municipality the cep would belong to`() { + server.respond("/25845000", Reply(404, Fixtures.NOT_FOUND_WITH_FAIXA_JSON)) + val error = assertFailsWith<CepNotFoundException> { client.lookup("25845-000") } + val faixa = assertNotNull(error.faixa) + + assertEquals("Areal", faixa.localidade) + assertEquals("RJ", faixa.uf) + assertEquals("Rio de Janeiro", faixa.estado) + assertEquals("Sudeste", faixa.regiao) + assertEquals("3300225", faixa.ibge) + assertEquals("25845001", faixa.cepIni) + assertEquals("25849899", faixa.cepFim) + assertEquals(118, faixa.ceps) + assertTrue(error.message!!.contains("Areal")) + } + + @Test + fun `not found without a faixa is still a clean not found`() { + server.respond("/99999999", Reply(404, Fixtures.NOT_FOUND_JSON)) + val error = assertFailsWith<CepNotFoundException> { client.lookup("99999999") } + + assertNull(error.faixa) + assertTrue(error.message!!.contains("99999999")) + } + + @Test + fun `an unusable faixa does not cost the not found`() { + val bodies = + listOf( + """{"erro":"cep_nao_encontrado","faixa":null}""", + """{"erro":"cep_nao_encontrado","faixa":{}}""", + """{"erro":"cep_nao_encontrado","faixa":"Areal"}""", + """{"erro":"cep_nao_encontrado","faixa":{"uf":"RJ"}}""", + """{"erro":"cep_nao_encontrado","faixa":""", + "<html>not found</html>", + "", + ) + for (body in bodies) { + server.respond("/99999999", Reply(404, body)) + val error = assertFailsWith<CepNotFoundException>(body) { client.lookup("99999999") } + + assertNull(error.faixa, body) + } + } + + @Test + fun `a faixa missing a field keeps the rest`() { + val body = + """{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal","uf":"RJ"}}""" + server.respond("/25845000", Reply(404, body)) + val faixa = + assertNotNull( + assertFailsWith<CepNotFoundException> { client.lookup("25845000") }.faixa, + ) + + assertEquals("Areal", faixa.localidade) + assertEquals("", faixa.cepIni) + assertEquals(0, faixa.ceps) + } + + @Test fun `not found is not retried`() { server.respond("/99999999", Reply(404, """{"erro":"x"}""")) assertFailsWith<CepNotFoundException> { client.lookup("99999999") } diff --git a/src/test/kotlin/org/cepis/ServerTest.kt b/src/test/kotlin/org/cepis/ServerTest.kt index b22975a..8175190 100644 --- a/src/test/kotlin/org/cepis/ServerTest.kt +++ b/src/test/kotlin/org/cepis/ServerTest.kt @@ -40,6 +40,19 @@ internal object Fixtures { "ddd":"11","siafi":"7107"} """ + /** A 404 body carrying the municipality the CEP would have belonged to. */ + const val NOT_FOUND_WITH_FAIXA_JSON = """ + {"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", + "faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro", + "regiao":"Sudeste","ibge":"3300225","cep_ini":"25845001", + "cep_fim":"25849899","ceps":118}} + """ + + /** A 404 for a number in a band no municipality owns: `faixa` is absent. */ + const val NOT_FOUND_JSON = """ + {"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"} + """ + const val CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado," + "regiao,ibge,gia,ddd,siafi" |