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.
- Runway MCP for Grok Bot: what shipped and what Sume lists
Runway added an MCP plugin to Grok Bot on Sep 17, 2026 for paid plans. Sume's docs do not list Grok Bot; here is how to test whether any client can use its MCP.
- Runway MCP in Cursor: what shipped and running a second server
Runway MCP reached Cursor's Marketplace on Sep 23, 2026. Cursor can hold more than one MCP server; here is how a second, Sume's hosted MCP, is added and scoped.
Written by Sume