/// 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 retriableStatus = {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 _months = { '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
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> lookupMany(List ceps) async { if (ceps.isEmpty) { return []; } final result = []; 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() async { final reply = await _request( _uri('/health'), acceptedStatus: const [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> _lookupChunk(List ceps) async { final reply = await _request( _uri('/csv', {'ceps': ceps.join(',')}), ); final rows = []; 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? query]) { final uri = Uri.parse('$baseUrl$path'); return query == null ? uri : uri.replace(queryParameters: query); } Map _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) { 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 acceptedStatus = const [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: { '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.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 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) { 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; }