Every outbound call needs a timeout

ยท 2 min read

The default HttpClient timeout is 100 seconds, far longer than anyone waiting on you. Set timeouts deliberately, layer them so they make sense together, and pass cancellation through.

A dependency doesn't have to fail to take you down. It only has to get slow. If it stops responding and your calls to it wait 100 seconds each, requests pile up, threads and connections run out, and your service stops answering too. A timeout turns "hangs forever" into a fast, handled failure.

Know your defaults

CallDefault timeout
HttpClient request100 seconds
SQL Server command (ADO.NET and EF Core)30 seconds
SQL Server connection15 seconds
Azure SDK operation (e.g. Service Bus TryTimeout)60 seconds

Those are generous fallbacks, not good choices. If your own API promises a response in 5 seconds, a 100-second call inside it makes no sense.

Work backwards from the caller

Start with how long the caller of your service will wait, and budget from there. If an API endpoint should respond within 10 seconds and calls two dependencies in sequence, each call gets well under 5 seconds, including any retries.

The rule: every timeout must be shorter than the one waiting on it. Otherwise your caller gives up, but your service keeps working on a response nobody will read.

Layer the timeouts for HTTP

With the resilience handler there are two timeouts that matter:

  • Attempt timeout: how long a single try may take.
  • Total timeout: how long the whole operation, including retries and their delays, may take.
builder.Services.AddHttpClient<PricingClient>()
    .AddStandardResilienceHandler(options =>
    {
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(6);
        options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(10); // must be at least twice the attempt timeout
    });

Watch out for HttpClient.Timeout itself. It wraps the entire handler pipeline, retries included, so if it's shorter than the total resilience timeout it cuts the retries off. With a resilience handler, leave it at its default or set it higher than the total timeout, and let the handler do the timing.

Pass cancellation through

A timeout in one place should stop work everywhere below it. Accept a CancellationToken and pass it to every async call, so when a request times out or the caller disconnects, the database query and downstream calls are cancelled too, rather than finishing for nobody.

For your own deadline inside a method:

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3));

var prices = await pricing.GetPricesAsync(skus, cts.Token);

Don't forget the database

EF Core's command timeout applies per command. For a known slow report, raise it for that query only, rather than globally:

db.Database.SetCommandTimeout(TimeSpan.FromMinutes(2));

And if a normal request regularly needs more than a few seconds of database time, the fix is usually an index or a different query, not a longer timeout.

Takeaway

Never rely on default timeouts. Budget from what your caller will wait, make each inner timeout shorter than the one around it, set attempt and total timeouts on HTTP calls, and pass cancellation tokens all the way down.