Calling Third-Party APIs Safely
Other people's servers are slow, flaky and change without asking
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
What is the main problem with creating new HttpClient() for every outgoing call?
Real-world analogy
What is it?
Calling an outside API means depending on a system you do not control. It can be slow, return errors, or change its response. Safe integration wraps each call in a typed client built from IHttpClientFactory, adds timeouts and resilience policies, and keeps the provider's data model out of your core code. For money and other side effects, you add idempotency so a retry cannot repeat the action.
Real-world relevance
A checkout service calls a payment provider. Under load the provider answers slowly and waiting requests pile up. Connections and memory run out, so your whole site hangs because of someone else. Timeouts, a circuit breaker and an idempotency key turn this into a clear, short failure with no double charge. Teams also keep a thin client class per provider, which makes switching providers or faking them in tests simple.
Key points
- Use IHttpClientFactory — It manages connection pools and rotates them over time. A new HttpClient() per call opens fresh sockets that linger and can exhaust the machine.
- Typed clients — Wrap one external API in a class that takes an HttpClient in its constructor. Register it with AddHttpClient<T> and inject the class where needed.
- Set a timeout — The default HttpClient timeout is 100 seconds. A slow partner makes your waiting requests pile up. Set an attempt timeout and a total timeout on every outside call.
- Retry with backoff — Retry transient failures such as a 503 or a dropped connection, with growing waits and some jitter. Immediate retries only add load to a struggling server.
- Retry only safe calls — Retrying a GET is harmless. Retrying a POST that creates something can duplicate it unless the operation is idempotent. The standard handler retries every method, so call options.Retry.DisableForUnsafeHttpMethods() when calls are not idempotent.
- Circuit breaker — When too many recent calls fail, it stops calling the partner for a short time. Your app fails fast, and the partner gets room to recover.
- Idempotency keys — Send a unique key with each payment request, for example in an Idempotency-Key header. The provider returns the first result for repeated keys instead of charging twice.
- Verify webhook signatures — A webhook is a public URL, so anyone can post to it. Check the provider's signature header with your shared secret before trusting the body. Also check its timestamp, so an old captured request cannot be replayed.
- Map their model to yours — Convert the provider's JSON into your own small types at the edge. When they rename a field, only one mapping changes.
Quick check
Answer from memory — scrolling up first makes it stick less.
When is it safe to retry a failed request automatically?
What does a circuit breaker do?
Code example
builder.Services.AddHttpClient<PaymentClient>(client =>
client.BaseAddress = new Uri("https://api.payments.example.com/"))
.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(20);
}); // retry, circuit breaker and timeouts in one handler
public record ChargeRequest(decimal Amount, string Currency);
public record PaymentResult(string Id, bool Succeeded);
public record ProviderChargeDto(string Id, string Status); // their shape, kept at the edge
public class PaymentClient
{
private readonly HttpClient _http;
public PaymentClient(HttpClient http) => _http = http;
public async Task<PaymentResult> ChargeAsync(
ChargeRequest request, string idempotencyKey, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Post, "charges")
{
Content = JsonContent.Create(request)
};
message.Headers.Add("Idempotency-Key", idempotencyKey); // safe to retry
using var response = await _http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<ProviderChargeDto>(ct);
return new PaymentResult(dto!.Id, dto.Status == "succeeded");
}
}Line-by-line walkthrough
- 1. AddHttpClient<PaymentClient> registers a typed client. The factory gives each PaymentClient a managed HttpClient with the base address.
- 2. AddStandardResilienceHandler, from the Microsoft.Extensions.Http.Resilience package, adds retry, circuit breaker and timeouts. Here each attempt gets 5 seconds and the whole call gets 20.
- 3. The three records keep the shapes small. ProviderChargeDto is their format, and PaymentResult is the only type the rest of your app sees.
- 4. ChargeAsync builds a POST to "charges" and adds an Idempotency-Key header, so a retried request cannot charge twice.
- 5. SendAsync passes the CancellationToken. EnsureSuccessStatusCode throws if the provider replied with an error code.
- 6. The response is read into the provider DTO and mapped to PaymentResult before it leaves the class.
Spot the bug
foreach (var order in orders)
{
using var client = new HttpClient();
var response = await client.PostAsJsonAsync("https://api.example.com/notify", order);
response.EnsureSuccessStatusCode();
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Make HTTP requests using IHttpClientFactory (Microsoft Learn)
- Build resilient HTTP apps (Microsoft Learn)
Lock it in