summaryrefslogtreecommitdiffstats
path: root/src/Cepis/CepisClient.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Cepis/CepisClient.cs')
-rw-r--r--src/Cepis/CepisClient.cs714
1 files changed, 714 insertions, 0 deletions
diff --git a/src/Cepis/CepisClient.cs b/src/Cepis/CepisClient.cs
new file mode 100644
index 0000000..72637c0
--- /dev/null
+++ b/src/Cepis/CepisClient.cs
@@ -0,0 +1,714 @@
+using System.Collections.Frozen;
+using System.Globalization;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+
+namespace Cepis;
+
+/// <summary>
+/// Client for the public CEP API at <see href="https://cep.is">cep.is</see>.
+/// </summary>
+/// <remarks>
+/// <para>
+/// Build one and reuse it. The configuration is immutable, no method keeps
+/// state between calls, and the single underlying <see cref="HttpClient"/>
+/// 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.
+/// </para>
+/// <example>
+/// <code>
+/// using var client = new CepisClient(new CepisClientOptions
+/// {
+/// BaseUrl = "http://localhost:8080",
+/// });
+///
+/// Address address = await client.LookupAsync("01001-000");
+/// </code>
+/// </example>
+/// <para>
+/// For a script, <see cref="Default"/> is a shared client with the default
+/// configuration, built on first use.
+/// </para>
+/// <para>
+/// 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.
+/// </para>
+/// <para>
+/// A note on names: the API surface is English, but the <b>data fields keep the
+/// API's own Portuguese names</b> (<see cref="Address.Logradouro"/>,
+/// <see cref="Address.Bairro"/>, <see cref="Address.Localidade"/>), with only
+/// the casing adapted. What you read in the JSON is what you access on the
+/// object.
+/// </para>
+/// </remarks>
+public sealed class CepisClient : IDisposable
+{
+ /// <summary>Where the public API lives.</summary>
+ public const string DefaultUrl = "https://cep.is";
+
+ /// <summary>How many CEPs the batch endpoint accepts in one request.</summary>
+ public const int MaxBatch = 500;
+
+ /// <summary>The version of this library.</summary>
+ 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<int> RetriableStatus =
+ new[] { 429, 500, 502, 503, 504 }.ToFrozenSet();
+
+ private static readonly FrozenSet<int> OkOnly = new[] { 200 }.ToFrozenSet();
+
+ private static readonly FrozenSet<int> HealthStatus = new[] { 200, 503 }.ToFrozenSet();
+
+ private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
+
+ private static readonly Lazy<CepisClient> SharedDefault = new(() => new CepisClient());
+
+ private readonly HttpClient _http;
+ private readonly bool _ownsHttp;
+ private readonly string _baseUrl;
+ private readonly CepisClientOptions _options;
+
+ /// <summary>Build a client with the default configuration.</summary>
+ public CepisClient()
+ : this(new CepisClientOptions())
+ {
+ }
+
+ /// <summary>Build a client with its own <see cref="HttpClient"/>.</summary>
+ /// <param name="options">the configuration</param>
+ /// <exception cref="ArgumentNullException"><paramref name="options"/> is null</exception>
+ /// <exception cref="ArgumentException">a setting is out of range</exception>
+ public CepisClient(CepisClientOptions options)
+ : this(NewHttpClient(), options, ownsHttp: true)
+ {
+ }
+
+ /// <summary>
+ /// Build a client over an <see cref="HttpClient"/> the caller owns.
+ /// </summary>
+ /// <remarks>
+ /// For applications that resolve their <see cref="HttpClient"/> from
+ /// <c>IHttpClientFactory</c> or a container. The instance is neither
+ /// reconfigured nor disposed by this client, so its own
+ /// <see cref="HttpClient.Timeout"/> still applies on top of
+ /// <see cref="CepisClientOptions.Timeout"/> — whichever is shorter wins.
+ /// </remarks>
+ /// <param name="httpClient">the transport to use</param>
+ /// <param name="options">the configuration, or null for the defaults</param>
+ /// <exception cref="ArgumentNullException"><paramref name="httpClient"/> is null</exception>
+ /// <exception cref="ArgumentException">a setting is out of range</exception>
+ 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)";
+ }
+
+ /// <summary>
+ /// A shared client with the default configuration, built on first use.
+ /// </summary>
+ /// <remarks>
+ /// 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.
+ /// </remarks>
+ public static CepisClient Default => SharedDefault.Value;
+
+ /// <summary>Where the API lives, without a trailing slash.</summary>
+ public string BaseUrl => _baseUrl;
+
+ /// <summary>The configuration this client was built with.</summary>
+ public CepisClientOptions Options => _options;
+
+ /// <summary>The <c>User-Agent</c> this client sends.</summary>
+ public string UserAgent { get; }
+
+ /// <summary>
+ /// Return the CEP's eight digits, accepting the mask.
+ /// </summary>
+ /// <remarks>
+ /// <c>"01001-000"</c>, <c>"01001000"</c> and <c>" 01001-000 "</c> all become
+ /// <c>"01001000"</c>. This runs <b>before</b> 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.
+ /// </remarks>
+ /// <param name="value">the CEP, masked or not</param>
+ /// <returns>the eight digits</returns>
+ /// <exception cref="InvalidCepException">unless exactly eight digits remain</exception>
+ 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;
+ }
+
+ /// <summary>
+ /// Look up a single CEP.
+ /// </summary>
+ /// <remarks>
+ /// This is the only call that carries a coordinate. About 57% of CEPs have
+ /// one; when they do not, <see cref="Address.Geo"/> is
+ /// <see langword="null"/>.
+ /// </remarks>
+ /// <param name="cep">the CEP, masked or not</param>
+ /// <param name="cancellationToken">cancels the call, waits included</param>
+ /// <returns>the address</returns>
+ /// <exception cref="InvalidCepException">
+ /// the CEP is not eight digits, in which case no request is made, or the API
+ /// rejected it with a 400
+ /// </exception>
+ /// <exception cref="CepNotFoundException">well-formed, but not in the database</exception>
+ /// <exception cref="RateLimitedException">the API answered 429 on every attempt</exception>
+ /// <exception cref="ServerErrorException">the API answered 5xx on every attempt</exception>
+ /// <exception cref="NetworkException">the request never completed</exception>
+ /// <exception cref="InvalidResponseException">the body cannot be read</exception>
+ public async Task<Address> 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<Address>(reply.Body);
+ }
+
+ /// <summary>
+ /// Look up many CEPs in one batch.
+ /// </summary>
+ /// <remarks>
+ /// <para>
+ /// The returned list has <b>the same length and the same order</b> as the
+ /// input. A CEP that does not exist becomes <see langword="null"/> at its
+ /// position — it does not vanish from the list, and it does not throw. That
+ /// is the right difference from
+ /// <see cref="LookupAsync(string, CancellationToken)"/>: in a batch of a
+ /// thousand, one bad CEP must not cost the other nine hundred and
+ /// ninety-nine.
+ /// </para>
+ /// <para>
+ /// A malformed CEP also becomes <see langword="null"/>, for the same reason.
+ /// If you need to know <i>which</i> one was malformed, validate first with
+ /// <see cref="NormalizeCep(string)"/>.
+ /// </para>
+ /// <para>
+ /// Input longer than <see cref="MaxBatch"/> is chunked into that many CEPs
+ /// per request automatically.
+ /// </para>
+ /// <para>
+ /// <b>Batches carry no coordinate.</b> The endpoint behind them is CSV, and
+ /// the CSV has no <c>geo</c> column. Every address returned here has
+ /// <see cref="Address.Geo"/> <see langword="null"/>, including for CEPs that
+ /// do have one. Need the coordinate? Use
+ /// <see cref="LookupAsync(string, CancellationToken)"/>, one at a time.
+ /// </para>
+ /// </remarks>
+ /// <param name="ceps">the CEPs, masked or not</param>
+ /// <param name="cancellationToken">cancels the call, waits included</param>
+ /// <returns>
+ /// one entry per input CEP, in order, <see langword="null"/> where there is
+ /// no address
+ /// </returns>
+ /// <exception cref="ArgumentNullException"><paramref name="ceps"/> is null</exception>
+ /// <exception cref="RateLimitedException">the API answered 429 on every attempt</exception>
+ /// <exception cref="ServerErrorException">the API answered 5xx on every attempt</exception>
+ /// <exception cref="NetworkException">a request never completed</exception>
+ public async Task<IReadOnlyList<Address?>> LookupManyAsync(
+ IEnumerable<string> ceps,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(ceps);
+
+ var wanted = ceps as IReadOnlyList<string> ?? [.. ceps];
+ if (wanted.Count == 0)
+ {
+ return [];
+ }
+
+ var result = new List<Address?>(wanted.Count);
+ for (var start = 0; start < wanted.Count; start += MaxBatch)
+ {
+ var end = Math.Min(start + MaxBatch, wanted.Count);
+ var chunk = new List<string>(end - start);
+ for (var i = start; i < end; i++)
+ {
+ chunk.Add(wanted[i]);
+ }
+
+ result.AddRange(await LookupChunkAsync(chunk, cancellationToken).ConfigureAwait(false));
+ }
+
+ return result;
+ }
+
+ /// <summary>
+ /// Query <c>GET /health</c>: does the API answer, and with which database.
+ /// </summary>
+ /// <remarks>
+ /// An API reporting itself degraded does not throw — that arrives in
+ /// <see cref="Health.Status"/>, and checking health is exactly when you want
+ /// the diagnosis rather than an exception. Network failures still throw.
+ /// </remarks>
+ /// <param name="cancellationToken">cancels the call, waits included</param>
+ /// <returns>the API's health</returns>
+ /// <exception cref="NetworkException">the request never completed</exception>
+ /// <exception cref="InvalidResponseException">the body cannot be read</exception>
+ public async Task<Health> HealthAsync(CancellationToken cancellationToken = default)
+ {
+ var reply = await RequestAsync("/health", HealthStatus, cancellationToken).ConfigureAwait(false);
+ return Deserialize<Health>(reply.Body);
+ }
+
+ /// <summary>
+ /// Release the <see cref="HttpClient"/> this client created for itself.
+ /// </summary>
+ /// <remarks>
+ /// An <see cref="HttpClient"/> handed in by the caller is left alone.
+ /// </remarks>
+ public void Dispose()
+ {
+ if (_ownsHttp)
+ {
+ _http.Dispose();
+ }
+ }
+
+ /// <summary>
+ /// Parse the <c>Retry-After</c> header in both RFC 9110 forms.
+ /// </summary>
+ /// <remarks>
+ /// It may arrive as a number of seconds (<c>"120"</c>) or as an HTTP date
+ /// (<c>"Wed, 21 Oct 2026 07:28:00 GMT"</c>). Handling only the first is the
+ /// common bug, and it makes a client ignore exactly the wait request that
+ /// matters most.
+ /// </remarks>
+ /// <param name="value">the header value, if any</param>
+ /// <returns>the wait, or null when there is nothing usable</returns>
+ 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<T>(byte[] body)
+ where T : class
+ {
+ T? value;
+ try
+ {
+ value = JsonSerializer.Deserialize<T>(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<byte>(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));
+
+ /// <summary>
+ /// Turn one CSV record into an address, or null when there is none.
+ /// </summary>
+ /// <remarks>
+ /// 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 <c>cep</c> column
+ /// of means "not found".
+ /// </remarks>
+ /// <param name="row">the record, by column name</param>
+ /// <returns>the address, or null</returns>
+ private static Address? AddressFromRow(Dictionary<string, string> row)
+ {
+ if (!row.TryGetValue("localidade", out var localidade) || string.IsNullOrWhiteSpace(localidade))
+ {
+ return null;
+ }
+
+ return JsonSerializer.SerializeToElement(row).Deserialize<Address>(JsonOptions);
+ }
+
+ private static string? FirstHeader(HttpResponseMessage response, string name) =>
+ response.Headers.TryGetValues(name, out var values) ? values.FirstOrDefault() : null;
+
+ /// <summary>
+ /// One chunk of at most <see cref="MaxBatch"/> CEPs, in a single request.
+ /// </summary>
+ /// <remarks>
+ /// 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.
+ /// </remarks>
+ /// <param name="ceps">the chunk</param>
+ /// <param name="cancellationToken">cancels the call</param>
+ /// <returns>one entry per CEP in the chunk, in order</returns>
+ private async Task<List<Address?>> LookupChunkAsync(
+ List<string> 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<Address?>(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;
+ }
+
+ /// <summary>
+ /// Perform the GET, with retries.
+ /// </summary>
+ /// <remarks>
+ /// <para>
+ /// <i>Definitive</i> 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.
+ /// </para>
+ /// <para>
+ /// <see cref="CepisClientOptions.TotalTimeout"/> 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.
+ /// </para>
+ /// </remarks>
+ /// <param name="path">the path, query included</param>
+ /// <param name="acceptedStatus">statuses to hand back rather than retry</param>
+ /// <param name="cancellationToken">cancels the call</param>
+ /// <returns>the status and the body</returns>
+ private async Task<Reply> RequestAsync(
+ string path,
+ FrozenSet<int> 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);
+ }
+
+ /// <summary>
+ /// Wait out the backoff, reporting whether there is still budget to retry.
+ /// </summary>
+ /// <remarks>
+ /// The wait is cut short by <paramref name="callToken"/>, so a
+ /// <see cref="CepisClientOptions.TotalTimeout"/> that expires mid-wait ends
+ /// the call rather than being overrun. A cancellation the caller asked for
+ /// propagates instead.
+ /// </remarks>
+ /// <param name="attempt">which attempt just failed, from one</param>
+ /// <param name="retryAfter">what the server asked for, if anything</param>
+ /// <param name="callToken">the deadline, or the caller's token</param>
+ /// <param name="cancellationToken">the caller's token alone</param>
+ /// <returns>false when the budget is spent</returns>
+ private async Task<bool> 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;
+ }
+ }
+
+ /// <summary>
+ /// How long to wait before the next attempt.
+ /// </summary>
+ /// <remarks>
+ /// The server's <c>Retry-After</c> wins over the local calculation when
+ /// present: it knows when it will be back, we are guessing. It still goes
+ /// through the <see cref="CepisClientOptions.BackoffMax"/> cap. Otherwise
+ /// the wait is drawn from <c>[0, ceiling]</c> rather than being the ceiling.
+ /// </remarks>
+ /// <param name="attempt">which attempt just failed, from one</param>
+ /// <param name="retryAfter">what the server asked for, if anything</param>
+ /// <returns>the wait</returns>
+ 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);
+}