HttpClient

Best practices for consuming REST APIs with native .NET HttpClient

While specialized REST libraries like Refit, RestSharp, or ServiceStack offer convenience and productive developer experiences, there are situations where using third-party dependencies may not be an option. Security policies, compliance requirements, or organizational constraints may limit your ability to introduce external packages into your codebase.

Fortunately, .NET’s built-in HttpClient class provides all the foundational capabilities needed to create a fully-functional REST client. This approach gives you complete control over the implementation and eliminates external dependencies, though it requires more manual coding and careful handling of edge cases.

This guide demonstrates how to build a robust, maintainable REST client using only .NET’s native HTTP capabilities.

Key Design Considerations

When building a REST client with HttpClient, consider these important aspects:

  1. Proper HttpClient Management - Avoid creating new HttpClient instances for each request to prevent socket exhaustion
  2. Error Handling - Implement standardized handling of HTTP status codes and network failures
  3. Serialization - Configure JSON/XML serialization options consistently across all requests
  4. Cancellation Support - Support for request cancellation and timeouts
  5. Authentication - Incorporate standardized authentication mechanisms
  6. Logging - Add telemetry for monitoring and troubleshooting
  7. Retry Logic - Implement transient failure handling and retries

Base Client Implementation

Let’s start by building a base client that implements these considerations:

using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.IO;

public abstract class MyJsonHttpClient : IDisposable
{
    protected readonly HttpClient HttpClient;
    private readonly string _baseAddress;
    private readonly bool _disposeHttpClient;
    protected readonly JsonSerializerOptions JsonSerializerOptions;

    public MyJsonHttpClient(HttpClient httpClient, string baseAddress, bool disposeHttpClient = false)
    {
        HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _baseAddress = baseAddress?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(baseAddress));
        _disposeHttpClient = disposeHttpClient;
        
        // Configure JSON serialization options
        JsonSerializerOptions = new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true,
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
        };
    }
    
    protected async Task<T?> InternalGetAsync<T>(string uri, CancellationToken cancellationToken = default)
    {
        // Ensure URI is properly formatted
        string requestUri = $"{_baseAddress}/{uri.TrimStart('/')}";
        
        using HttpResponseMessage response = await HttpClient
            .GetAsync(requestUri, cancellationToken)
            .ConfigureAwait(false);
            
        // Check for successful status code and throw if necessary
        response.EnsureSuccessStatusCode();

        return await DeserializeResponseAsync<T>(response, cancellationToken).ConfigureAwait(false);
    }    protected async Task<TResponse?> InternalPostAsync<TBody, TResponse>(
        string uri, 
        TBody body, 
        CancellationToken cancellationToken = default)
    {
        // Format the URI
        string requestUri = $"{_baseAddress}/{uri.TrimStart('/')}";
        
        // Serialize the request body
        string jsonContent = JsonSerializer.Serialize(body, JsonSerializerOptions);
        using StringContent httpContent = new(
            jsonContent, 
            Encoding.UTF8, 
            "application/json");

        // Add any custom headers if needed
        // httpContent.Headers.Add("Custom-Header", "Value");

        // Send the request
        using HttpResponseMessage response = await HttpClient
            .PostAsync(requestUri, httpContent, cancellationToken)
            .ConfigureAwait(false);

        // Validate the response
        response.EnsureSuccessStatusCode();

        // Process the response
        return await DeserializeResponseAsync<TResponse>(response, cancellationToken)
            .ConfigureAwait(false);
    }
    
    protected async Task<TResponse?> InternalPutAsync<TBody, TResponse>(
        string uri, 
        TBody body, 
        CancellationToken cancellationToken = default)
    {
        string requestUri = $"{_baseAddress}/{uri.TrimStart('/')}";
        string jsonContent = JsonSerializer.Serialize(body, JsonSerializerOptions);
        
        using StringContent httpContent = new(jsonContent, Encoding.UTF8, "application/json");
        using HttpResponseMessage response = await HttpClient
            .PutAsync(requestUri, httpContent, cancellationToken)
            .ConfigureAwait(false);
            
        response.EnsureSuccessStatusCode();
        return await DeserializeResponseAsync<TResponse>(response, cancellationToken)
            .ConfigureAwait(false);
    }
    
    protected async Task<TResponse?> InternalDeleteAsync<TResponse>(
        string uri,
        CancellationToken cancellationToken = default)
    {
        string requestUri = $"{_baseAddress}/{uri.TrimStart('/')}";
        
        using HttpResponseMessage response = await HttpClient
            .DeleteAsync(requestUri, cancellationToken)
            .ConfigureAwait(false);
            
        response.EnsureSuccessStatusCode();
        return await DeserializeResponseAsync<TResponse>(response, cancellationToken)
            .ConfigureAwait(false);
    }

    protected async Task<T?> DeserializeResponseAsync<T>(
        HttpResponseMessage responseMessage,
        CancellationToken cancellationToken = default)
    {
        // For empty responses or special status codes like 204 No Content
        if (responseMessage.Content == null || responseMessage.Content.Headers.ContentLength == 0)
        {
            return default;
        }
        
        Stream bodyStream = await responseMessage.Content
            .ReadAsStreamAsync(cancellationToken)
            .ConfigureAwait(false);
            
        return await JsonSerializer
            .DeserializeAsync<T>(bodyStream, JsonSerializerOptions, cancellationToken)
            .ConfigureAwait(false);
    }
    
    // Clean up resources
    public void Dispose()
    {
        if (_disposeHttpClient)
        {
            HttpClient?.Dispose();
        }
    }
}

