summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 14:40:05 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 14:40:05 -0300
commit0bc578d6d334c7f1ccaf329cd61bb8bce76b7f59 (patch)
tree86f73108f0432fd8330edcaab8b8ae7018322dee /lib
downloadperl-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib')
-rw-r--r--lib/Cepis.pm184
-rw-r--r--lib/Cepis/Address.pm213
-rw-r--r--lib/Cepis/CSV.pm165
-rw-r--r--lib/Cepis/Client.pm652
-rw-r--r--lib/Cepis/Error.pm226
-rw-r--r--lib/Cepis/Geo.pm148
-rw-r--r--lib/Cepis/Health.pm138
7 files changed, 1726 insertions, 0 deletions
diff --git a/lib/Cepis.pm b/lib/Cepis.pm
new file mode 100644
index 0000000..224d80d
--- /dev/null
+++ b/lib/Cepis.pm
@@ -0,0 +1,184 @@
+package Cepis;
+
+use strict;
+use warnings;
+
+use Exporter 5.57 qw(import);
+
+use Cepis::Address;
+use Cepis::Client;
+use Cepis::Error;
+use Cepis::Geo;
+use Cepis::Health;
+
+our $VERSION = '0.1.0';
+
+our @EXPORT_OK = qw(lookup lookup_many health normalize_cep);
+
+my $DEFAULT_CLIENT;
+
+sub default_client {
+ $DEFAULT_CLIENT = Cepis::Client->new unless defined $DEFAULT_CLIENT;
+ return $DEFAULT_CLIENT;
+}
+
+sub lookup {
+ return default_client()->lookup(@_);
+}
+
+sub lookup_many {
+ return default_client()->lookup_many(@_);
+}
+
+sub health {
+ return default_client()->health;
+}
+
+sub normalize_cep {
+ return Cepis::Client::normalize_cep(@_);
+}
+
+1;
+
+__END__
+
+=encoding UTF-8
+
+=head1 NAME
+
+Cepis - Perl client for the public CEP API at cep.is
+
+=head1 SYNOPSIS
+
+ use Cepis;
+
+ my $address = Cepis::lookup('01001-000');
+ print $address->localidade, ' ', $address->uf, "\n"; # São Paulo SP
+
+ my $result = Cepis::lookup_many( [ '01001000', '99999999' ] );
+ print defined $result->[1] ? 'found' : 'not found', "\n";
+
+ my $health = Cepis::health();
+ print $health->dne, "\n";
+
+=head1 DESCRIPTION
+
+Looks Brazilian postal codes up against L<https://cep.is>: official Correios
+data (DNE), refreshed every two weeks. Single lookups carry coordinates when the
+database has them; batches resolve up to 500 CEPs in one request.
+
+B<No non-core dependencies.> L<HTTP::Tiny>, L<JSON::PP> and L<Encode> all ship
+with Perl, and the CSV reader is part of this distribution. Reaching
+C<https://cep.is> does need L<IO::Socket::SSL> and L<Net::SSLeay>, which do not
+ship with Perl — see L</"HTTPS support">.
+
+For a script, the module-level functions are enough. In an application, build a
+L<Cepis::Client> once and reuse it:
+
+ my $client = Cepis::Client->new( timeout => 5.0, total_timeout => 15.0 );
+ my $address = $client->lookup('01001000');
+
+Pointing at a local instance (the API repo brings one up with C<just up>):
+
+ my $client = Cepis::Client->new( base_url => 'http://localhost:8080' );
+
+=head2 Errors
+
+Every failure is thrown as an object under L<Cepis::Error>, so one test catches
+the lot, and the split below it is by whether retrying helps:
+
+ my $address = eval { Cepis::lookup($cep_from_form) };
+ if ( !$address ) {
+ my $err = $@;
+ if ( ref $err && $err->isa('Cepis::Error::CEPNotFound') ) {
+ # the CEP does not exist: ask the user for another
+ }
+ elsif ( ref $err && $err->isa('Cepis::Error') ) {
+ # network, server, rate limit: retry later
+ }
+ else {
+ die $err;
+ }
+ }
+
+=head2 Field names
+
+The API surface is English, but the B<data fields keep the API's own Portuguese
+names> — C<logradouro>, C<bairro>, C<localidade>. What you read in the JSON is
+what you read on the object. See L<Cepis::Address>.
+
+=head2 HTTPS support
+
+L<HTTP::Tiny> speaks HTTPS only when L<IO::Socket::SSL> 1.968 or later and
+L<Net::SSLeay> 1.49 or later are installed, and neither ships with Perl. Without
+them, any call against the default C<https://cep.is> throws
+L<Cepis::Error::Network> straight away with a message naming both modules.
+C<Cepis::Client::tls_available()> reports the same thing without making a
+request. See L<Cepis::Client/"HTTPS support">.
+
+=head1 FUNCTIONS
+
+The module-level functions all go through one shared default client, built on
+first use. It applies process-wide, including to third-party libraries that also
+use this module, so do not reconfigure it — anyone needing different settings
+should build their own L<Cepis::Client>.
+
+Nothing is exported by default. C<lookup>, C<lookup_many>, C<health> and
+C<normalize_cep> are available on request:
+
+ use Cepis qw(lookup lookup_many);
+
+=head2 lookup
+
+ my $address = Cepis::lookup('01001-000');
+
+Looks up a CEP using the default configuration and returns a
+L<Cepis::Address>. Shorthand for C<< Cepis::default_client->lookup($cep) >>; see
+L<Cepis::Client/lookup> for the errors it throws.
+
+=head2 lookup_many
+
+ my $result = Cepis::lookup_many( \@ceps );
+
+Looks many CEPs up in one batch, using the default configuration, and returns an
+arrayref of the same length and order with C<undef> for the misses. Shorthand
+for L<Cepis::Client/lookup_many> — read that one, especially the warning that
+batches carry no coordinate.
+
+=head2 health
+
+ my $health = Cepis::health();
+
+Queries the API's health using the default configuration and returns a
+L<Cepis::Health>. Shorthand for L<Cepis::Client/health>.
+
+=head2 normalize_cep
+
+ my $number = Cepis::normalize_cep('01001-000'); # "01001000"
+
+Strips the mask and validates that 8 digits remain, throwing
+L<Cepis::Error::InvalidCEP> otherwise. Makes no request. See
+L<Cepis::Client/normalize_cep>.
+
+=head2 default_client
+
+ my $client = Cepis::default_client();
+
+The L<Cepis::Client> behind the functions above, built on first use.
+
+=head1 SEE ALSO
+
+L<Cepis::Client>, L<Cepis::Address>, L<Cepis::Geo>, L<Cepis::Health>,
+L<Cepis::Error>
+
+The API itself is documented at L<https://cep.is>.
+
+=head1 AUTHOR
+
+Murilo Ijanc E<lt>[email protected]<gt>
+
+=head1 LICENCE
+
+ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>.
+
+=cut
diff --git a/lib/Cepis/Address.pm b/lib/Cepis/Address.pm
new file mode 100644
index 0000000..a938d6f
--- /dev/null
+++ b/lib/Cepis/Address.pm
@@ -0,0 +1,213 @@
+package Cepis::Address;
+
+use strict;
+use warnings;
+
+use Cepis::Geo;
+
+our $VERSION = '0.1.0';
+
+our @FIELDS = qw(
+ cep logradouro complemento unidade bairro localidade
+ uf estado regiao ibge gia ddd siafi
+);
+
+{
+ no strict 'refs';
+ for my $name (@FIELDS) {
+ *{$name} = sub { return $_[0]->{$name} };
+ }
+}
+
+sub from_json {
+ my ( $class, $data ) = @_;
+
+ my %raw = %{ $data || {} };
+ my %self;
+ for my $name (@FIELDS) {
+ $self{$name} = _string( $raw{$name} );
+ }
+ $self{geo} = Cepis::Geo->from_json( $raw{geo} );
+ $self{raw} = \%raw;
+
+ return bless \%self, $class;
+}
+
+sub geo { return $_[0]->{geo} }
+sub raw { return $_[0]->{raw} }
+
+sub field {
+ my ( $self, $name ) = @_;
+ return $self->{raw}{$name};
+}
+
+sub formatted_cep {
+ my ($self) = @_;
+ my $cep = $self->{cep};
+ return $cep unless length($cep) == 8;
+ return substr( $cep, 0, 5 ) . '-' . substr( $cep, 5, 3 );
+}
+
+sub to_hash {
+ my ($self) = @_;
+ my %out = map { $_ => $self->{$_} } @FIELDS;
+ $out{geo} = $self->{geo} ? $self->{geo}->to_hash : undef;
+ return \%out;
+}
+
+sub _string {
+ my ($value) = @_;
+ return '' unless defined $value;
+ return '' if ref $value;
+ return "$value";
+}
+
+1;
+
+__END__
+
+=encoding UTF-8
+
+=head1 NAME
+
+Cepis::Address - a CEP and everything the database knows about it
+
+=head1 SYNOPSIS
+
+ my $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"
+
+=head1 DESCRIPTION
+
+B<Field names are the API's own, in Portuguese> — C<logradouro>, C<bairro>,
+C<localidade>. Everything else in this library is English; the fields are the
+one deliberate exception.
+
+The reason is fidelity: what you read in the JSON at
+L<https://cep.is/01001000> is exactly what you read on the object. Translating
+to C<street>/C<city> would mean maintaining a field-by-field mapping and forcing
+anyone reading the API docs alongside this library to translate back in their
+head. The values are Portuguese either way — they come from the Correios
+database.
+
+Fields the database does not have for that CEP come back as empty strings, never
+C<undef>. That is what the API returns, and changing it here would create a
+difference between reading the JSON and reading this library.
+
+L</geo> is the exception: it is C<undef> when there is no coordinate. There the
+difference is real, because the API B<omits> the whole field in that case (about
+43% of CEPs) rather than sending null.
+
+=head1 CONSTRUCTOR
+
+=head2 from_json
+
+ my $address = Cepis::Address->from_json( $hashref );
+
+Builds from the decoded JSON object the API returned.
+
+Unknown fields are ignored during construction but preserved in L</raw>. A new
+field in the API therefore cannot break this library.
+
+A malformed C<geo> block yields C<< geo => undef >> rather than propagating the
+error.
+
+=head1 ACCESSORS
+
+=head2 cep
+
+The 8 digits, unmasked.
+
+=head2 logradouro
+
+Street name.
+
+=head2 complemento
+
+Complement, such as C<"lado ímpar">.
+
+=head2 unidade
+
+Unit, when the CEP belongs to a specific building or post office box range.
+
+=head2 bairro
+
+Neighbourhood.
+
+=head2 localidade
+
+Municipality name.
+
+=head2 uf
+
+Two-letter state code.
+
+=head2 estado
+
+Full state name.
+
+=head2 regiao
+
+Region of the country, such as C<"Sudeste">.
+
+=head2 ibge
+
+IBGE municipality code.
+
+=head2 gia
+
+GIA code (São Paulo state tax registry).
+
+=head2 ddd
+
+Area code for telephone dialling.
+
+=head2 siafi
+
+SIAFI municipality code.
+
+=head2 geo
+
+A L<Cepis::Geo> object, or C<undef> when the database has no coordinate for
+this CEP. Addresses coming out of C<lookup_many> always have C<undef> here — see
+L<Cepis::Client/lookup_many>.
+
+=head2 raw
+
+The decoded response exactly as it arrived, as a hashref, so a field the API
+grows before this library is updated is not lost.
+
+=head2 field
+
+ $address->field('campo_do_futuro');
+
+Reads any field of the raw response by name, including one this release does not
+know about. Returns C<undef> when the field is absent.
+
+=head2 formatted_cep
+
+The CEP with its mask: C<01001-000>.
+
+The API always returns it unmasked; formatting is presentation, and it lives
+here so it does not become a repeated C<substr> in every project that uses this.
+
+=head2 to_hash
+
+Every named field plus C<geo> as a plain hashref, with C<geo> itself flattened
+to a hashref or C<undef>.
+
+=head1 SEE ALSO
+
+L<Cepis>, L<Cepis::Geo>
+
+=head1 LICENCE
+
+ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>.
+
+=cut
diff --git a/lib/Cepis/CSV.pm b/lib/Cepis/CSV.pm
new file mode 100644
index 0000000..6fabc0c
--- /dev/null
+++ b/lib/Cepis/CSV.pm
@@ -0,0 +1,165 @@
+package Cepis::CSV;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.1.0';
+
+sub parse_rows {
+ my ($text) = @_;
+
+ return [] unless defined $text && length $text;
+ $text =~ s/\A\x{FEFF}//;
+
+ my @rows;
+ my @row;
+ my $field = '';
+ my $quoted = 0;
+ my $started = 0;
+ my $length = length $text;
+ my $i = 0;
+
+ while ( $i < $length ) {
+ my $char = substr( $text, $i, 1 );
+
+ if ($quoted) {
+ if ( $char eq '"' ) {
+ if ( $i + 1 < $length && substr( $text, $i + 1, 1 ) eq '"' ) {
+ $field .= '"';
+ $i += 2;
+ next;
+ }
+ $quoted = 0;
+ $i++;
+ next;
+ }
+ $field .= $char;
+ $i++;
+ next;
+ }
+
+ if ( $char eq '"' && $field eq '' ) {
+ $quoted = 1;
+ $started = 1;
+ $i++;
+ next;
+ }
+
+ if ( $char eq ',' ) {
+ push @row, $field;
+ $field = '';
+ $started = 1;
+ $i++;
+ next;
+ }
+
+ if ( $char eq "\r" || $char eq "\n" ) {
+ $i++;
+ $i++
+ if $char eq "\r"
+ && $i < $length
+ && substr( $text, $i, 1 ) eq "\n";
+ if ( $started || @row ) {
+ push @row, $field;
+ push @rows, [@row];
+ }
+ @row = ();
+ $field = '';
+ $started = 0;
+ next;
+ }
+
+ $field .= $char;
+ $started = 1;
+ $i++;
+ }
+
+ if ( $started || @row ) {
+ push @row, $field;
+ push @rows, [@row];
+ }
+
+ return \@rows;
+}
+
+sub parse_hashes {
+ my ($text) = @_;
+
+ my $rows = parse_rows($text);
+ return ( [], [] ) unless @$rows;
+
+ my $header = shift @$rows;
+ my @out;
+ for my $row (@$rows) {
+ my %record;
+ for my $index ( 0 .. $#$header ) {
+ my $value = $index <= $#$row ? $row->[$index] : '';
+ $record{ $header->[$index] } = defined $value ? $value : '';
+ }
+ push @out, \%record;
+ }
+
+ return ( $header, \@out );
+}
+
+1;
+
+__END__
+
+=encoding UTF-8
+
+=head1 NAME
+
+Cepis::CSV - a small RFC 4180 reader
+
+=head1 SYNOPSIS
+
+ use Cepis::CSV;
+
+ my ( $header, $records ) = Cepis::CSV::parse_hashes($text);
+ print $records->[0]{localidade};
+
+=head1 DESCRIPTION
+
+Perl ships no CSV parser in its core, and this distribution has no non-core
+dependencies, so it carries its own. It handles what RFC 4180 requires and what
+the C<GET /csv> endpoint actually emits: quoted fields, embedded commas, embedded
+newlines, C<""> as an escaped quote, C<CRLF> or C<LF> line endings, and a leading
+byte order mark.
+
+The input is a character string, not bytes — decode the response body before
+calling in.
+
+This module is internal to L<Cepis>. It is documented because it is tested
+separately, not because it is meant to be a general-purpose CSV library: it has
+no writer, no configurable separator and no streaming interface.
+
+=head1 FUNCTIONS
+
+=head2 parse_rows
+
+ my $rows = Cepis::CSV::parse_rows($text);
+
+Returns an arrayref of arrayrefs, one per record, fields in order. Blank lines
+are skipped, so a trailing newline does not produce a phantom final record.
+
+=head2 parse_hashes
+
+ my ( $header, $records ) = Cepis::CSV::parse_hashes($text);
+
+Treats the first record as a header row and returns it as an arrayref, plus an
+arrayref of hashrefs keyed by column name. A record with fewer fields than the
+header gets empty strings for the ones it lacks; fields beyond the header are
+dropped.
+
+Returns two empty arrayrefs for empty input.
+
+=head1 SEE ALSO
+
+L<Cepis::Client>
+
+=head1 LICENCE
+
+ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>.
+
+=cut
diff --git a/lib/Cepis/Client.pm b/lib/Cepis/Client.pm
new file mode 100644
index 0000000..35eb51b
--- /dev/null
+++ b/lib/Cepis/Client.pm
@@ -0,0 +1,652 @@
+package Cepis::Client;
+
+use strict;
+use warnings;
+
+use Carp ();
+use Encode ();
+use HTTP::Tiny;
+use JSON::PP ();
+use Scalar::Util ();
+use Time::HiRes ();
+
+use Cepis::Address;
+use Cepis::CSV;
+use Cepis::Error;
+use Cepis::Health;
+
+our $VERSION = '0.1.0';
+
+use constant DEFAULT_URL => 'https://cep.is';
+use constant MAX_BATCH => 500;
+
+my %RETRIABLE_STATUS = map { $_ => 1 } ( 429, 500, 502, 503, 504 );
+
+my $JSON = JSON::PP->new->utf8;
+
+my @MONTHS = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);
+my %MONTH = map { $MONTHS[$_] => $_ + 1 } 0 .. $#MONTHS;
+
+sub normalize_cep {
+ my ($value) = @_;
+
+ if ( !defined $value ) {
+ Cepis::Error::InvalidCEP->throw(
+ message => 'CEP must be a string, got undef' );
+ }
+ if ( ref $value ) {
+ Cepis::Error::InvalidCEP->throw(
+ message => 'CEP must be a string, got a ' . ref($value) . ' reference' );
+ }
+
+ my $cleaned = "$value";
+ $cleaned =~ s/\A\s+//;
+ $cleaned =~ s/\s+\z//;
+ $cleaned =~ s/[-. ]//g;
+
+ if ( $cleaned !~ /\A[0-9]{8}\z/ ) {
+ Cepis::Error::InvalidCEP->throw(
+ message => "CEP must be 8 digits, got '$value'" );
+ }
+
+ return $cleaned;
+}
+
+sub tls_available {
+ return ( 1, undef ) unless HTTP::Tiny->can('can_ssl');
+ my ( $ok, $why ) = HTTP::Tiny->can_ssl;
+ return ( 1, undef ) if $ok;
+ return ( 0, $why );
+}
+
+sub new {
+ my ( $class, %args ) = @_;
+
+ my $base_url = _default( $args{base_url}, DEFAULT_URL );
+ my $timeout = _default( $args{timeout}, 10.0 );
+ my $max_attempts = _default( $args{max_attempts}, 3 );
+ my $backoff_base = _default( $args{backoff_base}, 0.5 );
+ my $backoff_max = _default( $args{backoff_max}, 8.0 );
+
+ Carp::croak('max_attempts must be >= 1')
+ unless Scalar::Util::looks_like_number($max_attempts) && $max_attempts >= 1;
+ Carp::croak('timeout must be > 0')
+ unless Scalar::Util::looks_like_number($timeout) && $timeout > 0;
+ Carp::croak('backoff_base must be >= 0')
+ unless Scalar::Util::looks_like_number($backoff_base) && $backoff_base >= 0;
+ Carp::croak('backoff_max must be >= 0')
+ unless Scalar::Util::looks_like_number($backoff_max) && $backoff_max >= 0;
+
+ $base_url = "$base_url";
+ $base_url =~ s{/+\z}{};
+
+ my $user_agent = _default( $args{user_agent},
+ "cepis-perl/$VERSION (+https://cep.is)" );
+
+ my $self = {
+ base_url => $base_url,
+ timeout => 0 + $timeout,
+ max_attempts => int( 0 + $max_attempts ),
+ backoff_base => 0 + $backoff_base,
+ backoff_max => 0 + $backoff_max,
+ total_timeout => $args{total_timeout},
+ user_agent => "$user_agent",
+ };
+
+ $self->{http} = HTTP::Tiny->new(
+ agent => $self->{user_agent},
+ timeout => $self->{timeout},
+ default_headers => {
+ 'Accept' => 'application/json, text/csv;q=0.9, */*;q=0.1',
+ 'Accept-Encoding' => 'identity',
+ },
+ );
+
+ return bless $self, $class;
+}
+
+sub base_url { return $_[0]->{base_url} }
+sub timeout { return $_[0]->{timeout} }
+sub max_attempts { return $_[0]->{max_attempts} }
+sub backoff_base { return $_[0]->{backoff_base} }
+sub backoff_max { return $_[0]->{backoff_max} }
+sub total_timeout { return $_[0]->{total_timeout} }
+sub user_agent { return $_[0]->{user_agent} }
+
+sub lookup {
+ my ( $self, $cep ) = @_;
+
+ my $number = normalize_cep($cep);
+ my ( $status, $body ) = $self->_request("/$number");
+
+ if ( $status == 404 ) {
+ Cepis::Error::CEPNotFound->throw(
+ message => "CEP $number does not exist in the DNE database" );
+ }
+ if ( $status == 400 ) {
+ Cepis::Error::InvalidCEP->throw(
+ message => _error_message( $body, "CEP $number rejected" ) );
+ }
+
+ return Cepis::Address->from_json( _json($body) );
+}
+
+sub lookup_many {
+ my ( $self, $ceps ) = @_;
+
+ Carp::croak('lookup_many expects an array reference of CEPs')
+ unless ref $ceps eq 'ARRAY';
+ return [] unless @$ceps;
+
+ my @out;
+ for ( my $start = 0; $start < @$ceps; $start += MAX_BATCH ) {
+ my $end = $start + MAX_BATCH - 1;
+ $end = $#$ceps if $end > $#$ceps;
+ push @out, @{ $self->_lookup_chunk( [ @{$ceps}[ $start .. $end ] ] ) };
+ }
+
+ return \@out;
+}
+
+sub health {
+ my ($self) = @_;
+ my ( undef, $body ) = $self->_request( '/health', accepted_status => [ 200, 503 ] );
+ return Cepis::Health->from_json( _json($body) );
+}
+
+sub _lookup_chunk {
+ my ( $self, $ceps ) = @_;
+
+ my $joined = join ',', map { defined $_ ? "$_" : '' } @$ceps;
+ my ( undef, $body ) = $self->_request( '/csv?ceps=' . _escape($joined) );
+
+ my ( $header, $records ) = Cepis::CSV::parse_hashes( _decode($body) );
+ unless ( grep { $_ eq 'cep' } @$header ) {
+ Cepis::Error::InvalidResponse->throw( message =>
+ 'response is not the expected CSV — something between you and the '
+ . 'API is answering instead of it: ' . _snippet($body) );
+ }
+
+ my @out;
+ for my $record (@$records) {
+ my $localidade = defined $record->{localidade} ? $record->{localidade} : '';
+ $localidade =~ s/\A\s+//;
+ $localidade =~ s/\s+\z//;
+ if ( $localidade eq '' ) {
+ push @out, undef;
+ next;
+ }
+ push @out, Cepis::Address->from_json($record);
+ }
+
+ push @out, undef while @out < @$ceps;
+ splice @out, scalar @$ceps if @out > @$ceps;
+
+ return \@out;
+}
+
+sub _request {
+ my ( $self, $path, %options ) = @_;
+
+ my @accepted = @{ $options{accepted_status} || [200] };
+ my %accepted = map { $_ => 1 } @accepted;
+
+ my $url = $self->{base_url} . $path;
+ _assert_tls($url);
+
+ my $deadline
+ = defined $self->{total_timeout}
+ ? _monotonic() + $self->{total_timeout}
+ : undef;
+
+ my $attempts = $self->{max_attempts};
+ my ( $last_status, $last_retry_after, $last_network );
+
+ for my $attempt ( 1 .. $attempts ) {
+ last if defined $deadline && _monotonic() >= $deadline;
+
+ my $response = $self->{http}->get($url);
+
+ if ( $response->{status} == 599 ) {
+ $last_network = $response->{content};
+ $last_network = 'unknown transport failure'
+ unless defined $last_network && length $last_network;
+ $last_network =~ s/\s+\z//;
+ $last_status = undef;
+ $last_retry_after = undef;
+ }
+ else {
+ my $status = $response->{status};
+ if ( $accepted{$status} || !$RETRIABLE_STATUS{$status} ) {
+ return ( $status, $response->{content} );
+ }
+ $last_status = $status;
+ $last_retry_after = _retry_after_seconds( $response->{headers}{'retry-after'} );
+ $last_network = undef;
+ }
+
+ last if $attempt == $attempts;
+
+ my $wait = $self->_backoff( $attempt, $last_retry_after );
+ if ( defined $deadline ) {
+ my $remaining = $deadline - _monotonic();
+ last if $remaining <= 0;
+ $wait = $remaining if $wait > $remaining;
+ }
+ Time::HiRes::sleep($wait) if $wait > 0;
+ }
+
+ my $suffix = "(after $attempts attempt(s))";
+
+ if ( defined $last_status && $last_status == 429 ) {
+ Cepis::Error::RateLimited->throw(
+ message => "rate limit reached at $url $suffix",
+ retry_after => $last_retry_after,
+ );
+ }
+ if ( defined $last_status ) {
+ Cepis::Error::ServerError->throw(
+ message => "the API answered $last_status at $url $suffix",
+ status => $last_status,
+ );
+ }
+ Cepis::Error::Network->throw(
+ message => "could not reach $url $suffix: "
+ . ( defined $last_network ? $last_network : 'no attempt was made' ) );
+}
+
+sub _assert_tls {
+ my ($url) = @_;
+
+ return unless $url =~ m{\Ahttps://}i;
+
+ my ( $ok, $why ) = tls_available();
+ return if $ok;
+
+ $why = '' unless defined $why;
+ $why =~ s/\s+/ /g;
+ $why =~ s/\A\s+//;
+ $why =~ s/\s+\z//;
+
+ Cepis::Error::Network->throw( message =>
+ "cannot reach $url: HTTP::Tiny has no HTTPS support on this Perl. "
+ . 'It needs IO::Socket::SSL 1.968 or later and Net::SSLeay 1.49 or '
+ . 'later, and neither of those ships with Perl. Install both '
+ . '(cpanm IO::Socket::SSL Net::SSLeay, or your distribution\'s '
+ . 'perl-IO-Socket-SSL and perl-Net-SSLeay packages), or point '
+ . 'base_url at an http:// instance. HTTP::Tiny reports: '
+ . ( length $why ? $why : 'no reason given' ) );
+}
+
+sub _backoff {
+ my ( $self, $attempt, $retry_after ) = @_;
+
+ if ( defined $retry_after ) {
+ return $retry_after < $self->{backoff_max}
+ ? $retry_after
+ : $self->{backoff_max};
+ }
+
+ my $ceiling = $self->{backoff_base} * ( 2**( $attempt - 1 ) );
+ $ceiling = $self->{backoff_max} if $ceiling > $self->{backoff_max};
+ return rand() * $ceiling;
+}
+
+sub _retry_after_seconds {
+ my ($value) = @_;
+
+ $value = $value->[0] if ref $value eq 'ARRAY';
+ return undef unless defined $value;
+
+ my $text = "$value";
+ $text =~ s/\A\s+//;
+ $text =~ s/\s+\z//;
+ return undef unless length $text;
+
+ if ( $text =~ /\A[0-9]+(?:\.[0-9]+)?\z/ ) {
+ return 0 + $text;
+ }
+
+ my $epoch = _http_date_epoch($text);
+ return undef unless defined $epoch;
+
+ my $seconds = $epoch - time();
+ return $seconds > 0 ? $seconds : 0;
+}
+
+sub _http_date_epoch {
+ my ($text) = @_;
+
+ my ( $day, $month, $year, $hour, $minute, $second );
+
+ if ( $text
+ =~ /\A[A-Za-z]+,\s+(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT\z/ )
+ {
+ ( $day, $month, $year, $hour, $minute, $second ) = ( $1, $2, $3, $4, $5, $6 );
+ }
+ elsif ( $text
+ =~ /\A[A-Za-z]+,\s+(\d{2})-([A-Za-z]{3})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+GMT\z/ )
+ {
+ ( $day, $month, $year, $hour, $minute, $second ) = ( $1, $2, $3, $4, $5, $6 );
+ $year += $year < 70 ? 2000 : 1900;
+ }
+ elsif ( $text
+ =~ /\A[A-Za-z]+\s+([A-Za-z]{3})\s+([\d ]\d)\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\z/ )
+ {
+ ( $month, $day, $hour, $minute, $second, $year ) = ( $1, $2, $3, $4, $5, $6 );
+ $day =~ s/\s//g;
+ }
+ else {
+ return undef;
+ }
+
+ my $number = $MONTH{ ucfirst lc $month };
+ return undef unless defined $number;
+
+ return _days_from_civil( $year + 0, $number, $day + 0 ) * 86400
+ + $hour * 3600
+ + $minute * 60
+ + $second;
+}
+
+sub _days_from_civil {
+ my ( $year, $month, $day ) = @_;
+
+ $year -= 1 if $month <= 2;
+ my $era = int( ( $year >= 0 ? $year : $year - 399 ) / 400 );
+ my $yoe = $year - $era * 400;
+ my $doy = int( ( 153 * ( $month + ( $month > 2 ? -3 : 9 ) ) + 2 ) / 5 ) + $day - 1;
+ my $doe = $yoe * 365 + int( $yoe / 4 ) - int( $yoe / 100 ) + $doy;
+
+ return $era * 146097 + $doe - 719468;
+}
+
+sub _monotonic {
+ if ( defined &Time::HiRes::clock_gettime && defined &Time::HiRes::CLOCK_MONOTONIC ) {
+ return Time::HiRes::clock_gettime( Time::HiRes::CLOCK_MONOTONIC() );
+ }
+ return Time::HiRes::time();
+}
+
+sub _json {
+ my ($body) = @_;
+
+ my $data = eval { $JSON->decode( defined $body ? $body : '' ) };
+ if ( !defined $data ) {
+ Cepis::Error::InvalidResponse->throw( message =>
+ 'response is not valid JSON — something between you and the API '
+ . 'is answering instead of it: ' . _snippet($body) );
+ }
+ if ( ref $data ne 'HASH' ) {
+ Cepis::Error::InvalidResponse->throw(
+ message => 'expected a JSON object, got ' . ( ref($data) || 'a scalar' ) );
+ }
+
+ return $data;
+}
+
+sub _error_message {
+ my ( $body, $fallback ) = @_;
+
+ my $data = eval { $JSON->decode( defined $body ? $body : '' ) };
+ return $fallback unless ref $data eq 'HASH';
+ return $fallback unless defined $data->{mensagem} && !ref $data->{mensagem};
+ return "$data->{mensagem}";
+}
+
+sub _decode {
+ my ($body) = @_;
+ return '' unless defined $body;
+ my $text = Encode::decode( 'UTF-8', $body, Encode::FB_DEFAULT() );
+ $text =~ s/\A\x{FEFF}//;
+ return $text;
+}
+
+sub _snippet {
+ my ($body) = @_;
+ my $text = defined $body ? substr( $body, 0, 120 ) : '';
+ $text =~ s/([^\x20-\x7E])/sprintf('\\x%02X', ord $1)/ge;
+ return "'$text'";
+}
+
+sub _escape {
+ my ($value) = @_;
+ my $bytes = Encode::encode( 'UTF-8', "$value" );
+ $bytes =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord $1)/ge;
+ return $bytes;
+}
+
+sub _default {
+ my ( $value, $fallback ) = @_;
+ return defined $value ? $value : $fallback;
+}
+
+1;
+
+__END__
+
+=encoding UTF-8
+
+=head1 NAME
+
+Cepis::Client - configurable client for the cep.is API
+
+=head1 SYNOPSIS
+
+ use Cepis::Client;
+
+ my $client = Cepis::Client->new(
+ base_url => 'https://cep.is',
+ timeout => 10.0,
+ max_attempts => 3,
+ backoff_base => 0.5,
+ backoff_max => 8.0,
+ total_timeout => 15.0,
+ );
+
+ my $address = $client->lookup('01001-000');
+ my $result = $client->lookup_many( [ '01001000', '99999999' ] );
+ my $health = $client->health;
+
+=head1 DESCRIPTION
+
+Build one instance and reuse it. The configuration is fixed at construction and
+no method keeps state between calls, so a single client can serve a whole
+process. It holds one L<HTTP::Tiny> internally, which keeps the connection alive
+between calls; that object is not shared between threads, so give each thread its
+own client.
+
+Retries cover 429, 500, 502, 503, 504 and network failures, with exponential
+backoff and full jitter. 400 and 404 are never retried: both are definitive
+answers about the request, so retrying returns the same thing more slowly while
+burning someone's quota.
+
+=head2 HTTPS support
+
+L<HTTP::Tiny> is a core module and speaks plain HTTP out of the box, but it
+speaks B<HTTPS only when L<IO::Socket::SSL> 1.968 or later and L<Net::SSLeay>
+1.49 or later are installed>, and neither of those ships with Perl. This library
+therefore has no non-core dependencies, but the default C<base_url> of
+C<https://cep.is> needs those two modules present.
+
+When they are missing, a request to an C<https:> URL throws
+L<Cepis::Error::Network> immediately, before any attempt is made, with a message
+naming both modules. No amount of retrying fixes a missing module, so it does
+not retry.
+
+L</tls_available> reports the same thing without making a request.
+
+=head1 FUNCTIONS
+
+=head2 normalize_cep
+
+ my $number = Cepis::Client::normalize_cep('01001-000'); # "01001000"
+
+Returns the CEP's 8 digits, accepting the mask. C<"01001-000">, C<"01001000">,
+C<" 01001-000 "> and C<"01001.000"> all become C<"01001000">.
+
+Throws L<Cepis::Error::InvalidCEP> unless exactly 8 digits remain. This runs
+B<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.
+
+Note that Perl does not distinguish a number from a string, so passing the
+literal C<01001000> passes a 7-digit number — the leading zero is gone before
+this function ever sees it. Keep CEPs in strings.
+
+Also reachable as C<Cepis::normalize_cep>.
+
+=head2 tls_available
+
+ my ( $ok, $why ) = Cepis::Client::tls_available();
+
+Reports whether L<HTTP::Tiny> on this Perl can do HTTPS. Returns a true value
+and C<undef> when it can; false and the reason when it cannot. See
+L</"HTTPS support">.
+
+=head1 CONSTRUCTOR
+
+=head2 new
+
+ my $client = Cepis::Client->new(%options);
+
+All options have defaults, so C<< Cepis::Client->new >> is valid. C<croak>s on a
+value that cannot work, which is a programming error rather than a
+L<Cepis::Error>.
+
+=over 4
+
+=item base_url
+
+Where the API lives. Defaults to C<https://cep.is>. Point it at a local instance
+(the API repo brings one up on C<http://localhost:8080> with C<just up>) or at a
+test server. It is the only option that usually changes between environments.
+Trailing slashes are stripped, so it cannot produce doubled separators that some
+proxies normalise and others 404.
+
+=item timeout
+
+Seconds to wait B<per socket operation> — L<HTTP::Tiny> applies it to the
+connect and to each read, so it is not a cap on the whole call. That cap is
+L</total_timeout>. Defaults to 10.
+
+=item max_attempts
+
+How many attempts in total, counting the first. C<1> disables retrying. The
+default of C<3> covers the common case (one transient failure) without turning a
+real outage into a long wait.
+
+=item backoff_base
+
+Base of the exponential wait, in seconds; defaults to 0.5. The actual wait is
+drawn from C<[0, base * 2**(n-1)]> — I<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.
+
+=item backoff_max
+
+Cap on each individual wait, in seconds; defaults to 8. It applies to a
+server-sent C<Retry-After> too, or a C<Retry-After: 3600> from a misconfigured
+proxy would park the process for an hour.
+
+=item total_timeout
+
+Cap on the whole call, attempts and waits included. C<undef> (the default)
+leaves L</max_attempts> as the only bound. Set it in code serving a web request:
+without it the worst case is C<max_attempts * timeout> plus the sum of the
+backoffs.
+
+=item user_agent
+
+Overrides the C<User-Agent>. The default identifies the library and its version,
+which is what lets the server diagnose an old client misbehaving.
+
+=back
+
+=head1 METHODS
+
+=head2 lookup
+
+ my $address = $client->lookup('01001-000');
+
+Looks up a single CEP and returns a L<Cepis::Address>. Accepts the CEP masked or
+unmasked.
+
+A 400 from the API is surfaced as L<Cepis::Error::InvalidCEP> carrying the API's
+own message: the format already passed local validation, so the server knows
+something this client does not.
+
+Throws L<Cepis::Error::InvalidCEP> when the CEP is not 8 digits (no request is
+made), L<Cepis::Error::CEPNotFound> when it is well-formed but absent from the
+database, L<Cepis::Error::RateLimited> on 429 after every attempt,
+L<Cepis::Error::ServerError> on 5xx after every attempt,
+L<Cepis::Error::Network> when the request never completed, and
+L<Cepis::Error::InvalidResponse> when a success status carries an unreadable
+body.
+
+=head2 lookup_many
+
+ my $result = $client->lookup_many( \@ceps );
+
+Looks up many CEPs in one request each 500 and returns an B<arrayref of the same
+length and in the same order as the input>. A CEP that does not exist is
+C<undef> at its position — it does not vanish from the list, and it does not
+throw. That is the right difference from L</lookup>: in a batch of a thousand,
+one bad CEP must not cost the other nine hundred and ninety-nine.
+
+A malformed CEP is also C<undef>, for the same reason, rather than throwing. To
+know I<which> one was malformed, validate first with L</normalize_cep>.
+
+The result is an arrayref rather than a list precisely because it may contain
+C<undef>. A Perl array holds C<undef> at any position without shortening — no
+element is lost and nothing shifts — so C<< $result->[$i] >> always corresponds
+to C<< $ceps[$i] >>. Reading the result with C<grep { defined }> discards the
+misses; reading it with C<for my $i ( 0 .. $#$result )> keeps the pairing.
+
+Input longer than 500 is chunked automatically. Do not loop over L</lookup>: a
+loop of 500 calls pays 500 TLS handshakes, the batch pays one.
+
+B<The batch carries no coordinate.> The endpoint behind it is CSV, and the CSV
+has no C<geo> column. Every L<Cepis::Address> returned here has C<geo> set to
+C<undef>, including for CEPs that do have one. Need C<geo>? Use L</lookup>, one
+at a time.
+
+The API guarantees one row per requested CEP. Should that ever fail to hold — a
+proxy truncating, a cut response — the result is padded with C<undef> to the
+input length rather than returned short, because a shifted list silently pairs
+the wrong address with the wrong CEP.
+
+=head2 health
+
+ my $health = $client->health;
+
+Queries C<GET /health> and returns a L<Cepis::Health>: does the API answer, and
+with which database.
+
+Does not throw for an API reporting itself degraded — that arrives in
+C<< $health->status >>, and checking health is exactly when you want the
+diagnosis rather than an exception. Network failures still throw.
+
+=head1 ACCESSORS
+
+C<base_url>, C<timeout>, C<max_attempts>, C<backoff_base>, C<backoff_max>,
+C<total_timeout> and C<user_agent> each return the configured value.
+
+=head1 CONSTANTS
+
+=head2 DEFAULT_URL
+
+C<https://cep.is>.
+
+=head2 MAX_BATCH
+
+C<500>, the most CEPs the API accepts in a single batch request.
+
+=head1 SEE ALSO
+
+L<Cepis>, L<Cepis::Address>, L<Cepis::Health>, L<Cepis::Error>
+
+=head1 LICENCE
+
+ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>.
+
+=cut
diff --git a/lib/Cepis/Error.pm b/lib/Cepis/Error.pm
new file mode 100644
index 0000000..080b0a1
--- /dev/null
+++ b/lib/Cepis/Error.pm
@@ -0,0 +1,226 @@
+package Cepis::Error;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.1.0';
+
+use overload
+ '""' => sub { $_[0]->message },
+ 'bool' => sub { 1 },
+ fallback => 1;
+
+sub new {
+ my ( $class, %args ) = @_;
+ my $self = {%args};
+ $self->{message} = $class unless defined $self->{message};
+ return bless $self, $class;
+}
+
+sub throw {
+ my $class = shift;
+ die $class->new(@_);
+}
+
+sub message { return $_[0]->{message} }
+
+package Cepis::Error::InvalidCEP;
+
+use strict;
+use warnings;
+use parent -norequire, 'Cepis::Error';
+
+our $VERSION = '0.1.0';
+
+package Cepis::Error::CEPNotFound;
+
+use strict;
+use warnings;
+use parent -norequire, 'Cepis::Error';
+
+our $VERSION = '0.1.0';
+
+package Cepis::Error::Network;
+
+use strict;
+use warnings;
+use parent -norequire, 'Cepis::Error';
+
+our $VERSION = '0.1.0';
+
+package Cepis::Error::ServerError;
+
+use strict;
+use warnings;
+use parent -norequire, 'Cepis::Error';
+
+our $VERSION = '0.1.0';
+
+sub status { return $_[0]->{status} }
+
+package Cepis::Error::RateLimited;
+
+use strict;
+use warnings;
+use parent -norequire, 'Cepis::Error';
+
+our $VERSION = '0.1.0';
+
+sub retry_after { return $_[0]->{retry_after} }
+
+package Cepis::Error::InvalidResponse;
+
+use strict;
+use warnings;
+use parent -norequire, 'Cepis::Error';
+
+our $VERSION = '0.1.0';
+
+1;
+
+__END__
+
+=encoding UTF-8
+
+=head1 NAME
+
+Cepis::Error - exception hierarchy for L<Cepis>
+
+=head1 SYNOPSIS
+
+ use Cepis;
+
+ my $address = eval { Cepis::lookup($cep_from_form) };
+ if ( !$address ) {
+ my $err = $@;
+ if ( ref $err && $err->isa('Cepis::Error::CEPNotFound') ) {
+ # the CEP does not exist: ask the user for another
+ }
+ elsif ( ref $err && $err->isa('Cepis::Error') ) {
+ # network, server, rate limit: retry later
+ }
+ else {
+ die $err;
+ }
+ }
+
+=head1 DESCRIPTION
+
+Every failure raised by this library is an object thrown with C<die>, and every
+one of them inherits from C<Cepis::Error>. One C<< ref $@ && $@->isa('Cepis::Error') >>
+therefore catches the lot.
+
+Below the base class the split is by B<the decision the caller has to make>,
+not by the HTTP status that caused it:
+
+=over 4
+
+=item *
+
+L</Cepis::Error::InvalidCEP> and L</Cepis::Error::CEPNotFound> are problems with
+the I<request>. Retrying is pointless — the same CEP will fail again.
+
+=item *
+
+L</Cepis::Error::Network>, L</Cepis::Error::ServerError> and
+L</Cepis::Error::RateLimited> are problems with I<transport>. Retrying may help,
+and the client already retried on its own before throwing any of them.
+
+=back
+
+That split is why C<CEPNotFound> is not "the HTTP 404 exception": C<InvalidCEP>
+is thrown without any request being made at all, when the CEP is malformed
+before it ever leaves the machine.
+
+Every error stringifies to its message, so C<warn $@> and C<"$@"> do the
+obvious thing.
+
+=head1 CLASSES
+
+=head2 Cepis::Error
+
+Base of every error. The only accessor it guarantees is C<message>.
+
+=over 4
+
+=item C<< Cepis::Error->new( message => $text, %fields ) >>
+
+Builds the object. Extra fields are stored as-is and become available to
+subclasses.
+
+=item C<< Cepis::Error->throw( message => $text, %fields ) >>
+
+Builds the object and C<die>s with it.
+
+=item C<< $err->message >>
+
+The human-readable message. Also what the object stringifies to.
+
+=back
+
+=head2 Cepis::Error::InvalidCEP
+
+The CEP is not 8 digits.
+
+Usually thrown B<without> a request ever being made — the client checks the
+format before going to the network. It is also thrown when the API answers 400
+to a CEP that passed local validation, in which case it carries the API's own
+message.
+
+=head2 Cepis::Error::CEPNotFound
+
+The CEP is well-formed but does not exist in the DNE database.
+
+=head2 Cepis::Error::Network
+
+The request never completed: DNS, refused connection, timeout, TLS.
+
+Thrown only after every configured attempt has failed — with one deliberate
+exception. When C<base_url> is C<https:> and Perl has no TLS support installed,
+this is thrown immediately, before any attempt, with a message naming the two
+modules to install. Retrying cannot fix a missing module. See
+L<Cepis::Client/"HTTPS support">.
+
+=head2 Cepis::Error::ServerError
+
+The API answered 5xx on every attempt.
+
+=over 4
+
+=item C<< $err->status >>
+
+The HTTP status seen on the last attempt.
+
+=back
+
+=head2 Cepis::Error::RateLimited
+
+The API, or the edge in front of it, answered 429 on every attempt.
+
+=over 4
+
+=item C<< $err->retry_after >>
+
+What the server asked for, in seconds, or C<undef> 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.
+
+=back
+
+=head2 Cepis::Error::InvalidResponse
+
+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.
+
+=head1 SEE ALSO
+
+L<Cepis>, L<Cepis::Client>
+
+=head1 LICENCE
+
+ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>.
+
+=cut
diff --git a/lib/Cepis/Geo.pm b/lib/Cepis/Geo.pm
new file mode 100644
index 0000000..bee2515
--- /dev/null
+++ b/lib/Cepis/Geo.pm
@@ -0,0 +1,148 @@
+package Cepis::Geo;
+
+use strict;
+use warnings;
+
+use Scalar::Util ();
+
+our $VERSION = '0.1.0';
+
+sub from_json {
+ my ( $class, $data ) = @_;
+
+ return undef unless ref $data eq 'HASH';
+
+ for my $key (qw(lat lon raio_m precisao)) {
+ return undef unless defined $data->{$key};
+ }
+ for my $key (qw(lat lon raio_m)) {
+ return undef unless Scalar::Util::looks_like_number( $data->{$key} );
+ }
+ return undef if ref $data->{precisao};
+
+ return bless {
+ lat => 0 + $data->{lat},
+ lon => 0 + $data->{lon},
+ raio_m => int( 0 + $data->{raio_m} ),
+ precisao => "$data->{precisao}",
+ }, $class;
+}
+
+sub lat { return $_[0]->{lat} }
+sub lon { return $_[0]->{lon} }
+sub raio_m { return $_[0]->{raio_m} }
+sub precisao { return $_[0]->{precisao} }
+
+sub is_precise { return $_[0]->{precisao} eq 'logradouro' ? 1 : 0 }
+
+sub to_hash {
+ my ($self) = @_;
+ return {
+ lat => $self->{lat},
+ lon => $self->{lon},
+ raio_m => $self->{raio_m},
+ precisao => $self->{precisao},
+ };
+}
+
+1;
+
+__END__
+
+=encoding UTF-8
+
+=head1 NAME
+
+Cepis::Geo - the coordinate of a CEP
+
+=head1 SYNOPSIS
+
+ my $address = Cepis::lookup('01310100');
+
+ if ( my $geo = $address->geo ) {
+ printf "%s %s\n", $geo->lat, $geo->lon; # -23.567202 -46.649659
+ print $geo->raio_m, "\n"; # 1084
+ print $geo->precisao, "\n"; # logradouro
+ }
+
+=head1 DESCRIPTION
+
+The CEP's coordinate, when the database has one — about 57% of them. When there
+is none, C<< $address->geo >> is C<undef>; the API omits the whole block rather
+than sending null.
+
+The point is the B<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.
+
+B<Always check C<precisao> before using C<lat>/C<lon> for distance.> There are
+two kinds of point and the difference between them is tens of kilometres:
+
+=over 4
+
+=item C<"logradouro">
+
+The point is a stretch of street. C<raio_m> lands around 100–150 m. Good for
+maps and for distance.
+
+=item C<"localidade">
+
+The CEP I<is> the whole municipality (a city-wide general CEP), and the point is
+its centroid. C<raio_m> runs from 7 km to 40 km. Computing shipping from this is
+wrong by tens of kilometres.
+
+=back
+
+C<raio_m> is the radius containing 90% of that CEP's addresses. It exists
+precisely so that error cannot pass unnoticed.
+
+Field names are the API's own, in Portuguese. See L<Cepis::Address> for why.
+
+=head1 CONSTRUCTOR
+
+=head2 from_json
+
+ my $geo = Cepis::Geo->from_json( $hashref );
+
+Builds from the C<geo> block the API returned. Returns C<undef> when the block
+is missing, is not a hash, or is malformed — a bad C<geo> is not worth losing
+the whole address over, since the rest of the response is still useful.
+
+=head1 ACCESSORS
+
+=head2 lat
+
+Latitude, as a number.
+
+=head2 lon
+
+Longitude, as a number.
+
+=head2 raio_m
+
+Radius in metres containing 90% of that CEP's addresses, as an integer.
+
+=head2 precisao
+
+Either C<"logradouro"> or C<"localidade">. Read the warning above before using
+the coordinate for anything metric.
+
+=head2 is_precise
+
+True when the coordinate points at a street rather than a municipality.
+Shorthand for C<< $geo->precisao eq 'logradouro' >>, which is the check to make
+before using the point to measure distance.
+
+=head2 to_hash
+
+The four fields as a plain hashref.
+
+=head1 SEE ALSO
+
+L<Cepis>, L<Cepis::Address>
+
+=head1 LICENCE
+
+ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>.
+
+=cut
diff --git a/lib/Cepis/Health.pm b/lib/Cepis/Health.pm
new file mode 100644
index 0000000..6782fd0
--- /dev/null
+++ b/lib/Cepis/Health.pm
@@ -0,0 +1,138 @@
+package Cepis::Health;
+
+use strict;
+use warnings;
+
+use Scalar::Util ();
+
+our $VERSION = '0.1.0';
+
+sub from_json {
+ my ( $class, $data ) = @_;
+
+ my %raw = %{ $data || {} };
+
+ return bless {
+ status => _string( $raw{status} ),
+ dne => _string( $raw{dne} ),
+ ceps => _integer( $raw{ceps} ),
+ geo => _integer( $raw{geo} ),
+ base_gerada_em => _string( $raw{base_gerada_em} ),
+ raw => \%raw,
+ }, $class;
+}
+
+sub status { return $_[0]->{status} }
+sub dne { return $_[0]->{dne} }
+sub ceps { return $_[0]->{ceps} }
+sub geo { return $_[0]->{geo} }
+sub base_gerada_em { return $_[0]->{base_gerada_em} }
+sub raw { return $_[0]->{raw} }
+
+sub ok { return $_[0]->{status} eq 'ok' ? 1 : 0 }
+
+sub to_hash {
+ my ($self) = @_;
+ return {
+ status => $self->{status},
+ dne => $self->{dne},
+ ceps => $self->{ceps},
+ geo => $self->{geo},
+ base_gerada_em => $self->{base_gerada_em},
+ };
+}
+
+sub _string {
+ my ($value) = @_;
+ return '' unless defined $value;
+ return '' if ref $value;
+ return "$value";
+}
+
+sub _integer {
+ my ($value) = @_;
+ return 0 unless defined $value;
+ return 0 unless Scalar::Util::looks_like_number($value);
+ return int( 0 + $value );
+}
+
+1;
+
+__END__
+
+=encoding UTF-8
+
+=head1 NAME
+
+Cepis::Health - the answer of C<GET /health>
+
+=head1 SYNOPSIS
+
+ my $h = Cepis::health();
+
+ $h->ok; # 1
+ $h->dne; # "26071" identifier of the fortnightly database
+ $h->ceps; # 1596168
+ $h->base_gerada_em; # "2026-07-24T03:48:28Z"
+
+=head1 DESCRIPTION
+
+Good for two things: checking that the API answers, and knowing B<which version
+of the database> is live. The DNE database changes every two weeks; L</dne>
+identifies it.
+
+An API reporting itself degraded does not raise — that arrives in L</status>.
+
+=head1 CONSTRUCTOR
+
+=head2 from_json
+
+ my $health = Cepis::Health->from_json( $hashref );
+
+Builds from the decoded JSON object the API returned.
+
+=head1 ACCESSORS
+
+=head2 status
+
+C<"ok"> when the API considers itself healthy, something else otherwise.
+
+=head2 ok
+
+True when L</status> is exactly C<"ok">.
+
+=head2 dne
+
+Identifier of the DNE database currently loaded. It changes every fortnight.
+
+=head2 ceps
+
+How many CEPs the database holds.
+
+=head2 geo
+
+How many of those CEPs carry a coordinate. Note that unlike
+L<Cepis::Address/geo> this is a count, not an object — that is what the API
+sends.
+
+=head2 base_gerada_em
+
+When the database was generated, as an ISO 8601 timestamp string.
+
+=head2 raw
+
+The decoded response exactly as it arrived, as a hashref.
+
+=head2 to_hash
+
+The five named fields as a plain hashref.
+
+=head1 SEE ALSO
+
+L<Cepis>, L<Cepis::Client>
+
+=head1 LICENCE
+
+ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>.
+
+=cut