summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:31:30 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:31:30 -0300
commit67276c6444a70b61030ab3ee52b84dc5014af0e8 (patch)
treeac0246ed287c6aec57e335403b20b23214ec4eb0
parent64764fb9fac0227f8e0443e7c7519d73aec275b9 (diff)
downloadphp-main.tar.gz
Carry the municipality of a not-found CEP on the exceptionHEADv0.2.0main
The API now sends an optional faixa block on the 404 of GET /{cep}, naming the municipality the CEP would have belonged to. That failing CEP is rarely random: it is usually someone who knows the city and guessed the number, most often the -000 that 957 municipalities never received. Knowing the lookup failed "in Areal/RJ" is the difference between a message a user can act on and a bare "CEP not found". It stays an error. Faixa hangs off CepNotFoundException, never off Address, and parsing is deliberately forgiving: a missing, empty or malformed block yields null rather than turning a clean not-found into a parse failure. Its cep_ini/cep_fim are the CEPs that exist, not the bounds the Correios publish, and the interval between them is sparse -- hence ceps, and hence the docblocks insisting the pair is not a list of valid CEPs.
-rw-r--r--README.md56
-rw-r--r--src/CepNotFoundException.php19
-rw-r--r--src/Client.php36
-rw-r--r--src/Faixa.php137
-rw-r--r--tests/LookupTest.php139
5 files changed, 382 insertions, 5 deletions
diff --git a/README.md b/README.md
index 5d26dbd..e13ed1a 100644
--- a/README.md
+++ b/README.md
@@ -233,7 +233,7 @@ the HTTP status that caused it:
| exception | 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 |
@@ -264,6 +264,60 @@ an `\InvalidArgumentException` and `CepNotFoundException` is an
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.
+### A CEP that does not exist, but in a city that does
+
+A failing CEP is rarely random: it is usually someone who knows the city and
+guessed the number — and the classic guess is the `-000`, which 957
+municipalities never received. So `CepNotFoundException` carries a `Faixa` when
+the API can say which municipality the CEP *would* have belonged to:
+
+```php
+try {
+ $address = Client::default()->lookup('25845-000');
+} catch (CepNotFoundException $e) {
+ if ($e->faixa !== null) {
+ printf(
+ "CEP not found in %s — the city has %d CEPs, between %s and %s\n",
+ $e->faixa->label(), // "Areal/RJ"
+ $e->faixa->ceps, // 118
+ $e->faixa->cep_ini, // "25845001"
+ $e->faixa->cep_fim, // "25849899"
+ );
+ }
+}
+```
+
+It is still a 404. **`faixa` is context for an error, never an address**: a CEP
+that has one is just as absent from the database as one that does not, and
+nothing here should turn a not-found into a success. What it buys you is a
+message worth reading — *"we could not find that CEP in Areal/RJ"* instead of
+*"CEP not found"*.
+
+`$e->faixa` is null whenever the API sent nothing, which is what happens for a
+CEP in a band no municipality owns. The field is **omitted, never null**, so
+absence is normal; and a `faixa` this client cannot read is also null, because a
+malformed block must never cost you the exception you came for.
+
+| field | |
+| --- | --- |
+| `localidade`, `uf`, `estado`, `regiao`, `ibge` | the municipality, same keys as on `Address` |
+| `cep_ini`, `cep_fim` | the smallest and largest CEP that **exist** there |
+| `ceps` | how many CEPs the municipality actually has |
+| `label()` | `"Areal/RJ"`, for showing to a person |
+
+Two things about the range, both easy to get wrong:
+
+`cep_ini` and `cep_fim` are **not** the bounds the Correios publish. They are
+the extremes of what is really in the database. The DNE's own start for Areal is
+`25845000` — exactly the CEP that failed above — and it is not `cep_ini`
+precisely because it does not exist.
+
+**The range is sparse.** Areal's 118 CEPs are spread across the ~4,900 numbers
+between `25845001` and `25849899`; almost all of that interval is empty. That is
+what `ceps` is for. Never iterate the interval, never present the pair as the
+list of valid CEPs for the city, and never conclude from
+`cep_ini <= $x <= cep_fim` that `$x` exists.
+
## Development
Everything runs through `make`:
diff --git a/src/CepNotFoundException.php b/src/CepNotFoundException.php
index c64de8b..e9511e7 100644
--- a/src/CepNotFoundException.php
+++ b/src/CepNotFoundException.php
@@ -9,7 +9,26 @@ namespace Cepis;
*
* Extends `\OutOfBoundsException`, the SPL type for a value that is not a
* valid key: looking a CEP up is conceptually a key lookup.
+ *
+ * {@see CepNotFoundException::$faixa} may name the municipality the CEP would
+ * have belonged to. It does not soften the answer — the CEP still does not
+ * exist — it only makes the failure sayable: *"we could not find that CEP in
+ * Areal/RJ"* rather than *"CEP not found"*.
*/
final class CepNotFoundException extends \OutOfBoundsException implements CepisException
{
+ /**
+ * @param Faixa|null $faixa The municipality whose range covers the CEP, or
+ * `null` when the API sent none — which is the case for a CEP in a band
+ * no municipality owns, and also whenever the block cannot be read.
+ * Never assume it is there. See {@see Faixa} for why the range it
+ * carries is not a list of valid CEPs.
+ */
+ public function __construct(
+ string $message,
+ public readonly ?Faixa $faixa = null,
+ ?\Throwable $previous = null,
+ ) {
+ parent::__construct($message, 0, $previous);
+ }
}
diff --git a/src/Client.php b/src/Client.php
index 4e24b7f..5fd19b1 100644
--- a/src/Client.php
+++ b/src/Client.php
@@ -30,7 +30,7 @@ namespace Cepis;
*/
final class Client
{
- public const VERSION = '0.1.0';
+ public const VERSION = '0.2.0';
/**
* Where the API lives, unless `baseUrl` says otherwise.
@@ -171,6 +171,12 @@ final class Client
* the API's own message: the format already passed local validation, so
* the server knows something this client does not.
*
+ * A 404 is surfaced as {@see CepNotFoundException}, which may carry a
+ * {@see Faixa} naming the municipality the CEP would have belonged to. It
+ * is still a 404 either way: the `faixa` is context for the error, never an
+ * address, and a CEP that has one is just as absent from the DNE as one
+ * that does not.
+ *
* @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.
@@ -184,9 +190,13 @@ final class Client
[$status, $body] = $this->request('/' . $number);
if ($status === 404) {
- throw new CepNotFoundException(
- sprintf('CEP %s does not exist in the DNE database', $number),
- );
+ $faixa = self::errorFaixa($body);
+ $message = sprintf('CEP %s does not exist in the DNE database', $number);
+ if ($faixa !== null) {
+ $message .= sprintf(' (the range covering it is %s)', $faixa->label());
+ }
+
+ throw new CepNotFoundException($message, $faixa);
}
if ($status === 400) {
throw new InvalidCepException(
@@ -390,6 +400,24 @@ final class Client
}
/**
+ * Pull the optional `faixa` block out of a 404 body, or `null`.
+ *
+ * Nothing in here throws, by design. The body of a not-found is extra
+ * information on top of an answer the caller already has, so a truncated
+ * response, an HTML error page from a proxy or a `faixa` in a shape this
+ * release does not know must all end the same way: no `faixa`, and the same
+ * {@see CepNotFoundException} that would have been thrown anyway. Note the
+ * plain `json_decode` — {@see Client::decodeJsonObject()} is deliberately
+ * not used here, because its whole job is to throw.
+ */
+ private static function errorFaixa(string $body): ?Faixa
+ {
+ $data = json_decode($body, true);
+
+ return is_array($data) ? Faixa::fromArray($data['faixa'] ?? null) : null;
+ }
+
+ /**
* Parse the `Retry-After` header in both RFC 9110 forms.
*
* It may arrive as a number of seconds (`"120"`) or as an HTTP date
diff --git a/src/Faixa.php b/src/Faixa.php
new file mode 100644
index 0000000..3f4be84
--- /dev/null
+++ b/src/Faixa.php
@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The municipality a CEP *would* belong to, when the CEP itself does not exist.
+ *
+ * It arrives on the 404 of `GET /{cep}` and is carried by
+ * {@see CepNotFoundException::$faixa}. **It is context for an error, never an
+ * address.** A CEP with a `faixa` is still a CEP that is not in the DNE: the
+ * answer to the lookup remains "does not exist".
+ *
+ * It exists because a failing CEP is rarely random. It is usually someone who
+ * knows the city and guessed the number — and the classic guess is the `-000`,
+ * which 957 municipalities never received. Knowing that `25845000` "is Areal"
+ * is what lets you say *"we could not find that CEP in Areal/RJ"* instead of
+ * the useless *"CEP not found"*.
+ *
+ * {@see Faixa::$cep_ini} and {@see Faixa::$cep_fim} are the smallest and the
+ * largest CEP that **actually exist** in that municipality, not the bounds the
+ * Correios publish for it. The DNE's own start for Areal is `25845000` —
+ * exactly the CEP that just failed — and it is not `cep_ini` precisely because
+ * it does not exist.
+ *
+ * **The range is sparse, and the pair is not a list of valid CEPs.** Areal's
+ * 118 CEPs are spread across the ~4,900 numbers between `25845001` and
+ * `25849899`; the overwhelming majority of that interval is empty. That is what
+ * {@see Faixa::$ceps} is for. Never iterate the interval, never present it as
+ * "the CEPs of this city", and never conclude from `cep_ini <= x <= cep_fim`
+ * that `x` exists.
+ *
+ * The whole object is **omitted** by the API — never sent as null — when the
+ * CEP falls in a band no municipality owns, so absence is normal and
+ * {@see CepNotFoundException::$faixa} is null for it.
+ */
+final class Faixa
+{
+ /**
+ * Property names are the API's own JSON keys, `cep_ini` and `cep_fim`
+ * included. See {@see Address} for why they are not translated or
+ * camel-cased.
+ *
+ * @param string $cep_ini Smallest CEP that exists in the municipality, not
+ * the start the Correios publish for it.
+ * @param string $cep_fim Largest CEP that exists in the municipality.
+ * @param int $ceps How many CEPs the municipality actually has. Read it
+ * together with the pair above: the interval is sparse, and this is the
+ * only number that says how sparse.
+ */
+ public function __construct(
+ public readonly string $localidade,
+ public readonly string $uf = '',
+ public readonly string $estado = '',
+ public readonly string $regiao = '',
+ public readonly string $ibge = '',
+ public readonly string $cep_ini = '',
+ public readonly string $cep_fim = '',
+ public readonly int $ceps = 0,
+ ) {
+ }
+
+ /**
+ * Build from the decoded `faixa` block, or `null` when it is missing,
+ * empty or malformed.
+ *
+ * Deliberately forgiving. A `faixa` this client cannot read must never cost
+ * the caller the answer it came for: the CEP does not exist either way, and
+ * turning a clean not-found into a parse failure would trade a useful
+ * exception for a useless one. Only {@see Faixa::$localidade} is required —
+ * without a municipality name there is nothing to tell the user — and every
+ * other field falls back the way {@see Address} does, to an empty string or
+ * to zero.
+ */
+ public static function fromArray(mixed $data): ?self
+ {
+ if (!is_array($data) || $data === []) {
+ return null;
+ }
+
+ $localidade = self::asString($data['localidade'] ?? null);
+ if ($localidade === '') {
+ return null;
+ }
+
+ return new self(
+ localidade: $localidade,
+ uf: self::asString($data['uf'] ?? null),
+ estado: self::asString($data['estado'] ?? null),
+ regiao: self::asString($data['regiao'] ?? null),
+ ibge: self::asString($data['ibge'] ?? null),
+ cep_ini: self::asString($data['cep_ini'] ?? null),
+ cep_fim: self::asString($data['cep_fim'] ?? null),
+ ceps: self::asInt($data['ceps'] ?? null),
+ );
+ }
+
+ /**
+ * The municipality as you would show it to a person: `"Areal/RJ"`, or just
+ * `"Areal"` when the API sent no UF.
+ */
+ public function label(): string
+ {
+ return $this->uf === '' ? $this->localidade : $this->localidade . '/' . $this->uf;
+ }
+
+ /**
+ * @return array{
+ * localidade: string, uf: string, estado: string, regiao: string,
+ * ibge: string, cep_ini: string, cep_fim: string, ceps: int
+ * }
+ */
+ public function toArray(): array
+ {
+ return [
+ 'localidade' => $this->localidade,
+ 'uf' => $this->uf,
+ 'estado' => $this->estado,
+ 'regiao' => $this->regiao,
+ 'ibge' => $this->ibge,
+ 'cep_ini' => $this->cep_ini,
+ 'cep_fim' => $this->cep_fim,
+ 'ceps' => $this->ceps,
+ ];
+ }
+
+ private static function asString(mixed $value): string
+ {
+ return is_scalar($value) ? (string) $value : '';
+ }
+
+ private static function asInt(mixed $value): int
+ {
+ return is_numeric($value) ? (int) (float) $value : 0;
+ }
+}
diff --git a/tests/LookupTest.php b/tests/LookupTest.php
index 9483948..03dee57 100644
--- a/tests/LookupTest.php
+++ b/tests/LookupTest.php
@@ -10,6 +10,23 @@ use Cepis\InvalidResponseException;
final class LookupTest extends ServerTestCase
{
+ /**
+ * The `faixa` the API sends on the 404 of a CEP that does not exist but
+ * falls inside a municipality's range. `25845000` is the textbook case: it
+ * is the DNE's own start for Areal, it is the number everyone guesses, and
+ * it was never issued — which is why `cep_ini` is `25845001`.
+ */
+ private const AREAL_FAIXA = [
+ 'localidade' => 'Areal',
+ 'uf' => 'RJ',
+ 'estado' => 'Rio de Janeiro',
+ 'regiao' => 'Sudeste',
+ 'ibge' => '3300225',
+ 'cep_ini' => '25845001',
+ 'cep_fim' => '25849899',
+ 'ceps' => 118,
+ ];
+
public function testSuccessfulLookup(): void
{
$this->server->respond('/01001000', [self::json(self::SE_ADDRESS)]);
@@ -94,6 +111,128 @@ final class LookupTest extends ServerTestCase
self::fail('expected CepNotFoundException');
} catch (CepNotFoundException $exception) {
self::assertStringContainsString('99999999', $exception->getMessage());
+ self::assertNull($exception->faixa);
+ }
+ }
+
+ public function testNotFoundCarriesTheFaixaWhenTheApiSendsOne(): void
+ {
+ $this->server->respond('/25845000', [self::json(
+ [
+ 'erro' => 'cep_nao_encontrado',
+ 'mensagem' => 'CEP não existe na base do DNE',
+ 'faixa' => self::AREAL_FAIXA,
+ ],
+ 404,
+ )]);
+
+ try {
+ $this->client->lookup('25845-000');
+ self::fail('expected CepNotFoundException');
+ } catch (CepNotFoundException $exception) {
+ $faixa = $exception->faixa;
+ self::assertNotNull($faixa);
+ self::assertSame('Areal', $faixa->localidade);
+ self::assertSame('RJ', $faixa->uf);
+ self::assertSame('Rio de Janeiro', $faixa->estado);
+ self::assertSame('Sudeste', $faixa->regiao);
+ self::assertSame('3300225', $faixa->ibge);
+ self::assertSame(118, $faixa->ceps);
+ self::assertSame('Areal/RJ', $faixa->label());
+
+ // The bounds are the CEPs that exist, not the ones the Correios
+ // publish: the CEP that just failed is the DNE's own start for
+ // Areal and is below cep_ini for exactly that reason.
+ self::assertSame('25845001', $faixa->cep_ini);
+ self::assertSame('25849899', $faixa->cep_fim);
+ self::assertTrue($faixa->cep_ini > '25845000');
+
+ // 118 CEPs over ~4,900 numbers: the pair is a range, never a list.
+ self::assertLessThan(
+ (int) $faixa->cep_fim - (int) $faixa->cep_ini,
+ $faixa->ceps,
+ );
+
+ self::assertStringContainsString('Areal/RJ', $exception->getMessage());
+ self::assertStringContainsString('25845000', $exception->getMessage());
+ }
+ }
+
+ public function testFaixaIsStillANotFoundAndNotAnAddress(): void
+ {
+ $this->server->respond('/25845000', [self::json(
+ ['erro' => 'cep_nao_encontrado', 'faixa' => self::AREAL_FAIXA],
+ 404,
+ )]);
+
+ $this->expectException(CepNotFoundException::class);
+ $this->client->lookup('25845000');
+ }
+
+ /**
+ * A `faixa` this client cannot read costs nothing: the answer is the same
+ * not-found it would have been without one. Anything else would trade a
+ * useful exception for an unhelpful parse failure.
+ */
+ public function testUnreadableFaixaStillGivesACleanNotFound(): void
+ {
+ $shapes = [
+ 'null' => null,
+ 'empty object' => [],
+ 'a string' => 'Areal',
+ 'a number' => 42,
+ 'a list' => ['Areal', 'RJ'],
+ 'no municipality' => ['uf' => 'RJ', 'cep_ini' => '25845001'],
+ 'empty municipality' => ['localidade' => '', 'uf' => 'RJ'],
+ 'nested junk' => ['localidade' => ['Areal'], 'ceps' => 'many'],
+ ];
+
+ $specs = [];
+ foreach ($shapes as $faixa) {
+ $specs[] = self::json(['erro' => 'cep_nao_encontrado', 'faixa' => $faixa], 404);
+ }
+ $this->server->respond('/25845000', $specs);
+
+ foreach (array_keys($shapes) as $name) {
+ try {
+ $this->client->lookup('25845000');
+ self::fail('expected CepNotFoundException for ' . $name);
+ } catch (CepNotFoundException $exception) {
+ self::assertNull($exception->faixa, $name);
+ self::assertStringContainsString('25845000', $exception->getMessage(), $name);
+ }
+ }
+ }
+
+ public function testPartialFaixaKeepsWhatItCanRead(): void
+ {
+ $this->server->respond('/25845000', [self::json(
+ ['erro' => 'cep_nao_encontrado', 'faixa' => ['localidade' => 'Areal', 'ceps' => 'many']],
+ 404,
+ )]);
+
+ try {
+ $this->client->lookup('25845000');
+ self::fail('expected CepNotFoundException');
+ } catch (CepNotFoundException $exception) {
+ self::assertNotNull($exception->faixa);
+ self::assertSame('Areal', $exception->faixa->localidade);
+ self::assertSame('', $exception->faixa->uf);
+ self::assertSame('', $exception->faixa->cep_ini);
+ self::assertSame(0, $exception->faixa->ceps);
+ self::assertSame('Areal', $exception->faixa->label());
+ }
+ }
+
+ public function testUnreadableNotFoundBodyStillGivesACleanNotFound(): void
+ {
+ $this->server->respond('/25845000', [self::raw(404, '<html>proxy says no</html>')]);
+
+ try {
+ $this->client->lookup('25845000');
+ self::fail('expected CepNotFoundException');
+ } catch (CepNotFoundException $exception) {
+ self::assertNull($exception->faixa);
}
}