Building resilient HTTP API clients in C#
Posted: (EET/GMT+2)
HTTP APIs are everywhere these says, and calling one is straightforward with pretty much any programming language. If you are using C# to write code that calls an HTTP API, treat that API as something that can fail in several different ways.
A resilient client does not only check whether the request returned the HTTP status code 200 OK. It decides which responses are expected, which ones are temporary, and which ones should be shown clearly to the user.
In .NET, start by using IHttpClientFactory instead of creating new HttpClient instances manually. For example:
builder.Services.AddHttpClient<ProductsClient>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.Timeout = TimeSpan.FromSeconds(10);
});
Then put the API-specific logic into a small client class:
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
public sealed class ProductsClient(HttpClient httpClient)
{
public async Task<Product?> GetProductAsync(
int id, CancellationToken cancellationToken)
{
using HttpResponseMessage response =
await httpClient.GetAsync($"api/products/{id}",
cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (response.StatusCode == HttpStatusCode.TooManyRequests ||
response.StatusCode == HttpStatusCode.ServiceUnavailable)
{
throw new TemporaryApiException(
$"Product API is temporarily unavailable: {(int)response.StatusCode}");
}
if (!response.IsSuccessStatusCode)
{
throw new ApiException(
$"Product API returned HTTP {(int)response.StatusCode}");
}
try
{
return await response.Content.ReadFromJsonAsync<Product>(
cancellationToken);
}
catch (JsonException ex)
{
throw new ApiException(
"Product API returned JSON in an unexpected format.",
ex);
}
}
}
public sealed record Product(int Id, string Name);
public sealed class ApiException(string message, Exception? innerException = null)
: Exception(message, innerException);
public sealed class TemporaryApiException(string message)
: Exception(message);
This makes the behavior explicit:
404 Not Foundmeans the product does not exist429 Too Many Requestsmay be temporary503 Service Unavailablemay be temporary- unexpected responses become application errors
- invalid JSON is handled separately from HTTP errors
Keep in mind that not every non-200 response is an error. For example, 201 Created, 202 Accepted, and 204 No Content are also successful responses.
For modern .NET applications, you can also add the HTTP resilience package:
dotnet add package Microsoft.Extensions.Http.Resilience
Then add a standard resilience handler:
builder.Services
.AddHttpClient<ProductsClient>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
})
.AddStandardResilienceHandler();
This gives you common resilience behavior such as retries, timeouts, and circuit breaking. Retries are useful for temporary failures, but they are not always safe.
When do you know if it's safe to retry? Usually safe to retry are:
- any
GETrequests - temporary network failures
429 Too Many Requests503 Service Unavailable.
Be careful with:
POSTrequests that create data- payments
- orders
- email sending
- commands that change system state.
If a retry can create duplicate work, use an idempotency key or do not retry automatically. Also think about the user-facing error message. The user usually does not need the raw HTTP status code or stack trace:
The product service is temporarily unavailable. Please try again in a moment.
But the log should still contain enough information for troubleshooting:
- HTTP status code
- request URL or operation name
- correlation ID
- elapsed time
- exception details (but no credentials!).
Good questions to ask when writing an API client:
- which status codes are expected?
- which errors are temporary?
- which requests are safe to retry?
- what timeout is acceptable for the user?
- what happens if the JSON shape changes?
- what should be logged for support?
A resilient HTTP client is not complicated code. It is mostly explicit decisions.
Hope this helps!