summaryrefslogtreecommitdiffstats
path: root/lib/src/client.dart
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:38:22 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:38:22 -0300
commit8e719fc715eb7ae9aa1c842e633f15b5c6cd3688 (patch)
tree4afda3d13e1b772f2c9a5cf9ceea5e84c647ebdd /lib/src/client.dart
downloaddart-8e719fc715eb7ae9aa1c842e633f15b5c6cd3688.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib/src/client.dart')
-rw-r--r--lib/src/client.dart496
1 files changed, 496 insertions, 0 deletions
diff --git a/lib/src/client.dart b/lib/src/client.dart
new file mode 100644
index 0000000..9ddc165
--- /dev/null
+++ b/lib/src/client.dart
@@ -0,0 +1,496 @@
+/// The HTTP client, built on `package:http`.
+///
+/// Retry policy: 429 and the transient 5xx in [retriableStatus], plus network
+/// failures, are retried with exponentially backed-off, jittered waits. 400 and
+/// 404 are deliberately absent from that set — both are definitive answers
+/// about the request, so retrying returns the same thing more slowly while
+/// burning someone else's quota.
+library;
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:math';
+
+import 'package:http/http.dart' as http;
+
+import 'csv.dart';
+import 'errors.dart';
+import 'models.dart';
+import 'version.dart';
+
+/// Where the public API lives.
+const String defaultBaseUrl = 'https://cep.is';
+
+/// How many CEPs the `/csv` endpoint accepts in one request.
+const int maxBatch = 500;
+
+/// The statuses worth trying again. 400 and 404 are deliberately absent.
+const Set<int> retriableStatus = <int>{429, 500, 502, 503, 504};
+
+const String _accept = 'application/json, text/csv;q=0.9, */*;q=0.1';
+
+final RegExp _digitsOnly = RegExp(r'^\d{8}$');
+
+final RegExp _separators = RegExp(r'[-.\s]');
+
+final RegExp _imfFixdate = RegExp(
+ r'^[A-Za-z]{3},\s+(\d{2})\s+([A-Za-z]{3})\s+(\d{4})\s+'
+ r'(\d{2}):(\d{2}):(\d{2})\s+GMT$',
+);
+
+const Map<String, int> _months = <String, int>{
+ 'jan': 1,
+ 'feb': 2,
+ 'mar': 3,
+ 'apr': 4,
+ 'may': 5,
+ 'jun': 6,
+ 'jul': 7,
+ 'aug': 8,
+ 'sep': 9,
+ 'oct': 10,
+ 'nov': 11,
+ 'dec': 12,
+};
+
+/// Returns the CEP's 8 digits, accepting the mask.
+///
+/// `'01001-000'`, `'01001000'` and `' 01001-000 '` all become `'01001000'`.
+///
+/// Throws [InvalidCepException] unless exactly 8 digits remain. This runs
+/// **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.
+String normalizeCep(String value) {
+ final cleaned = value.trim().replaceAll(_separators, '');
+ if (!_digitsOnly.hasMatch(cleaned)) {
+ throw InvalidCepException(
+ "CEP must be 8 digits, got '$value'",
+ source: value,
+ );
+ }
+ return cleaned;
+}
+
+/// Client for the cep.is API.
+///
+/// Reuse one instance: the configuration is immutable and no method keeps state
+/// between calls, so it is safe to share anywhere in an isolate.
+///
+/// ```dart
+/// final client = Client(totalTimeout: Duration(seconds: 15));
+/// final address = await client.lookup('01001-000');
+/// ```
+///
+/// Call [close] when done, unless you passed your own `httpClient` — then it is
+/// yours to close.
+class Client {
+ /// Creates a client.
+ ///
+ /// [baseUrl] is where the API lives. Point it at a local instance (the API
+ /// repo brings one up on `http://localhost:8080` with `just up`) or at a test
+ /// server. It is the only parameter that usually changes between
+ /// environments. A trailing slash is stripped, so it cannot produce doubled
+ /// separators that some proxies normalise and others 404.
+ ///
+ /// [timeout] bounds a **single attempt** — request and body read together.
+ /// `package:http` reads the whole response before returning, so this is one
+ /// deadline over the exchange rather than one per socket operation. The cap
+ /// on the whole call, retries and waits included, is [totalTimeout].
+ ///
+ /// [maxAttempts] counts the first attempt; `1` disables retrying. The default
+ /// of `3` covers the common case (one transient failure) without turning a
+ /// real outage into a long wait.
+ ///
+ /// [backoffBase] is the base of the exponential wait. The actual wait is
+ /// drawn from `[0, base * 2^(n-1)]` — *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.
+ ///
+ /// [backoffMax] caps each individual wait. It applies to a server-sent
+ /// `Retry-After` too, or a `Retry-After: 3600` from a misconfigured proxy
+ /// would park the caller for an hour.
+ ///
+ /// [totalTimeout] caps the whole call, attempts and waits included. `null`
+ /// leaves [maxAttempts] as the only bound. Set it in code serving a web
+ /// request: without it the worst case is `maxAttempts * timeout` plus the sum
+ /// of the backoffs. When this cap is what cuts an attempt short, the error
+ /// reported is still the API's last real answer rather than the truncation —
+ /// a 503 you already know about is a better diagnosis than a timeout this
+ /// client caused itself.
+ ///
+ /// [userAgent] overrides the `User-Agent`. The default identifies the library
+ /// and its version, which is what lets the server diagnose an old client
+ /// misbehaving.
+ ///
+ /// [httpClient] replaces the transport — pass one to share a connection pool,
+ /// or to use a platform-native implementation such as `cupertino_http` or
+ /// `cronet_http` under Flutter. A client passed here is never closed by
+ /// [close].
+ ///
+ /// Throws [ArgumentError] for a non-positive duration or fewer than one
+ /// attempt.
+ Client({
+ String baseUrl = defaultBaseUrl,
+ Duration timeout = const Duration(seconds: 10),
+ int maxAttempts = 3,
+ Duration backoffBase = const Duration(milliseconds: 500),
+ Duration backoffMax = const Duration(seconds: 8),
+ Duration? totalTimeout,
+ String? userAgent,
+ http.Client? httpClient,
+ }) : baseUrl = _stripTrailingSlashes(baseUrl),
+ timeout = _positive(timeout, 'timeout'),
+ maxAttempts = _atLeastOne(maxAttempts),
+ backoffBase = _positive(backoffBase, 'backoffBase'),
+ backoffMax = _positive(backoffMax, 'backoffMax'),
+ totalTimeout = totalTimeout == null
+ ? null
+ : _positive(totalTimeout, 'totalTimeout'),
+ userAgent = userAgent ?? 'cepis-dart/$packageVersion (+https://cep.is)',
+ _http = httpClient ?? http.Client(),
+ _ownsHttp = httpClient == null;
+
+ /// The API root, without a trailing slash.
+ final String baseUrl;
+
+ /// How long a single attempt may take, request and body read together.
+ final Duration timeout;
+
+ /// How many attempts in total, counting the first.
+ final int maxAttempts;
+
+ /// Base of the exponential wait.
+ final Duration backoffBase;
+
+ /// Cap on each individual wait, a server-sent `Retry-After` included.
+ final Duration backoffMax;
+
+ /// Cap on the whole call, or `null` for none.
+ final Duration? totalTimeout;
+
+ /// The `User-Agent` sent with every request.
+ final String userAgent;
+
+ final http.Client _http;
+ final bool _ownsHttp;
+ final Random _random = Random();
+
+ /// Looks up a single CEP.
+ ///
+ /// ```dart
+ /// final address = await client.lookup('01001-000');
+ /// address.localidade; // 'São Paulo'
+ /// ```
+ ///
+ /// A 400 from the API surfaces as [InvalidCepException] carrying the API's
+ /// own message: the format already passed local validation, so the server
+ /// knows something this client does not.
+ ///
+ /// Throws [InvalidCepException] when the CEP is not 8 digits — no request is
+ /// made; [CepNotFoundException] when it is well-formed but absent from the
+ /// database; [RateLimitedException] on 429 after every attempt;
+ /// [ServerException] on 5xx after every attempt; [NetworkException] when the
+ /// request never completed; [InvalidResponseException] on a success status
+ /// with an unreadable body.
+ Future<Address> lookup(String cep) async {
+ final number = normalizeCep(cep);
+ final reply = await _request(_uri('/$number'));
+
+ if (reply.status == 404) {
+ throw CepNotFoundException(
+ 'CEP $number does not exist in the DNE database',
+ cep: number,
+ );
+ }
+ if (reply.status == 400) {
+ throw InvalidCepException(
+ _errorMessage(reply.body, 'CEP $number rejected'),
+ source: cep,
+ );
+ }
+
+ return Address.fromJson(_json(reply.body));
+ }
+
+ /// Looks up many CEPs in one batch.
+ ///
+ /// Returns a list of **the same length and in the same order** as the input.
+ /// A CEP that does not exist becomes `null` at its position — it does not
+ /// vanish from the list, and it does not throw. That is the right difference
+ /// from [lookup]: in a batch of a thousand, one bad CEP must not cost the
+ /// other nine hundred and ninety-nine.
+ ///
+ /// A malformed CEP also becomes `null`, for the same reason, rather than
+ /// throwing [InvalidCepException]. To know *which* one was malformed,
+ /// validate first with [normalizeCep].
+ ///
+ /// Input is chunked into blocks of [maxBatch] automatically.
+ ///
+ /// **Batches carry no coordinate.** The endpoint behind this is CSV, and the
+ /// CSV has no `geo` column. Every [Address] returned here has a `null`
+ /// [Address.geo], including for CEPs that do have one. Need the coordinate?
+ /// Use [lookup], one at a time.
+ Future<List<Address?>> lookupMany(List<String> ceps) async {
+ if (ceps.isEmpty) {
+ return <Address?>[];
+ }
+
+ final result = <Address?>[];
+ for (var start = 0; start < ceps.length; start += maxBatch) {
+ final end = min(start + maxBatch, ceps.length);
+ result.addAll(await _lookupChunk(ceps.sublist(start, end)));
+ }
+ return result;
+ }
+
+ /// Queries `GET /health`: does the API answer, and with which database.
+ ///
+ /// Does not throw for an API reporting itself degraded — that arrives in
+ /// [Health.status], and checking health is exactly when you want the
+ /// diagnosis rather than an exception. Network failures still throw.
+ Future<Health> health() async {
+ final reply = await _request(
+ _uri('/health'),
+ acceptedStatus: const <int>[200, 503],
+ );
+ return Health.fromJson(_json(reply.body));
+ }
+
+ /// Releases the underlying transport.
+ ///
+ /// Does nothing when the transport was supplied through the `httpClient`
+ /// parameter: that one belongs to the caller.
+ void close() {
+ if (_ownsHttp) {
+ _http.close();
+ }
+ }
+
+ Future<List<Address?>> _lookupChunk(List<String> ceps) async {
+ final reply = await _request(
+ _uri('/csv', <String, String>{'ceps': ceps.join(',')}),
+ );
+
+ final rows = <Address?>[];
+ for (final record in parseCsv(reply.body)) {
+ if ((record['localidade'] ?? '').trim().isEmpty) {
+ rows.add(null);
+ continue;
+ }
+ rows.add(Address.fromJson(record));
+ }
+
+ while (rows.length < ceps.length) {
+ rows.add(null);
+ }
+ return rows.sublist(0, ceps.length);
+ }
+
+ Uri _uri(String path, [Map<String, String>? query]) {
+ final uri = Uri.parse('$baseUrl$path');
+ return query == null ? uri : uri.replace(queryParameters: query);
+ }
+
+ Map<String, dynamic> _json(String body) {
+ final Object? data;
+ try {
+ data = jsonDecode(body);
+ } on FormatException {
+ throw InvalidResponseException(
+ 'response is not valid JSON — something between you and the API is '
+ 'answering instead of it: ${_snippet(body)}',
+ );
+ }
+ if (data is! Map<String, dynamic>) {
+ throw InvalidResponseException(
+ 'expected a JSON object, got ${data.runtimeType}',
+ );
+ }
+ return data;
+ }
+
+ Duration _backoff(int attempt, Duration? retryAfter) {
+ if (retryAfter != null) {
+ return retryAfter < backoffMax ? retryAfter : backoffMax;
+ }
+ final exponent = attempt - 1 > 30 ? 30 : attempt - 1;
+ final ceiling = min(
+ backoffBase.inMicroseconds * (1 << exponent),
+ backoffMax.inMicroseconds,
+ );
+ return Duration(microseconds: _random.nextInt(max(ceiling, 1)));
+ }
+
+ Future<_Reply> _request(
+ Uri url, {
+ List<int> acceptedStatus = const <int>[200],
+ }) async {
+ final elapsed = Stopwatch()..start();
+ final budget = totalTimeout;
+
+ int? lastStatus;
+ Duration? lastRetryAfter;
+ Object? lastFailure;
+
+ for (var attempt = 1; attempt <= maxAttempts; attempt++) {
+ var deadline = timeout;
+ var truncated = false;
+ if (budget != null) {
+ final remaining = budget - elapsed.elapsed;
+ if (remaining <= Duration.zero) {
+ break;
+ }
+ if (remaining < deadline) {
+ deadline = remaining;
+ truncated = true;
+ }
+ }
+
+ try {
+ final response = await _http.get(
+ url,
+ headers: <String, String>{
+ 'User-Agent': userAgent,
+ 'Accept': _accept,
+ },
+ ).timeout(deadline);
+
+ if (acceptedStatus.contains(response.statusCode) ||
+ !retriableStatus.contains(response.statusCode)) {
+ return _Reply(response.statusCode, _decode(response.bodyBytes));
+ }
+
+ lastStatus = response.statusCode;
+ lastRetryAfter = _retryAfter(response.headers['retry-after']);
+ lastFailure = null;
+ } on Exception catch (error) {
+ if (truncated && error is TimeoutException && lastStatus != null) {
+ break;
+ }
+ lastFailure = error;
+ lastStatus = null;
+ lastRetryAfter = null;
+ }
+
+ if (attempt == maxAttempts) {
+ break;
+ }
+
+ var wait = _backoff(attempt, lastRetryAfter);
+ if (budget != null) {
+ final remaining = budget - elapsed.elapsed;
+ if (remaining <= Duration.zero) {
+ break;
+ }
+ if (remaining < wait) {
+ wait = remaining;
+ }
+ }
+ await Future<void>.delayed(wait);
+ }
+
+ final suffix = '(after $maxAttempts attempt(s))';
+ if (lastStatus == 429) {
+ throw RateLimitedException(
+ 'rate limit reached at $url $suffix',
+ retryAfter: lastRetryAfter,
+ );
+ }
+ if (lastStatus != null) {
+ throw ServerException(
+ 'the API answered $lastStatus at $url $suffix',
+ status: lastStatus,
+ );
+ }
+ throw NetworkException(
+ 'could not reach $url $suffix: $lastFailure',
+ cause: lastFailure,
+ );
+ }
+
+ static String _stripTrailingSlashes(String value) {
+ var end = value.length;
+ while (end > 0 && value[end - 1] == '/') {
+ end--;
+ }
+ return value.substring(0, end);
+ }
+
+ static Duration _positive(Duration value, String name) {
+ if (value <= Duration.zero) {
+ throw ArgumentError.value(value, name, 'must be > 0');
+ }
+ return value;
+ }
+
+ static int _atLeastOne(int value) {
+ if (value < 1) {
+ throw ArgumentError.value(value, 'maxAttempts', 'must be >= 1');
+ }
+ return value;
+ }
+}
+
+class _Reply {
+ const _Reply(this.status, this.body);
+
+ final int status;
+ final String body;
+}
+
+String _decode(List<int> bytes) => utf8.decode(bytes, allowMalformed: true);
+
+String _snippet(String body) =>
+ body.length <= 120 ? body : '${body.substring(0, 120)}…';
+
+String _errorMessage(String body, String fallback) {
+ try {
+ final Object? data = jsonDecode(body);
+ if (data is Map<String, dynamic>) {
+ final message = data['mensagem'];
+ if (message is String && message.isNotEmpty) {
+ return message;
+ }
+ }
+ } on FormatException {
+ return fallback;
+ }
+ return fallback;
+}
+
+Duration? _retryAfter(String? value) {
+ if (value == null) {
+ return null;
+ }
+ final header = value.trim();
+ if (header.isEmpty) {
+ return null;
+ }
+
+ final seconds = double.tryParse(header);
+ if (seconds != null) {
+ if (seconds <= 0) {
+ return Duration.zero;
+ }
+ return Duration(microseconds: (seconds * 1000000).round());
+ }
+
+ final match = _imfFixdate.firstMatch(header);
+ if (match == null) {
+ return null;
+ }
+ final month = _months[match.group(2)!.toLowerCase()];
+ if (month == null) {
+ return null;
+ }
+ final target = DateTime.utc(
+ int.parse(match.group(3)!),
+ month,
+ int.parse(match.group(1)!),
+ int.parse(match.group(4)!),
+ int.parse(match.group(5)!),
+ int.parse(match.group(6)!),
+ );
+ final delta = target.difference(DateTime.now().toUtc());
+ return delta.isNegative ? Duration.zero : delta;
+}