Register one typed HttpClient for exchangerate.dev, inject the API key from configuration, and deserialize rate values to decimal. Keep cancellation and timeout behavior explicit so a slow upstream request cannot hold an application request open indefinitely.
IHttpClientFactory for connection reuse, DNS refresh, logging, and central client configuration.decimal and retain the API freshness metadata alongside them.CancellationToken through every request and set a bounded client timeout.EnsureSuccessStatusCode() before decoding so failed responses never look like valid rate data.Register a typed HttpClient
The registration owns the base URL, bearer token, and timeout. The typed client owns the response contract and passes cancellation from the caller to the network request.
// Program.cs
using System.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<FxClient>(client =>
{
var key = builder.Configuration["EXCHANGERATE_API_KEY"]
?? throw new InvalidOperationException("EXCHANGERATE_API_KEY is missing");
client.BaseAddress = new Uri("https://api.exchangerate.dev/v1/");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", key);
client.Timeout = TimeSpan.FromSeconds(5);
});
// FxClient.cs
using System.Net.Http.Json;
using System.Text.Json.Serialization;
public sealed record LatestRates(
string Base,
string Source,
[property: JsonPropertyName("market_session")] string MarketSession,
[property: JsonPropertyName("data_updated_at")] DateTimeOffset DataUpdatedAt,
Dictionary<string, decimal> Rates
);
public sealed class FxClient(HttpClient http)
{
public async Task<LatestRates> GetLatestAsync(
string baseCurrency,
CancellationToken cancellationToken)
{
using var response = await http.GetAsync(
$"latest/{baseCurrency}?symbols=EUR,GBP",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LatestRates>(
cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Empty FX response");
}
}Pass cancellation from the request
var app = builder.Build();
app.MapGet("/fx", async (
FxClient fx,
CancellationToken cancellationToken) =>
{
var rates = await fx.GetLatestAsync("USD", cancellationToken);
return Results.Ok(new {
eur = rates.Rates["EUR"],
rates.Source,
rates.MarketSession,
rates.DataUpdatedAt,
});
});
app.Run();Cache successful observations only
A shared cache can absorb repeated reads, but the cache entry should contain the complete observation rather than only Rates["EUR"]. Keep Source, MarketSession, and DataUpdatedAt so the caller can distinguish a live trading-day update from a daily or closed-market value.
- Keep one typed client registration.
- Use decimal for rates and amounts.
- Pass cancellation through the full call chain.
- Reject non-success HTTP responses before deserialization.
