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.1.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
/// .
///
/// 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
/// 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)
{
throw new CepNotFoundException($"CEP {number} does not exist in the DNE database");
}
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;
}
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);
}