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.

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
OperationCanceledExceptionthat nests aTimeoutException, the type the default predicate skips.HttpClient.Timeoutdefaults to 100 seconds. - With
ExponentialandUseJitter, Polly uses a decorrelated jitter formula.MaxDelaycaps the delay, but not one returned by aDelayGenerator. - A
DelayGeneratorthat returnsnullfalls back to the computed backoff, so returningHeaders.RetryAfter?.Deltawaits for the server'sRetry-Afterwhen there is one and backs off normally when there isn't.
| Property | Default |
|---|---|
ShouldHandle | Any exception other than OperationCanceledException |
MaxRetryAttempts | 3, in addition to the original call |
BackoffType | Constant |
Delay | 2 seconds |
MaxDelay | null (no cap) |
UseJitter | false |
DelayGenerator | null |
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
- Power Automate HTTP Webhook action: wait for a callback
The HTTP Webhook action sends a subscribe request with the flow's callback URL, then pauses until something POSTs to it. How to use it with a slow API.
- Spring Boot RestTemplate POST JSON with a Bearer token
Set JSON content type and setBearerAuth on HttpHeaders, wrap them in an HttpEntity, call postForEntity, and catch the 4xx exception to read the body.
- Salesforce Apex HTTP callout: POST JSON to an external API
An Apex HTTP callout builds an HttpRequest, sends it with Http.send and reads the HttpResponse. From a trigger or after DML, it must run asynchronously.
- Spring Retry and @Retryable for a paid API call in Spring 7
Spring Retry is archived; Spring Framework 7 has @Retryable in core. For a paid API call, narrow the exceptions, back off, and pass one idempotency key in.
Written by Sume