Good to see you again. In the previous lesson, you implemented a resource-oriented POST /api/portfolios endpoint with validation, 201 Created, and Problem Details errors. That endpoint can now accept a portfolio, but a useful portfolio tracker must sometimes cross a much less reliable boundary: an external market-price provider.
This lesson adds that boundary in a controlled way. You will build a typed market-price client backed by IHttpClientFactory, give it a deliberately chosen timeout, propagate request cancellation, and keep provider-specific HTTP details out of your portfolio application service. Plan for about 40–45 minutes.
Why outbound HTTP needs an explicit design
Calling a database inside your own application is already an infrastructure dependency. Calling a market-price API adds further uncertainty:
- DNS can resolve slowly or point to a changed address.
- The provider can be unavailable, overloaded, or rate-limiting requests.
- A response can be valid HTTP but contain an unexpected payload.
- A request can wait long enough to consume ASP.NET Core request threads, connection slots, and user patience.
The weak implementation is familiar:
public async Task<decimal> GetPriceAsync(string symbol)
{
using var client = new HttpClient();
var response = await client.GetStringAsync(
$"https://provider.example/prices/{symbol}");
return decimal.Parse(response);
}
It has several problems:
- It creates a fresh
HttpClientand connection pool repeatedly. - The external URL, request shape, and parsing logic leak into application code.
- It has no intentional timeout choice.
- It cannot cleanly distinguish “symbol not found,” “provider returned an error,” and “the caller cancelled.”
- It is difficult to test without reaching the real provider.
IHttpClientFactory addresses the first concern by managing and pooling underlying message handlers. A typed client addresses the rest by putting all knowledge of one external HTTP API behind a focused C# interface and implementation.
The RIGHT Way To Use HttpClient In .NET
Watch The RIGHT Way To Use HttpClient In .NET by Milan Jovanović for a compact visual explanation of why repeated direct HttpClient construction is risky and how a typed client encapsulates a provider integration.
Watch the connection problem to see why creating clients repeatedly can exhaust available sockets under load. Then watch typed clients for registration, consumption, and the important warning about injecting a typed client into a singleton.
A detail from your earlier DI-lifetime lesson matters here: AddHttpClient<IMarketPriceClient, MarketPriceClient>() registers the typed client as transient. It is appropriate to inject it into a controller or scoped application service. Do not capture it inside a singleton such as a cache singleton or long-lived worker. Later, when you build hosted services, you will create scopes or use a factory-oriented design instead.
The factory, typed clients, and handler pooling
There are three common IHttpClientFactory patterns:
| Pattern | Consumer receives | Good fit |
|---|---|---|
| Basic factory | IHttpClientFactory | A small number of dynamic or ad hoc client configurations |
| Named client | IHttpClientFactory, then CreateClient("name") | Several configurations where a name is a useful selection mechanism |
| Typed client | A domain-specific service such as IMarketPriceClient | A stable external dependency with its own API operations and models |
For the capstone, a typed client is the clearest choice. Portfolio valuation code should ask:
await marketPriceClient.GetLatestAsync(symbol, cancellationToken);
It should not know:
- the provider's URL structure;
- its authorization or accepted media-type headers;
- its JSON property names;
- its status-code conventions;
- its timeout budget.
This is not just abstraction for its own sake. If you replace a provider, revise its response schema, or introduce a test double, the change stays at one integration boundary.
HTTP requests with IHttpClientFactory - ASP.NET Core | Microsoft Learn
Read the relevant sections of Microsoft Learn’s HTTP requests with IHttpClientFactory. They explain why typed clients fit dependency injection cleanly and how the factory manages connection-related resources beneath the client instance.
In the “Typed clients” section, first read the benefits. Then read the remainder of that subsection, including the constructor example, the AddHttpClient registration, and direct injection of the typed client. Focus on the distinction between a typed integration service and a raw HttpClient. Next, in “HttpClient and lifetime management,” read from the explanation of new client instances through the handler-pooling discussion. In particular, study handler pooling. The key point is that factory-created HttpClient objects are inexpensive wrappers, while the factory reuses handlers and their connection pools for a controlled period.
A precise interview statement is:
I use
IHttpClientFactoryrather than creating a newHttpClientper request because it manages handler and connection-pool lifetimes. For a stable external API, I prefer a typed client: it centralizes the provider's base address, headers, HTTP behavior, and DTO mapping behind a domain-specific interface.
Define the market-price boundary
Assume the capstone needs the latest quote for a symbol such as MSFT. Keep the provider's DTO separate from the value your application uses.
public sealed record MarketPriceQuote(
string Symbol,
decimal Price,
string Currency,
DateTimeOffset AsOfUtc);
public interface IMarketPriceClient
{
Task<MarketPriceQuote?> GetLatestAsync(
string symbol,
CancellationToken cancellationToken);
}
Returning null here has a specific meaning: the provider successfully handled the request but has no quote for that symbol, represented by 404 Not Found in this example. It does not mean that the provider timed out or returned 500.
Now model the provider’s payload separately:
internal sealed record ProviderPriceResponse(
decimal Price,
string Currency,
DateTimeOffset AsOfUtc);
This boundary is worthwhile even if the provider currently returns nearly the same shape. External APIs change independently of your own API. You do not want a provider changing price to lastPrice to ripple through portfolio controllers, Angular models, and internal calculation code.
Set a timeout deliberately
A timeout is a statement of policy: how long is this operation useful enough to keep waiting?
HttpClient has a default timeout of 100 seconds. For a market-price lookup performed during a user request, 100 seconds is almost never reasonable. It can cause requests to accumulate while the external dependency is degraded.
For this capstone, set a 2-second timeout. This is an illustrative starting point, not a universal constant. A sensible real-world choice follows the user-facing latency budget:
- If the portfolio-summary endpoint should complete within 3 seconds, it cannot give 3 seconds entirely to one downstream provider.
- The API needs time for its own validation, database work, serialization, and a safe error response.
- A 2-second quote timeout leaves some budget for those activities.
Use one clear timeout mechanism at this stage:
client.Timeout = TimeSpan.FromSeconds(2);
Do not add retries yet. A GET quote request is normally safe to retry from an HTTP-semantics perspective, but retrying can still increase latency, provider load, and quota consumption. In the resilience module, you will add a bounded retry and circuit breaker only after defining how failures should behave.
Timeout versus request cancellation
These two signals are related but different:
| Event | Meaning | Correct client behavior |
|---|---|---|
| Request cancellation token is cancelled | The browser disconnected, Angular cancelled navigation, or the server is shutting down | Stop work promptly and let cancellation propagate |
| Client timeout expires | The dependency took longer than the configured budget | Treat it as a provider failure; report or map it at the application boundary |
Provider returns 404 | The provider responded successfully but has no known quote | Return the client’s defined “no quote” result |
Provider returns 500 or 429 | The provider responded with an error or rate limit | Preserve an infrastructure failure for the caller to handle |
In .NET, an HttpClient timeout commonly surfaces as an OperationCanceledException or TaskCanceledException, not necessarily as a TimeoutException. Therefore, if you translate timeout failures, first check whether the caller’s cancellation token was cancelled. Otherwise you may mistakenly log a normal client disconnect as an external-provider outage.
Implement the typed client
Add the interface and records under an integration-oriented folder, for example:
PortfolioTracker.Api/
Integrations/
MarketPrices/
IMarketPriceClient.cs
MarketPriceClient.cs
MarketPriceQuote.cs
ProviderPriceResponse.cs
Here is a focused implementation. Adapt the relative route and JSON DTO to your selected provider when you wire in a real sandbox API.
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace PortfolioTracker.Api.Integrations.MarketPrices;
public sealed record MarketPriceQuote(
string Symbol,
decimal Price,
string Currency,
DateTimeOffset AsOfUtc);
public interface IMarketPriceClient
{
Task<MarketPriceQuote?> GetLatestAsync(
string symbol,
CancellationToken cancellationToken);
}
internal sealed record ProviderPriceResponse(
decimal Price,
string Currency,
DateTimeOffset AsOfUtc);
public sealed class MarketPriceTimeoutException : Exception
{
public MarketPriceTimeoutException(string symbol, Exception innerException)
: base($"The market-price provider timed out for symbol '{symbol}'.",
innerException)
{
}
}
public sealed class MarketPriceContractException : Exception
{
public MarketPriceContractException(string message)
: base(message)
{
}
}
public sealed class MarketPriceClient : IMarketPriceClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<MarketPriceClient> _logger;
public MarketPriceClient(
HttpClient httpClient,
ILogger<MarketPriceClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<MarketPriceQuote?> GetLatestAsync(
string symbol,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(symbol);
var normalizedSymbol = symbol.Trim().ToUpperInvariant();
var encodedSymbol = Uri.EscapeDataString(normalizedSymbol);
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"v1/prices/{encodedSymbol}");
try
{
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
var providerResponse =
await response.Content.ReadFromJsonAsync<ProviderPriceResponse>(
cancellationToken: cancellationToken);
if (providerResponse is null ||
providerResponse.Price < 0 ||
string.IsNullOrWhiteSpace(providerResponse.Currency))
{
throw new MarketPriceContractException(
"The market-price provider returned an invalid quote.");
}
return new MarketPriceQuote(
normalizedSymbol,
providerResponse.Price,
providerResponse.Currency,
providerResponse.AsOfUtc);
}
catch (OperationCanceledException exception)
when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(
exception,
"Market-price request timed out for {Symbol}",
normalizedSymbol);
throw new MarketPriceTimeoutException(
normalizedSymbol,
exception);
}
}
}
Notice the deliberate choices:
HttpCliententers via the constructor. The client does not construct it.- The method accepts and passes the incoming
CancellationToken. Uri.EscapeDataStringprotects the dynamic URL segment. Do not concatenate arbitrary input directly into a URL path.404becomes the explicit “no quote” result.EnsureSuccessStatusCode()preserves other unsuccessful responses as failures rather than silently treating them as a valid quote.- The external payload is checked before becoming an internal
MarketPriceQuote. - No
.Result,.Wait(), orGetAwaiter().GetResult()appears in the request path. - The timeout catch filter leaves true caller cancellation alone.
The ILogger call includes the symbol but not an authorization header, API key, bearer token, or full provider response body. In the observability phase, you will structure and correlate these logs more systematically.
Register and consume the client
For now, read the base URL from a simple configuration key. The next lesson will replace this temporary string-based configuration with strongly typed, validated options and environment-specific overrides.
appsettings.Development.json
{
"MarketPrice": {
"BaseUrl": "https://sandbox.market-provider.example/"
}
}
The base URL must end with / because the client uses relative request paths such as v1/prices/MSFT. A leading / in the relative path would discard a base-path segment if you later configure one.
Program.cs
using PortfolioTracker.Api.Integrations.MarketPrices;
var builder = WebApplication.CreateBuilder(args);
var marketPriceBaseUrl =
builder.Configuration["MarketPrice:BaseUrl"]
?? throw new InvalidOperationException(
"MarketPrice:BaseUrl must be configured.");
builder.Services
.AddHttpClient<IMarketPriceClient, MarketPriceClient>(client =>
{
client.BaseAddress = new Uri(marketPriceBaseUrl);
client.Timeout = TimeSpan.FromSeconds(2);
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
});
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
// Existing service registrations and middleware remain here.
var app = builder.Build();
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
You do not need a separate registration such as this:
builder.Services.AddTransient<IMarketPriceClient, MarketPriceClient>();
AddHttpClient<IMarketPriceClient, MarketPriceClient>() already registers the typed client and configures the HttpClient supplied to its constructor.
An application service can now consume only the focused abstraction:
public sealed class PortfolioValuationService
{
private readonly IMarketPriceClient _marketPriceClient;
public PortfolioValuationService(
IMarketPriceClient marketPriceClient)
{
_marketPriceClient = marketPriceClient;
}
public async Task<decimal?> GetCurrentValueAsync(
string symbol,
int quantity,
CancellationToken cancellationToken)
{
var quote = await _marketPriceClient.GetLatestAsync(
symbol,
cancellationToken);
return quote is null
? null
: quote.Price * quantity;
}
}
The valuation service decides what “no quote” means for portfolio behavior. Perhaps the UI shows “Price unavailable,” perhaps it displays the last stored price later when you introduce caching. The typed HTTP client’s responsibility is narrower: perform and interpret the provider call consistently.
Implementation pass and review checklist
Implement this incrementally in your capstone:
- Add
IMarketPriceClient,MarketPriceQuote, andMarketPriceClient. - Add the provider-specific response DTO rather than returning provider JSON beyond the integration folder.
- Register the typed client using
AddHttpClient. - Configure the base URL and a 2-second explicit timeout.
- Inject
IMarketPriceClientinto a scoped application service. - Pass
HttpContext.RequestAbortedthrough a controller and service path when invoking the client. - Trigger a valid symbol against your provider sandbox and confirm the quote mapping.
- Temporarily use an invalid symbol and confirm the provider’s
404becomesnull. - Temporarily point the base URL to an unreachable host or a deliberately slow test endpoint. Confirm that the call fails around the configured timeout rather than waiting for the default 100 seconds.
During code review, use this short checklist:
| Question | Expected answer |
|---|---|
Is new HttpClient() called inside request-handling code? | No |
| Does a typed client own provider URL and JSON knowledge? | Yes |
| Is the timeout explicit and appropriate to the request’s latency budget? | Yes |
Is caller cancellation propagated to SendAsync and JSON deserialization? | Yes |
| Is a typed client captured by a singleton? | No |
| Are API keys, bearer tokens, or raw response bodies logged? | No |
| Are provider DTOs kept out of controller and Angular contracts? | Yes |
| Are retries added without an explicit failure policy? | No |
Key takeaways
IHttpClientFactorymanages handler lifetimes and connection pooling; it avoids the risks of creating a freshHttpClientfor every outbound call.- A typed client is a domain-specific boundary around one external API. It centralizes URL construction, headers, status handling, deserialization, timeout policy, and provider DTOs.
- An explicit timeout is an operational decision, not a default to accept blindly. For this interactive quote lookup, 2 seconds is a reasonable learning example.
- Pass the request
CancellationTokenthrough every async call. Distinguish caller cancellation from a client-side timeout before classifying or logging the outcome. - Treat
404, provider failures, invalid provider payloads, and timeouts as distinct outcomes. - Do not capture a transient typed client in a singleton service.
Next, you will bind this client’s configuration into strongly typed options, validate it at startup, and override it safely across development and deployment environments.
Can't find a good explanation? Sign up and we'll make it for you
Sign up