Integration guide

Retry and backoff for payment APIs in .NET

Which payment HTTP failures are safe to retry, why a timeout is not one of them, and how backoff without an idempotency key charges the customer twice.

Retrying a payment call is how you recover from a dead connection. It is also how you take the money twice. The difference is not the backoff curve. It is whether you know the first attempt did not happen.

This sits next to idempotency keys. Backoff decides when to call again. The key decides whether that second call is a new charge.

What is safe to send again

Response Retry? Why
Network error before any byte was written Yes The gateway never saw the request.
408, 429, 500, 502, 503, 504 Yes, with a key They may have processed it anyway. The key makes the replay safe.
Timeout, no response Only with a key The charge may have succeeded. A bare retry is a second charge.
400, 401, 403, 404, 422 No The request is wrong. Repeating it will be wrong again.
402, card declined No The issuer answered. Retrying is not going to change the card.

The timeout row is the one teams get wrong, because a timeout feels like “it failed”. A timeout means you stopped waiting. The gateway may have authorised the payment and been unable to tell you.

If the gateway has no idempotency key, do not retry a timeout automatically. Reconcile. Look the payment up by the reference you sent, if they let you, or wait for the webhook. A human can refund a duplicate. An automatic retry loop will create one every time the gateway is slow.

Backoff that does not stampede

Exponential delay, plus jitter, plus a cap. Without jitter, every failed client retries on the same second and the gateway stays down.

Honour Retry-After when it is present. Your curve is a guess; the header is the server telling you when it will accept work.

public static TimeSpan Delay(int attempt, TimeSpan? retryAfter, TimeSpan cap)
{
    if (retryAfter is { } server && server > TimeSpan.Zero)
        return server < cap ? server : cap;

    // Full jitter: sleep a random fraction of the exponential window.
    var ceilingMs = Math.Min(cap.TotalMilliseconds, 200 * Math.Pow(2, attempt));
    var ms = Random.Shared.Next(0, (int)ceilingMs + 1);
    return TimeSpan.FromMilliseconds(ms);
}

Stop. Five attempts is already a long time in a checkout. An unbounded loop behind a user who has closed the page is how you hammer a gateway at 3am with a card they no longer want charged.

public async Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request,
    string idempotencyKey,
    CancellationToken ct)
{
    const int maxAttempts = 5;

    for (var attempt = 0; attempt < maxAttempts; attempt++)
    {
        using var clone = Clone(request);
        clone.Headers.TryAddWithoutValidation("Idempotency-Key", idempotencyKey);

        try
        {
            var response = await http.SendAsync(clone, ct);
            if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts - 1)
                return response;

            var retryAfter = response.Headers.RetryAfter?.Delta;
            await Task.Delay(Delay(attempt, retryAfter, TimeSpan.FromSeconds(8)), ct);
        }
        catch (HttpRequestException) when (attempt < maxAttempts - 1)
        {
            await Task.Delay(Delay(attempt, null, TimeSpan.FromSeconds(8)), ct);
        }
    }

    throw new InvalidOperationException("unreachable");
}

private static bool ShouldRetry(HttpStatusCode status) => status is
    HttpStatusCode.RequestTimeout or
    HttpStatusCode.TooManyRequests or
    HttpStatusCode.InternalServerError or
    HttpStatusCode.BadGateway or
    HttpStatusCode.ServiceUnavailable or
    HttpStatusCode.GatewayTimeout;

Clone matters. HttpRequestMessage cannot be sent twice. If you reuse it, the second attempt throws, the retry never happens, and the log looks like a gateway bug.

A timeout is not in that catch. TaskCanceledException from HttpClient.Timeout must not fall into the same path unless the gateway call carries a key you have already sent on the first attempt and you are sure the gateway dedupes on it. If you are not sure, stop and reconcile.

What a published rate limit does not decide

Stripe documents idempotent replay and publishes Retry-After behaviour on 429s in its error docs. That is a reason to honour the header, not a reason to copy a single numeric schedule onto Tap, Telr or Moyasar. If the provider does not publish a limit, the cap above is a client-side safety limit. It is not a measurement of their capacity.

The same applies in reverse to webhooks they send you. Their retry schedule, where they publish one, tells you how long you have to ack. It does not mean your outbound calls should use the same delays. Inbound ordering is the subject of out-of-order webhooks.

Failure modes

Symptom Cause Fix
Duplicate captures after a slow day Timeout retried with a new key, or no key Same key as the first attempt, or no automatic retry
400s in a loop Retry policy matches every non-2xx Retry only the statuses in the table
Thundering herd when the gateway returns Identical exponential delays Jitter
Second attempt throws InvalidOperationException Reused HttpRequestMessage Clone per attempt
Checkout spinner for minutes No attempt cap Cap, then surface the error

Production checklist

  • Idempotency key on the first attempt, reused on every retry
  • No automatic retry of timeouts unless that key is known to be honoured
  • No retry of 4xx except 408 and 429
  • Jitter, a cap, and Retry-After when present
  • A new HttpRequestMessage per attempt
  • Metric: retries by status code, so a bad deploy is visible as a 400 loop rather than “the gateway is down”

Sources

Those two pages are the reference behaviour. They are not a description of every gateway you will call. Where the provider’s docs are silent, the safe default is the timeout row in the table: do not retry, go and look the payment up.