using System.Collections.Frozen; using System.Globalization; using System.Net.Sockets; using System.Text; using System.Text.Json; namespace Cepis; /// /// Client for the public CEP API at cep.is. /// /// /// /// Build one and reuse it. The configuration is immutable, no method keeps /// state between calls, and the single underlying /// pools connections — sharing one instance across threads is both safe and /// faster than creating them. A client per request is the classic way to run a /// .NET process out of sockets. /// /// /// /// using var client = new CepisClient(new CepisClientOptions /// { /// BaseUrl = "http://localhost:8080", /// }); /// /// Address address = await client.LookupAsync("01001-000"); /// /// /// /// For a script, is a shared client with the default /// configuration, built on first use. /// /// /// Retries cover 429, the transient 5xx 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 else's quota. /// /// /// A note on names: the API surface is English, but the data fields keep the /// API's own Portuguese names (, /// , ), with only /// the casing adapted. What you read in the JSON is what you access on the /// object. /// /// public sealed class CepisClient : IDisposable { /// Where the public API lives. public const string DefaultUrl = "https://cep.is"; /// How many CEPs the batch endpoint accepts in one request. public const int MaxBatch = 500; /// The version of this library. public const string Version = "0.2.0"; private const string AcceptHeader = "application/json, text/csv;q=0.9, */*;q=0.1"; private static readonly FrozenSet RetriableStatus = new[] { 429, 500, 502, 503, 504 }.ToFrozenSet(); private static readonly FrozenSet OkOnly = new[] { 200 }.ToFrozenSet(); private static readonly FrozenSet HealthStatus = new[] { 200, 503 }.ToFrozenSet(); private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); private static readonly Lazy SharedDefault = new(() => new CepisClient()); private readonly HttpClient _http; private readonly bool _ownsHttp; private readonly string _baseUrl; private readonly CepisClientOptions _options; /// Build a client with the default configuration. public CepisClient() : this(new CepisClientOptions()) { } /// Build a client with its own . /// the configuration /// is null /// a setting is out of range public CepisClient(CepisClientOptions options) : this(NewHttpClient(), options, ownsHttp: true) { } /// /// Build a client over an the caller owns. /// /// /// For applications that resolve their from /// IHttpClientFactory or a container. The instance is neither /// reconfigured nor disposed by this client, so its own /// still applies on top of /// — whichever is shorter wins. /// /// the transport to use /// the configuration, or null for the defaults /// is null /// a setting is out of range public CepisClient(HttpClient httpClient, CepisClientOptions? options = null) : this(httpClient, options ?? new CepisClientOptions(), ownsHttp: false) { } private CepisClient(HttpClient httpClient, CepisClientOptions options, bool ownsHttp) { ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(options); Validate(options); _http = httpClient; _ownsHttp = ownsHttp; _options = options; _baseUrl = options.BaseUrl.Trim().TrimEnd('/'); UserAgent = options.UserAgent ?? $"cepis-csharp/{Version} (+https://cep.is)"; } /// /// A shared client with the default configuration, built on first use. /// /// /// Convenient for scripts and one-off lookups. It applies process-wide, /// including to third-party libraries that also use this package, so do not /// dispose it; anyone needing different settings should build their own. /// public static CepisClient Default => SharedDefault.Value; /// Where the API lives, without a trailing slash. public string BaseUrl => _baseUrl; /// The configuration this client was built with. public CepisClientOptions Options => _options; /// The User-Agent this client sends. public string UserAgent { get; } /// /// Return the CEP's eight digits, accepting the mask. /// /// /// "01001-000", "01001000" and " 01001-000 " all become /// "01001000". This runs before any request: a seven-digit CEP /// is the caller's bug, and spending a round trip for the server to say so /// wastes both sides' time. /// /// the CEP, masked or not /// the eight digits /// unless exactly eight digits remain public static string NormalizeCep(string value) { if (value is null) { throw new InvalidCepException("the CEP must not be null"); } var cleaned = value.Trim().Replace("-", "", StringComparison.Ordinal) .Replace(".", "", StringComparison.Ordinal) .Replace(" ", "", StringComparison.Ordinal); if (!IsEightDigits(cleaned)) { throw new InvalidCepException($"the CEP must be 8 digits, got \"{value}\""); } return cleaned; } /// /// Look up a single CEP. /// /// /// /// This is the only call that carries a coordinate. About 57% of CEPs have /// one; when they do not, is /// . /// /// /// A CEP that does not exist throws, as it always has. What is new is that /// the exception may name the municipality the number would belong to, in /// — context for the failure, not /// an address, and never a reason to treat the lookup as having succeeded. /// /// /// the CEP, masked or not /// cancels the call, waits included /// the address /// /// the CEP is not eight digits, in which case no request is made, or the API /// rejected it with a 400 /// /// /// well-formed, but not in the database; carries the municipality it would /// belong to when the API names one /// /// the API answered 429 on every attempt /// the API answered 5xx on every attempt /// the request never completed /// the body cannot be read public async Task
LookupAsync(string cep, CancellationToken cancellationToken = default) { var number = NormalizeCep(cep); var reply = await RequestAsync($"/{number}", OkOnly, cancellationToken).ConfigureAwait(false); if (reply.Status == 404) { var faixa = ReadFaixa(reply.Body); throw new CepNotFoundException(NotFoundMessage(number, faixa), faixa); } if (reply.Status == 400) { throw new InvalidCepException( ErrorMessage(reply.Body, $"CEP {number} was rejected by the API")); } return Deserialize
(reply.Body); } /// /// Look up many CEPs in one batch. /// /// /// /// The returned list has the same length and the same order as the /// input. A CEP that does not exist becomes at its /// position — it does not vanish from the list, and it does not throw. That /// is the right difference from /// : in a batch of a /// thousand, one bad CEP must not cost the other nine hundred and /// ninety-nine. /// /// /// A malformed CEP also becomes , for the same reason. /// If you need to know which one was malformed, validate first with /// . /// /// /// Input longer than is chunked into that many CEPs /// per request automatically. /// /// /// Batches carry no coordinate. The endpoint behind them is CSV, and /// the CSV has no geo column. Every address returned here has /// , including for CEPs that /// do have one. Need the coordinate? Use /// , one at a time. /// /// /// the CEPs, masked or not /// cancels the call, waits included /// /// one entry per input CEP, in order, where there is /// no address /// /// is null /// the API answered 429 on every attempt /// the API answered 5xx on every attempt /// a request never completed public async Task> LookupManyAsync( IEnumerable ceps, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(ceps); var wanted = ceps as IReadOnlyList ?? [.. ceps]; if (wanted.Count == 0) { return []; } var result = new List(wanted.Count); for (var start = 0; start < wanted.Count; start += MaxBatch) { var end = Math.Min(start + MaxBatch, wanted.Count); var chunk = new List(end - start); for (var i = start; i < end; i++) { chunk.Add(wanted[i]); } result.AddRange(await LookupChunkAsync(chunk, cancellationToken).ConfigureAwait(false)); } return result; } /// /// Query GET /health: does the API answer, and with which database. /// /// /// An API reporting itself degraded does not throw — that arrives in /// , and checking health is exactly when you want /// the diagnosis rather than an exception. Network failures still throw. /// /// cancels the call, waits included /// the API's health /// the request never completed /// the body cannot be read public async Task HealthAsync(CancellationToken cancellationToken = default) { var reply = await RequestAsync("/health", HealthStatus, cancellationToken).ConfigureAwait(false); return Deserialize(reply.Body); } /// /// Release the this client created for itself. /// /// /// An handed in by the caller is left alone. /// public void Dispose() { if (_ownsHttp) { _http.Dispose(); } } /// /// Parse the Retry-After header in both RFC 9110 forms. /// /// /// It may arrive as a number of seconds ("120") or as an HTTP date /// ("Wed, 21 Oct 2026 07:28:00 GMT"). Handling only the first is the /// common bug, and it makes a client ignore exactly the wait request that /// matters most. /// /// the header value, if any /// the wait, or null when there is nothing usable internal static TimeSpan? ParseRetryAfter(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } var header = value.Trim(); if (double.TryParse(header, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)) { return TimeSpan.FromSeconds(Math.Max(0, seconds)); } const DateTimeStyles styles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal; if (!DateTimeOffset.TryParseExact(header, "r", CultureInfo.InvariantCulture, styles, out var target) && !DateTimeOffset.TryParse(header, CultureInfo.InvariantCulture, styles, out target)) { return null; } var wait = target - DateTimeOffset.UtcNow; return wait < TimeSpan.Zero ? TimeSpan.Zero : wait; } private static HttpClient NewHttpClient() => new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AutomaticDecompression = System.Net.DecompressionMethods.All, }) { Timeout = System.Threading.Timeout.InfiniteTimeSpan, }; private static JsonSerializerOptions CreateJsonOptions() { var options = new JsonSerializerOptions(JsonSerializerDefaults.General) { NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, ReadCommentHandling = JsonCommentHandling.Disallow, AllowTrailingCommas = false, }; options.Converters.Add(new LenientStringConverter()); return options; } private static void Validate(CepisClientOptions options) { if (string.IsNullOrWhiteSpace(options.BaseUrl)) { throw new ArgumentException("BaseUrl must not be empty", nameof(options)); } if (options.MaxAttempts < 1) { throw new ArgumentException("MaxAttempts must be >= 1", nameof(options)); } if (options.Timeout <= TimeSpan.Zero) { throw new ArgumentException("Timeout must be > 0", nameof(options)); } if (options.BackoffBase < TimeSpan.Zero) { throw new ArgumentException("BackoffBase must be >= 0", nameof(options)); } if (options.BackoffMax < TimeSpan.Zero) { throw new ArgumentException("BackoffMax must be >= 0", nameof(options)); } if (options.TotalTimeout is { } total && total <= TimeSpan.Zero) { throw new ArgumentException("TotalTimeout must be > 0 when set", nameof(options)); } } private static bool IsEightDigits(string value) { if (value.Length != 8) { return false; } foreach (var c in value) { if (c is < '0' or > '9') { return false; } } return true; } private static T Deserialize(byte[] body) where T : class { T? value; try { value = JsonSerializer.Deserialize(body, JsonOptions); } catch (JsonException unreadable) { throw new InvalidResponseException( "the response is not valid JSON — something between you and the API is " + $"answering instead of it: {Preview(body)}", unreadable); } return value ?? throw new InvalidResponseException( $"expected a JSON object, got {Preview(body)}"); } private static string ErrorMessage(byte[] body, string fallback) { try { using var document = JsonDocument.Parse(new ReadOnlyMemory(body)); if (document.RootElement.ValueKind == JsonValueKind.Object && document.RootElement.TryGetProperty("mensagem", out var message) && message.ValueKind == JsonValueKind.String && message.GetString() is { Length: > 0 } text) { return text; } } catch (JsonException) { return fallback; } return fallback; } /// /// Read the faixa block out of a 404 body, when it has one. /// /// /// The block is optional and always will be: the API omits it — it does not /// send a null — for a CEP in a band no municipality owns. Absent, /// misshapen or unreadable, the answer here is and /// the caller still gets the plain not-found it was going to get anyway. /// /// the 404 body /// the municipality the CEP would belong to, or null private static Faixa? ReadFaixa(byte[] body) { try { using var document = JsonDocument.Parse(new ReadOnlyMemory(body)); if (document.RootElement.ValueKind == JsonValueKind.Object && document.RootElement.TryGetProperty("faixa", out var faixa)) { return FaixaReader.Read(faixa); } } catch (JsonException) { return null; } return null; } /// /// What to tell the caller about a CEP that is not in the database. /// /// /// The municipality goes in the message when the API named one, because the /// message is what ends up in a log, and "the range belongs to Areal/RJ" is /// the difference between a dead end and a question worth asking the user. /// The count is in it for the same reason the API sends it: so nobody reads /// the two ends as a list of CEPs to walk. /// /// the eight digits /// the municipality it would belong to, if any /// the message private static string NotFoundMessage(string cep, Faixa? faixa) { var message = $"CEP {cep} does not exist in the DNE database"; return faixa is null ? message : message + $" — the range belongs to {faixa.Localidade}/{faixa.UF}, " + $"which has {faixa.Ceps} CEP(s) between {faixa.CepIni} and {faixa.CepFim}"; } private static string Preview(byte[] body) => Encoding.UTF8.GetString(body, 0, Math.Min(body.Length, 120)); /// /// Turn one CSV record into an address, or null when there is none. /// /// /// The CSV column names are the JSON field names, so the record is routed /// through the same mapping as a native lookup rather than a second, /// drifting copy of it. A record the API filled only the cep column /// of means "not found". /// /// the record, by column name /// the address, or null private static Address? AddressFromRow(Dictionary row) { if (!row.TryGetValue("localidade", out var localidade) || string.IsNullOrWhiteSpace(localidade)) { return null; } return JsonSerializer.SerializeToElement(row).Deserialize
(JsonOptions); } private static string? FirstHeader(HttpResponseMessage response, string name) => response.Headers.TryGetValues(name, out var values) ? values.FirstOrDefault() : null; /// /// One chunk of at most CEPs, in a single request. /// /// /// The API guarantees one record per requested CEP. Should that ever fail to /// hold — a proxy truncating, a cut response — the result is padded to the /// input length rather than returned short, because a shifted list silently /// pairs the wrong address with the wrong CEP. /// /// the chunk /// cancels the call /// one entry per CEP in the chunk, in order private async Task> LookupChunkAsync( List ceps, CancellationToken cancellationToken) { var query = "/csv?ceps=" + Uri.EscapeDataString(string.Join(',', ceps)); var reply = await RequestAsync(query, OkOnly, cancellationToken).ConfigureAwait(false); var rows = new List(ceps.Count); foreach (var row in Csv.Parse(Encoding.UTF8.GetString(reply.Body))) { rows.Add(AddressFromRow(row)); } while (rows.Count < ceps.Count) { rows.Add(null); } return rows.Count > ceps.Count ? rows.GetRange(0, ceps.Count) : rows; } /// /// Perform the GET, with retries. /// /// /// /// Definitive error statuses (400, 404) are returned normally, for /// the caller to turn into the right exception — only transient failures /// become exceptions here, and only once the attempts are exhausted. /// /// /// arms its own token, which /// cuts an attempt that is still in flight and any wait between attempts, /// so the whole call really is bounded. That token is also how an abort is /// told apart from a transport failure — asking it, rather than comparing a /// clock against the budget, is what keeps the two from racing. The abort /// does not overwrite the diagnosis: a caller whose budget ran out while /// the API was answering 503 is told about the 503, not about a /// cancellation it did not ask for. /// /// /// the path, query included /// statuses to hand back rather than retry /// cancels the call /// the status and the body private async Task RequestAsync( string path, FrozenSet acceptedStatus, CancellationToken cancellationToken) { var uri = _baseUrl + path; using var deadline = _options.TotalTimeout is null ? null : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); deadline?.CancelAfter(_options.TotalTimeout!.Value); var callToken = deadline?.Token ?? cancellationToken; var attempts = 0; var lastStatus = 0; TimeSpan? lastRetryAfter = null; Exception? lastFailure = null; for (var attempt = 1; attempt <= _options.MaxAttempts; attempt++) { cancellationToken.ThrowIfCancellationRequested(); if (deadline is { IsCancellationRequested: true }) { break; } attempts = attempt; using var request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); request.Headers.TryAddWithoutValidation("Accept", AcceptHeader); using var attemptCancellation = CancellationTokenSource.CreateLinkedTokenSource(callToken); attemptCancellation.CancelAfter(_options.Timeout); try { using var response = await _http .SendAsync(request, HttpCompletionOption.ResponseContentRead, attemptCancellation.Token) .ConfigureAwait(false); var status = (int)response.StatusCode; if (acceptedStatus.Contains(status) || !RetriableStatus.Contains(status)) { var body = await response.Content .ReadAsByteArrayAsync(attemptCancellation.Token) .ConfigureAwait(false); return new Reply(status, body); } lastStatus = status; lastRetryAfter = ParseRetryAfter(FirstHeader(response, "Retry-After")); lastFailure = null; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception unreachable) when (unreachable is HttpRequestException or OperationCanceledException or SocketException or IOException) { if (deadline is { IsCancellationRequested: true }) { if (lastStatus == 0) { lastFailure = unreachable; } break; } lastFailure = unreachable; lastStatus = 0; lastRetryAfter = null; } if (attempt == _options.MaxAttempts) { break; } if (!await WaitBeforeRetryAsync(attempt, lastRetryAfter, callToken, cancellationToken) .ConfigureAwait(false)) { break; } } var suffix = $" (after {attempts} attempt(s))"; if (lastStatus == 429) { throw new RateLimitedException($"rate limit reached at {uri}{suffix}", lastRetryAfter); } if (lastStatus != 0) { throw new ServerErrorException( $"the API answered {lastStatus} at {uri}{suffix}", lastStatus); } throw new NetworkException( $"could not reach {uri}{suffix}: {lastFailure?.Message}", lastFailure); } /// /// Wait out the backoff, reporting whether there is still budget to retry. /// /// /// The wait is cut short by , so a /// that expires mid-wait ends /// the call rather than being overrun. A cancellation the caller asked for /// propagates instead. /// /// which attempt just failed, from one /// what the server asked for, if anything /// the deadline, or the caller's token /// the caller's token alone /// false when the budget is spent private async Task WaitBeforeRetryAsync( int attempt, TimeSpan? retryAfter, CancellationToken callToken, CancellationToken cancellationToken) { var wait = Backoff(attempt, retryAfter); if (wait <= TimeSpan.Zero) { return !callToken.IsCancellationRequested; } try { await Task.Delay(wait, callToken).ConfigureAwait(false); return true; } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return false; } } /// /// How long to wait before the next attempt. /// /// /// The server's Retry-After wins over the local calculation when /// present: it knows when it will be back, we are guessing. It still goes /// through the cap. Otherwise /// the wait is drawn from [0, ceiling] rather than being the ceiling. /// /// which attempt just failed, from one /// what the server asked for, if anything /// the wait internal TimeSpan Backoff(int attempt, TimeSpan? retryAfter) { var ceiling = _options.BackoffMax.TotalMilliseconds; if (retryAfter is { } asked) { return TimeSpan.FromMilliseconds(Math.Min(asked.TotalMilliseconds, ceiling)); } var exponential = _options.BackoffBase.TotalMilliseconds * Math.Pow(2, Math.Min(attempt - 1, 32)); return TimeSpan.FromMilliseconds(Random.Shared.NextDouble() * Math.Min(exponential, ceiling)); } private readonly record struct Reply(int Status, byte[] Body); }