Lesson 16 of 45 intermediate

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?

Open interactive version (quiz + challenge)

Real-world analogy

Think of ordering from a supplier by phone. You reuse the same phone line instead of installing a new one for each call. You hang up if nobody answers after a while, and you only redial for calls that are safe to repeat. For a payment you quote an order number, so a second call cannot charge twice. Here the phone line is the pooled connection and hanging up is the timeout. The order number is the idempotency key.

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

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. 1. AddHttpClient<PaymentClient> registers a typed client. The factory gives each PaymentClient a managed HttpClient with the base address.
  2. 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. 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. 4. ChargeAsync builds a POST to "charges" and adds an Idempotency-Key header, so a retried request cannot charge twice.
  5. 5. SendAsync passes the CancellationToken. EnsureSuccessStatusCode throws if the provider replied with an error code.
  6. 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?
Disposing the client does not close its sockets immediately. What does the loop create each round?
Show answer
A new HttpClient per iteration opens a new connection each time, and the closed sockets linger, so under load the app runs out of ports. Fix: register a typed client with AddHttpClient and inject it, so connections are pooled and reused.

Explain like I'm 5

When you call a shop, you use the same phone each time. You hang up if no one answers, and you only call again if calling twice is harmless. For buying, you give an order number so they never charge you twice.

Fun fact

A closed socket does not vanish at once. The operating system keeps it in a waiting state for a while. A loop creating HttpClient objects can run out of ports even though each one was disposed.

Hands-on challenge

Say it out loud in 30 seconds: "How do you call a third-party payment API safely from ASP.NET Core?" Then check: did you mention IHttpClientFactory, timeouts, retries only for safe calls, idempotency keys, and verifying webhook signatures?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook