diff options
Diffstat (limited to 'lib/src/client.dart')
| -rw-r--r-- | lib/src/client.dart | 496 |
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; +} |