Introduction
Modern .NET APIs rarely work in isolation. They commonly integrate with:
- Payment gateways
- SMS/Email services
- Authentication providers
- Third-party data APIs
- Internal microservices
All such integrations are:
- Network I/O bound
- Unreliable by nature
- Latency sensitive
Therefore, they must be implemented asynchronously and resiliently.
5.1 Why External API Calls MUST Be Async
External calls involve:
- DNS lookup
- TCP connection
- SSL handshake
- Network latency
- Remote server processing
If done synchronously:
- Your request thread is blocked
- Under load, your entire API collapses
❌ Synchronous External Call (Bad)
var response = httpClient.GetAsync(url).Result; // Dangerous
✅ Asynchronous External Call (Correct)
var response = await httpClient.GetAsync(url);
✅ The thread is released while waiting on the network.
5.2 The HttpClient Problem (Very Important Concept)
❌ Wrong Pattern (Socket Exhaustion)
public async Task CallApiAsync()
{
var client = new HttpClient(); // NEW instance each time ❌
var response = await client.GetAsync("https://api.example.com");
}
This causes:
- TCP socket leaks
- Port exhaustion
- Random production outages under load
5.3 Correct Pattern: IHttpClientFactory
IHttpClientFactory:
- Manages connection pooling
- Reuses sockets safely
- Avoids DNS issues
- Is thread-safe and production-safe
Register in Program.cs
builder.Services.AddHttpClient();
Inject into Service
public class PaymentGatewayService
{
private readonly HttpClient _httpClient;
public PaymentGatewayService(HttpClient httpClient)
{
_httpClient = httpClient;
}
}
Or with factory:
public class PaymentGatewayService
{
private readonly IHttpClientFactory _factory;
public PaymentGatewayService(IHttpClientFactory factory)
{
_factory = factory;
}
public HttpClient CreateClient() => _factory.CreateClient();
}
5.4 Typed HttpClient (Best Practice for Large Systems)
builder.Services.AddHttpClient<PaymentGatewayClient>(client =>
{
client.BaseAddress = new Uri("https://payment.api.com/");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
public class PaymentGatewayClient
{
private readonly HttpClient _client;
public PaymentGatewayClient(HttpClient client)
{
_client = client;
}
public async Task<PaymentResponse> ProcessAsync(PaymentRequest req)
{
var response = await _client.PostAsJsonAsync("pay", req);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<PaymentResponse>();
}
}
✅ Clean, testable, DI-friendly, production-safe
5.5 GET, POST, PUT, DELETE – Async Usage
GET
var data = await _client.GetFromJsonAsync<UserDto>("users/10");
POST
var response = await _client.PostAsJsonAsync("orders", order);
PUT
await _client.PutAsJsonAsync("orders/10", order);
DELETE
await _client.DeleteAsync("orders/10");
5.6 Handling HTTP Status Codes Properly
⚠️ Never assume success.
var response = await _client.GetAsync("payments/123");
if (!response.IsSuccessStatusCode)
{
throw new ExternalServiceException(
$"Payment API failed: {response.StatusCode}");
}
5.7 Timeouts in External API Calls
Default Timeout (100 seconds) is dangerous
client.Timeout = TimeSpan.FromSeconds(5);
✅ Always define:
- Upper latency bounds
- SLA-based timeout
5.8 CancellationToken + Client Disconnect
public async Task<PaymentStatus> CheckAsync(
string txnId, CancellationToken token)
{
var response = await _client
.GetAsync($"txn/{txnId}", token);
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<PaymentStatus>(token);
}
✅ If:
- Client disconnects
- Request times out
→ External call is cancelled immediately.
5.9 The Reality of External APIs: Failures Are Normal
Failures include:
- DNS failure
- Timeout
- 5xx errors
- Partial outages
- Network jitter
Hence we must use:
- Retries
- Circuit breakers
- Bulkheads
- Fallbacks
This is where Polly comes in.
5.10 Polly Integration (Retries + Circuit Breaker)
Install Polly
dotnet add package Microsoft.Extensions.Http.Polly
Register with Retry + Circuit Breaker
builder.Services
.AddHttpClient<PaymentGatewayClient>()
.AddTransientHttpErrorPolicy(p => p.WaitAndRetryAsync(
3, retry => TimeSpan.FromSeconds(Math.Pow(2, retry))))
.AddTransientHttpErrorPolicy(p => p.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30)));
✅ This automatically:
- Retries on 5xx + timeouts
- Breaks circuit after repeated failures
- Prevents cascading failures
5.11 Real-Life Example: Async Payment Gateway Integration
Request / Response DTO
public class PaymentRequest
{
public string OrderId { get; set; } = default!;
public decimal Amount { get; set; }
}
public class PaymentResponse
{
public bool Success { get; set; }
public string TransactionId { get; set; } = default!;
}
Gateway Client (Async + Resilient)
public class PaymentGatewayClient
{
private readonly HttpClient _client;
public PaymentGatewayClient(HttpClient client)
{
_client = client;
}
public async Task<PaymentResponse> PayAsync(
PaymentRequest request, CancellationToken token)
{
var response = await _client
.PostAsJsonAsync("payments", request, token);
response.EnsureSuccessStatusCode();
return await response
.Content
.ReadFromJsonAsync<PaymentResponse>(token);
}
}
Service Layer
public class OrderPaymentService
{
private readonly PaymentGatewayClient _gateway;
public OrderPaymentService(PaymentGatewayClient gateway)
{
_gateway = gateway;
}
public async Task<string> PayOrderAsync(
string orderId, decimal amount, CancellationToken token)
{
var request = new PaymentRequest
{
OrderId = orderId,
Amount = amount
};
var response = await _gateway.PayAsync(request, token);
if (!response.Success)
throw new Exception("Payment failed");
return response.TransactionId;
}
}
Controller
[HttpPost("{orderId}/pay")]
public async Task<IActionResult> PayOrder(
string orderId, [FromBody] decimal amount, CancellationToken token)
{
var txnId = await _paymentService
.PayOrderAsync(orderId, amount, token);
return Ok(new { transactionId = txnId });
}
✅ End-to-end:
- Fully Async
- Cancellation-aware
- Retry + circuit breaker protected
- Production ready
5.12 Parallel External API Calls (When Multiple APIs Are Required)
Example: Fetch order + user + payment from 3 systems.
var orderTask = _orderApi.GetAsync(orderId);
var userTask = _userApi.GetAsync(userId);
var payTask = _payApi.GetAsync(txnId);
await Task.WhenAll(orderTask, userTask, payTask);
return new DashboardDto
{
Order = await orderTask,
User = await userTask,
Payment = await payTask
};
✅ Runs all external calls in parallel
✅ Massive latency reduction
5.13 Bulkhead Isolation (Advanced Microservice Pattern)
Protects your system from being overloaded by one failing dependency.
.AddPolicyHandler(
Policy.BulkheadAsync<HttpResponseMessage>(
maxParallelization: 10,
maxQueuingActions: 20));
✅ Only a limited number of requests reach that external API at once.
5.14 Common Async External Call Mistakes (Very Dangerous)
❌ Mistake 1: New HttpClient Per Call
Causes socket exhaustion
❌ Mistake 2: No Timeout
API can hang forever
❌ Mistake 3: No Retry Policy
Temporary failures break your system
❌ Mistake 4: No Circuit Breaker
Cascading failures across microservices
❌ Mistake 5: No CancellationToken
Wastes network + CPU after client disconnect
5.15 Observability for External API Calls
✅ Log:
- Request duration
- Status code
- Retry count
- Circuit state
✅ Track:
- External API latency
- Failure rates
- Timeout frequency
✅ Use:
- Structured logging
- Distributed tracing
5.16 HttpClient Lifecycle and Disposal Patterns
The Problem: Improper HttpClient Disposal
// WRONG: Disposing HttpClient improperly
public async Task CallApiAsync()
{
using var client = new HttpClient(); // ❌ Disposed too early
var response = await client.GetAsync("https://api.example.com");
// HttpClient disposed but underlying sockets may still be in use
return await response.Content.ReadAsStringAsync();
}
Issue: HttpClient uses connection pooling. Early disposal can:
- Cause socket leaks
- Trigger TCP TIME_WAIT state accumulation
- Reduce connection reuse benefits
Correct Patterns with IHttpClientFactory
// Option 1: Named client (reusable, managed)
services.AddHttpClient("PaymentGateway", client =>
{
client.BaseAddress = new Uri("https://payments.example.com/");
client.Timeout = TimeSpan.FromSeconds(30);
});
// Usage
public class PaymentService
{
private readonly IHttpClientFactory _httpClientFactory;
public PaymentService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task ProcessPaymentAsync()
{
// Factory manages lifecycle - no manual disposal needed
var client = _httpClientFactory.CreateClient("PaymentGateway");
var response = await client.GetAsync("/payments");
// HttpClient is returned to factory pool automatically
}
}
Key Points:
- IHttpClientFactory handles HttpClient disposal automatically
- HttpClient instances are pooled and reused
- Default HttpClientMessageHandler lifetime: 2 minutes
- Factory ensures proper DNS refresh (prevents DNS caching issues)
5.17 Streaming Large Payloads Asynchronously
The Problem: Memory Pressure with Large Responses
// WRONG: Loading entire response into memory
public async Task<string> DownloadLargeFileAsync(string url)
{
var response = await _httpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync(); // ❌ 1GB in memory!
return content; // Memory spike, potential OutOfMemoryException
}
Solution: Stream Response Content
// CORRECT: Stream processing
public async Task ProcessLargeFileAsync(string url, string outputPath)
{
using var response = await _httpClient.GetAsync(
url,
HttpCompletionOption.ResponseHeadersRead); // Don't buffer body
await using var contentStream = await response.Content.ReadAsStreamAsync();
await using var fileStream = File.Create(outputPath);
// Stream chunk by chunk (64KB buffer)
await contentStream.CopyToAsync(fileStream, bufferSize: 65536);
// Memory usage: ~64KB instead of potentially gigabytes
}
Streaming Uploads
public async Task UploadLargeFileAsync(string filePath, string url)
{
await using var fileStream = File.OpenRead(filePath);
using var streamContent = new StreamContent(fileStream);
// Chunked transfer encoding
var response = await _httpClient.PostAsync(url, streamContent);
response.EnsureSuccessStatusCode();
}
Benefits:
- Constant memory usage regardless of file size
- Supports files larger than available RAM
- Better performance with chunked transfer
- Progress reporting capability
5.18 Custom Message Handlers for Cross-Cutting Concerns
Creating Custom DelegatingHandler
public class LoggingHandler : DelegatingHandler
{
private readonly ILogger<LoggingHandler> _logger;
public LoggingHandler(ILogger<LoggingHandler> logger)
{
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation("Outgoing HTTP Request: {Method} {Url}",
request.Method, request.RequestUri);
try
{
var response = await base.SendAsync(request, cancellationToken);
_logger.LogInformation(
"HTTP Response: {StatusCode} in {ElapsedMilliseconds}ms",
response.StatusCode, stopwatch.ElapsedMilliseconds);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "HTTP Request failed after {ElapsedMilliseconds}ms",
stopwatch.ElapsedMilliseconds);
throw;
}
}
}
Registering Custom Handlers
// Register with dependency injection
services.AddTransient<LoggingHandler>();
services.AddTransient<AuthHeaderHandler>();
// Chain multiple handlers
services.AddHttpClient<ExternalApiClient>()
.AddHttpMessageHandler<LoggingHandler>()
.AddHttpMessageHandler<AuthHeaderHandler>()
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
Common Handler Use Cases
// 1. Authentication Handler
public class AuthHeaderHandler : DelegatingHandler
{
private readonly ITokenService _tokenService;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await _tokenService.GetAccessTokenAsync();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
// 2. Correlation ID Handler
public class CorrelationIdHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Add("X-Correlation-ID",
Guid.NewGuid().ToString());
return base.SendAsync(request, cancellationToken);
}
}
5.19 DNS Resolution in Long-Running Services
The Problem: DNS Caching
// Without IHttpClientFactory
var client = new HttpClient
{
BaseAddress = new Uri("https://api.example.com")
};
// DNS resolved once at HttpClient creation
// If IP changes (failover, scaling), connections fail
Real-world impact: Service outages during:
- Cloud provider failover
- Auto-scaling events
- Blue-green deployments
- DNS-based load balancing
IHttpClientFactory Solution
// With factory, DNS is refreshed periodically
services.AddHttpClient<ExternalApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
// Control DNS refresh behavior
PooledConnectionLifetime = TimeSpan.FromMinutes(2), // Default
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
// DNS resolution settings
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
UseCookies = false,
AllowAutoRedirect = false
});
Manual DNS Refresh Pattern
public class DnsAwareHttpClientFactory
{
private readonly IHttpClientFactory _httpClientFactory;
private DateTime _lastDnsRefresh = DateTime.MinValue;
private readonly TimeSpan _dnsRefreshInterval = TimeSpan.FromMinutes(5);
public async Task<HttpResponseMessage> SendWithDnsRefreshAsync(
HttpRequestMessage request)
{
if (DateTime.UtcNow - _lastDnsRefresh > _dnsRefreshInterval)
{
// Force new HttpClient creation (with fresh DNS)
var client = _httpClientFactory.CreateClient("FreshClient");
_lastDnsRefresh = DateTime.UtcNow;
return await client.SendAsync(request);
}
else
{
var client = _httpClientFactory.CreateClient();
return await client.SendAsync(request);
}
}
}
5.20 HttpClient Configuration Best Practices
Connection Pool Tuning
services.AddHttpClient<HighVolumeApiClient>()
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
// Connection pooling settings
MaxConnectionsPerServer = 100, // Default: int.MaxValue
PooledConnectionLifetime = TimeSpan.FromMinutes(2), // DNS refresh
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
EnableMultipleHttp2Connections = true, // HTTP/2 optimization
// Timeout settings
ConnectTimeout = TimeSpan.FromSeconds(5),
ResponseDrainTimeout = TimeSpan.FromSeconds(2),
// Performance
UseCookies = false, // Disable if not needed
AutomaticDecompression = DecompressionMethods.GZip,
UseProxy = false // Disable if not using proxy
});
HttpClient Defaults by Use Case
High-Volume Internal Service
// Many small requests between microservices
MaxConnectionsPerServer: 200
PooledConnectionLifetime: 5 minutes
Timeout: 2 seconds
Keep-Alive: enabled
HTTP/2: enabled
External API Integration
// Fewer, larger requests to external providers
MaxConnectionsPerServer: 50
PooledConnectionLifetime: 10 minutes
Timeout: 30 seconds
Retry: enabled with exponential backoff
Circuit Breaker: enabled
File Upload/Download Service
// Large payloads, streaming
MaxConnectionsPerServer: 20
Timeout: 5 minutes
BufferSize: 65536 (64KB)
ChunkedTransfer: enabled
ResponseHeadersRead: enabled
5.21 Testing Async External API Calls
Unit Testing with MockHttpMessageHandler
[Fact]
public async Task GetUserAsync_ReturnsUser_WhenApiSucceeds()
{
// Arrange
var handler = new MockHttpMessageHandler();
handler.When("https://api.example.com/users/123")
.Respond("application/json",
JsonSerializer.Serialize(new { Id = 123, Name = "John" }));
var httpClient = new HttpClient(handler);
var service = new UserService(httpClient);
// Act
var user = await service.GetUserAsync(123);
// Assert
Assert.NotNull(user);
Assert.Equal("John", user.Name);
}
[Fact]
public async Task GetUserAsync_Throws_WhenApiFails()
{
// Arrange
var handler = new MockHttpMessageHandler();
handler.When("*").Respond(HttpStatusCode.InternalServerError);
var httpClient = new HttpClient(handler);
var service = new UserService(httpClient);
// Act & Assert
await Assert.ThrowsAsync<ExternalApiException>(
() => service.GetUserAsync(123));
}
Integration Testing with TestServer
[Fact]
public async Task PaymentIntegration_WorksEndToEnd()
{
// Arrange - Create test server
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
// Mock external dependency
services.AddHttpClient<PaymentGatewayClient>()
.ConfigurePrimaryHttpMessageHandler(() =>
new MockHttpMessageHandler());
});
});
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync("/api/payments",
new { Amount = 100 });
// Assert
response.EnsureSuccessStatusCode();
var payment = await response.Content.ReadFromJsonAsync<PaymentResponse>();
Assert.True(payment.Success);
}
5.22 Advanced Resilience Patterns
Fallback Strategies
// Polly Fallback Policy
services.AddHttpClient<WeatherService>()
.AddPolicyHandler(Policy<HttpResponseMessage>
.HandleResult(r => !r.IsSuccessStatusCode)
.FallbackAsync(
fallbackAction: async (ct) =>
{
// Return cached/stub data
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
JsonSerializer.Serialize(GetCachedWeatherData()))
};
},
onFallbackAsync: (result, context) =>
{
_logger.LogWarning("Weather API failed, using fallback");
return Task.CompletedTask;
}));
Timeout Per Request (Not Per Client)
public async Task<HttpResponseMessage> CallWithTimeoutAsync(
string url,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
return await _httpClient.GetAsync(
url,
HttpCompletionOption.ResponseHeadersRead,
timeoutCts.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
throw new TimeoutException($"Request to {url} timed out after {timeout}");
}
}
Chapter 5 Summary
- External calls are I/O bound → must be async
- Never create HttpClient manually per request
- Always use IHttpClientFactory or Typed Clients
- Always configure:
- Timeout
- Retry
- Circuit Breaker
- Always pass CancellationToken
- Parallelize independent calls with Task.WhenAll
- Use Polly for real production resiliency