Polly retry policy for an HttpClient POST to a paid API

A Polly retry for a paid POST: handle only transient failures, back off exponentially with jitter, honor Retry-After, and resend one idempotency key.

5 min readSume
All posts

A Polly retry policy for an HttpClient POST is a RetryStrategyOptions<HttpResponseMessage> on a resilience pipeline: set ShouldHandle to match only transient failures, use BackoffType = DelayBackoffType.Exponential with UseJitter = true, keep MaxRetryAttempts small, and read Retry-After in a DelayGenerator. For a POST that costs money, build a fresh HttpRequestMessage on every attempt but send the same idempotency key each time.

Polly facts come from its Retry resilience strategy page; .NET facts from Microsoft Learn's SendAsync, Timeout and RetryAfter pages. The example API's retry rules come from Sume's Errors and spend and Create a run pages. All were read on 2026-09-29. For the plain call without retries, see C# HttpClient POST JSON with a Bearer token.

What does Polly's retry do by default?

Less than a paid POST needs. The defaults handle exceptions only, so a 429 or 503 response comes back to you without a retry:

  • A timed-out request isn't retried by default. On .NET 5 and later, a timeout surfaces as an OperationCanceledException that nests a TimeoutException, the type the default predicate skips. HttpClient.Timeout defaults to 100 seconds.
  • With Exponential and UseJitter, Polly uses a decorrelated jitter formula. MaxDelay caps the delay, but not one returned by a DelayGenerator.
  • A DelayGenerator that returns null falls back to the computed backoff, so returning Headers.RetryAfter?.Delta waits for the server's Retry-After when there is one and backs off normally when there isn't.
From Polly's Retry resilience strategy defaults table, read 2026-09-29.
PropertyDefault
ShouldHandleAny exception other than OperationCanceledException
MaxRetryAttempts3, in addition to the original call
BackoffTypeConstant
Delay2 seconds
MaxDelaynull (no cap)
UseJitterfalse
DelayGeneratornull

Which responses should the policy retry?

Only answers where resending the same request can succeed. For Sume's run create, that is 409 idempotency_key_in_use (wait about a second), 429 rate_limited (wait retry-after) and 503 studio_agent_upstream_unavailable (retry with the same key). Don't retry 409 idempotency_conflict, 402, 401, 403 or 404, and not every 5xx: 502 attachment_fetch_failed means an input URL couldn't be fetched. A 4xx at create means nothing ran and nothing was charged. Retryable HTTP status codes covers the general rules.

How do I write the Polly retry in C#?

SendAsync refuses a request message the client already sent, so create the message inside the callback. The key is created once, outside it:

using System.Net;
using System.Net.Http.Json;
using Polly;
using Polly.Retry;
var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SUME_API_KEY")}");
var key = "order-8823-promo-v1"; // one key for every attempt
var body = new { instruction = "15-second vertical promo." };
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
    .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        MaxRetryAttempts = 4,
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,
        Delay = TimeSpan.FromSeconds(1),
        MaxDelay = TimeSpan.FromSeconds(60),
        ShouldHandle = async args => args.Outcome.Exception is HttpRequestException
            || args.Outcome.Result is { StatusCode: HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable }
            || (args.Outcome.Result is { StatusCode: HttpStatusCode.Conflict } r
                && (await r.Content.ReadAsStringAsync()).Contains("idempotency_key_in_use")),
        DelayGenerator = args => new ValueTask<TimeSpan?>(args.Outcome.Result?.Headers.RetryAfter?.Delta),
    })
    .Build();
var response = await pipeline.ExecuteAsync(async token =>
{
    var request = new HttpRequestMessage(HttpMethod.Post, "https://api.sume.com/v1/formats/acme/product-promo/runs")
        { Content = JsonContent.Create(body) };
    request.Headers.Add("Idempotency-Key", key);
    return await http.SendAsync(request, token);
});

Why reuse the idempotency key across attempts?

Because the retry exists for the case where you can't tell whether the server acted. With the same key and the same body, Sume answers 200 with the original run and idempotency_hit: true: no second run, no second charge. A new key per attempt starts a new paid run each time; Sume's docs say a per-request UUID makes the header decorative. Derive it from the order and a version you bump for a deliberate re-run.

If you add timeouts to ShouldHandle, the same rule makes that retry safe. Once you hold a 202, stop retrying the create: poll the run or wait for its webhook. A run that later ends failed needs a new key.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume