Skip to content

HCR043

Unsafe HTTP methods should not be retried by custom resilience pipelines.

Why

AddResilienceHandler is the common way to build a named Polly pipeline on an HttpClient. AddRetry(new HttpRetryStrategyOptions()) still retries POST, PUT, PATCH, DELETE, and CONNECT unless DisableForUnsafeHttpMethods() or a safe-method-only ShouldHandle predicate is configured. Retrying those methods can duplicate side effects—charges, orders, mutations—unless the operation is explicitly idempotent.

HCR041 covers AddStandardResilienceHandler(). This rule covers the custom pipeline shape that HCR041 does not see.

Bad

services.AddHttpClient<PaymentsClient>()
    .AddResilienceHandler("payments", builder =>
    {
        builder.AddRetry(new HttpRetryStrategyOptions());
    });

public sealed class PaymentsClient(HttpClient httpClient)
{
    public Task<HttpResponseMessage> CreateAsync(CancellationToken cancellationToken)
    {
        return httpClient.PostAsync("/payments", null, cancellationToken);
    }
}

Better

services.AddHttpClient<PaymentsClient>()
    .AddResilienceHandler("payments", builder =>
    {
        var retryOptions = new HttpRetryStrategyOptions();
        retryOptions.DisableForUnsafeHttpMethods();
        builder.AddRetry(retryOptions);
    });
services.AddHttpClient<PaymentsClient>()
    .AddResilienceHandler("payments", builder =>
    {
        builder.AddRetry(new HttpRetryStrategyOptions
        {
            ShouldHandle = args =>
                args.Outcome.Result?.RequestMessage?.Method == HttpMethod.Get
        });
    });

A timeout-only or circuit-breaker-only pipeline does not retry, so this rule stays quiet when AddRetry is absent.

Current Detection

The implementation reports:

  • Framework AddResilienceHandler registrations (Microsoft.Extensions.DependencyInjection, with global-namespace stubs supported for lightweight source tests) whose configure callback is an inline lambda, anonymous method, or local function in the same method and contains a framework AddRetry (Polly or global namespace) whose receiver is the pipeline builder parameter or a fluent chain from it (AddTimeout(...).AddRetry(...)).
  • Typed-client IServiceCollection-shaped registrations, including AddHttpClient<TService, TImplementation>() and registrations split through a visible unreassigned IHttpClientBuilder local, when the implementation class visibly sends an unsafe HTTP method.
  • Named-client registrations such as AddHttpClient("payments") or AddHttpClient(ClientNames.Payments) when visible code calls CreateClient with the same literal or compile-time constant and then sends an unsafe method.
  • Send / SendAsync request shapes with literal or constant unsafe HttpMethod values, including CONNECT. Unsafe methods are POST, PUT, PATCH, DELETE, and CONNECT.

The diagnostic is placed on the AddRetry name token. It uses the same compilation-wide unsafe-call index as HCR041 and HCR042, built once per compilation and only after a framework AddResilienceHandler with a visible builder-bound AddRetry is paired with a typed or named client.

It does not report when:

  • the callback has no builder-bound AddRetry (timeout, circuit breaker, or rate limiter only);
  • DisableForUnsafeHttpMethods() is invoked on the retry options in the callback (Microsoft.Extensions.Http.Resilience or global);
  • a visible Polly.Retry ShouldHandle assignment is composed exclusively from safe HttpMethod checks;
  • MaxRetryAttempts is a literal 0 on the retry options passed to that AddRetry;
  • AddRetry is invoked on a different pipeline builder than the callback parameter;
  • the configure argument is a method group on another type;
  • lookalike custom AddResilienceHandler / AddRetry / DisableForUnsafeHttpMethods APIs are used;
  • no visible unsafe typed-client or named-client call exists.

It does not infer retries from static ResiliencePipelineBuilder instances that are not wired through AddResilienceHandler, or from configuration-driven / mutable method names.

Code Fix

When AddRetry is passed new HttpRetryStrategyOptions { ... }, the code fix introduces a local, calls DisableForUnsafeHttpMethods(), and passes that local to AddRetry. Expression lambdas are converted to blocks. The fix is withheld when the argument is not an object creation of HttpRetryStrategyOptions (including generic RetryStrategyOptions<T> and existing locals), so an existing retry policy is never overwritten.

Suppression

Suppress only when the endpoint is idempotent by contract, the request uses idempotency keys, or retry predicates exclude unsafe methods in a way the analyzer cannot model.

References