Domain-Specific API Implementation

With the base client in place, you can now build domain-specific API wrappers that encapsulate your REST resources. The abstraction keeps the actual API implementation clean and focused on the business domain:

public class MyTaskAPI : MyJsonHttpClient
{
    // Constants for endpoint paths
    private const string TasksEndpoint = "tasks";
    
    public MyTaskAPI(HttpClient httpClient, string baseAddress)
        : base(httpClient, baseAddress)
    { }

    /// <summary>
    /// Retrieves a specific task by its ID
    /// </summary>
    /// <param name="id">The task identifier</param>
    /// <param name="cancellationToken">Cancellation token</param>
    /// <returns>The task or null if not found</returns>
    public Task<TaskApiModel?> GetTaskAsync(int id, CancellationToken cancellationToken = default)
        => InternalGetAsync<TaskApiModel>($"{TasksEndpoint}/{id}", cancellationToken);

    /// <summary>
    /// Gets all available tasks
    /// </summary>
    /// <param name="cancellationToken">Cancellation token</param>
    /// <returns>Array of tasks</returns>
    public Task<TaskApiModel[]?> GetAllTasksAsync(CancellationToken cancellationToken = default)
        => InternalGetAsync<TaskApiModel[]>(TasksEndpoint, cancellationToken);
    
    /// <summary>
    /// Creates a new task
    /// </summary>
    /// <param name="newTask">The task to create</param>
    /// <param name="cancellationToken">Cancellation token</param>
    /// <returns>The created task with its assigned ID</returns>
    public Task<TaskApiModel?> AddTaskAsync(TaskApiCreateModel newTask, CancellationToken cancellationToken = default)
        => InternalPostAsync<TaskApiCreateModel, TaskApiModel>(TasksEndpoint, newTask, cancellationToken);
        
    /// <summary>
    /// Updates an existing task
    /// </summary>
    /// <param name="id">The ID of the task to update</param>
    /// <param name="taskUpdate">The update data</param>
    /// <param name="cancellationToken">Cancellation token</param>
    /// <returns>The updated task</returns>
    public Task<TaskApiModel?> UpdateTaskAsync(
        int id, 
        TaskApiUpdateModel taskUpdate, 
        CancellationToken cancellationToken = default)
        => InternalPutAsync<TaskApiUpdateModel, TaskApiModel>(
            $"{TasksEndpoint}/{id}", 
            taskUpdate, 
            cancellationToken);
            
    /// <summary>
    /// Deletes a task by its ID
    /// </summary>
    /// <param name="id">The ID of the task to delete</param>
    /// <param name="cancellationToken">Cancellation token</param>
    /// <returns>True if deletion was successful</returns>
    public async Task<bool> DeleteTaskAsync(int id, CancellationToken cancellationToken = default)
    {
        try
        {
            await InternalDeleteAsync<object>($"{TasksEndpoint}/{id}", cancellationToken);
            return true;
        }
        catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
            // Handle 404 Not Found as a special case
            return false;
        }
    }
}

Using with HttpClientFactory

While the example above works, creating HttpClient instances directly is not recommended for production applications. Instead, use the built-in IHttpClientFactory which properly manages connection pooling and lifetime:

// In Startup.cs or Program.cs
services.AddHttpClient<MyTaskAPI>(client => 
{
    client.BaseAddress = new Uri("https://api.example.com/");
    client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
    client.Timeout = TimeSpan.FromSeconds(30);
});

// In a controller or service
public class TaskController
{
    private readonly MyTaskAPI _taskApi;
    
    public TaskController(MyTaskAPI taskApi)
    {
        _taskApi = taskApi;
    }
    
    public async Task<IActionResult> GetTaskById(int id)
    {
        var task = await _taskApi.GetTaskAsync(id);
        if (task == null) return NotFound();
        return Ok(task);
    }
}

Advanced Usage Examples

Once implemented, your API client can be used to perform CRUD operations and more:

// Create HttpClient (preferably through HttpClientFactory in production)
HttpClient httpClient = new HttpClient();
MyTaskAPI taskApi = new MyTaskAPI(httpClient, "https://api.example.com");

// GET operations
TaskApiModel? task = await taskApi.GetTaskAsync(123);
TaskApiModel[]? allTasks = await taskApi.GetAllTasksAsync();

// POST - Create new resource
var createModel = new TaskApiCreateModel 
{ 
    Title = "Buy groceries",
    DueDate = DateTime.Now.AddDays(1),
    Priority = TaskPriority.High
};
TaskApiModel? createdTask = await taskApi.AddTaskAsync(createModel);
Console.WriteLine($"Created task with ID: {createdTask?.Id}");

// PUT - Update existing resource
var updateModel = new TaskApiUpdateModel
{
    Title = "Buy organic groceries",
    Status = TaskStatus.InProgress
};
TaskApiModel? updatedTask = await taskApi.UpdateTaskAsync(createdTask.Id, updateModel);

// DELETE - Remove resource
bool deleted = await taskApi.DeleteTaskAsync(createdTask.Id);
if (deleted) 
{
    Console.WriteLine("Task successfully deleted");
}

// Using cancellation tokens for timeout control
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try 
{
    var tasks = await taskApi.GetAllTasksAsync(cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Request timed out after 5 seconds");
}

Error Handling

Proper error handling is essential when working with HTTP APIs. Here’s how to enhance your client with robust error handling:

// Add this to your MyJsonHttpClient class
protected async Task<T?> InternalGetWithErrorHandlingAsync<T>(
    string uri, 
    CancellationToken cancellationToken = default)
{
    try
    {
        return await InternalGetAsync<T>(uri, cancellationToken);
    }
    catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
    {
        // Handle 404 Not Found
        return default;
    }
    catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
    {
        // Handle 401 Unauthorized
        throw new UnauthorizedException("Authentication required", ex);
    }
    catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
    {
        // Handle 403 Forbidden
        throw new ForbiddenException("You don't have permission to access this resource", ex);
    }
    catch (HttpRequestException ex)
    {
        // Handle other HTTP errors
        throw new ApiException($"API error: {ex.StatusCode}", ex);
    }
    catch (JsonException ex)
    {
        // Handle deserialization errors
        throw new ApiException("Invalid response format", ex);
    }
    catch (OperationCanceledException ex)
    {
        // Handle timeouts and cancellations
        throw new ApiException("The operation was canceled or timed out", ex);
    }
    catch (Exception ex)
    {
        // Handle unexpected errors
        throw new ApiException("An unexpected error occurred", ex);
    }
}

Authentication Implementation

Adding authentication to your client:

public class AuthenticatedJsonHttpClient : MyJsonHttpClient
{
    private string _accessToken;

    public AuthenticatedJsonHttpClient(HttpClient httpClient, string baseAddress)
        : base(httpClient, baseAddress)
    {
    }

    public Task SetAccessTokenAsync(string accessToken)
    {
        _accessToken = accessToken;
        HttpClient.DefaultRequestHeaders.Authorization = 
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
        return Task.CompletedTask;
    }

    // Add refresh token logic if needed
    public async Task<bool> RefreshTokenAsync(string refreshToken)
    {
        // Implementation for token refresh
        // ...
        
        return true;
    }
}

Testing Considerations

When using a custom HttpClient implementation, proper testing becomes critical:

// Sample test using Moq and xUnit
public class TaskApiTests
{
    [Fact]
    public async Task GetTask_ReturnsTaskWhenExists()
    {
        // Arrange
        var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
        var response = new HttpResponseMessage
        {
            StatusCode = System.Net.HttpStatusCode.OK,
            Content = new StringContent("{\"id\":123,\"title\":\"Test Task\",\"completed\":false}")
        };

        mockHttpMessageHandler
            .Protected()
            .Setup<Task<HttpResponseMessage>>(
                "SendAsync",
                ItExpr.IsAny<HttpRequestMessage>(),
                ItExpr.IsAny<CancellationToken>())
            .ReturnsAsync(response);

        var httpClient = new HttpClient(mockHttpMessageHandler.Object);
        var taskApi = new MyTaskAPI(httpClient, "https://api.example.com");

        // Act
        var task = await taskApi.GetTaskAsync(123);

        // Assert
        Assert.NotNull(task);
        Assert.Equal(123, task.Id);
        Assert.Equal("Test Task", task.Title);
        Assert.False(task.Completed);
    }
}

Comparison

Pros

  • No external dependencies required
  • Complete control over implementation details
  • No license restrictions or compatibility concerns
  • Customizable to exact organizational requirements
  • Integration with existing HttpClientFactory infrastructure
  • Potentially smaller deployment footprint
  • Can optimize for specific use cases

Cons

  • Significant development time investment
  • Requires careful implementation of error handling and edge cases
  • Extensive testing needed to ensure reliability
  • Higher maintenance burden as API evolves
  • Need to implement features provided out-of-box by libraries
  • Risk of reinventing solutions to common problems

When to Choose HttpClient

Building a custom client with HttpClient is most appropriate when:

  1. Security policies prohibit third-party dependencies
  2. Simple needs that don’t require advanced features of specialized libraries
  3. Unique requirements not easily addressed by existing libraries
  4. Learning purposes to understand HTTP communication fundamentals
  5. Complete control is needed over every aspect of the implementation

For other scenarios, specialized libraries like Refit, RestSharp, or HttpClientFactory with System.Text.Json will typically provide a better developer experience and faster time-to-market.

Full Sample

See the full sample on GitHub: https://github.com/BenjaminAbt/dotnet.rest-samples