Flurl

Fluent and user-friendly URL building and HTTP client library for .NET. Features intuitive API through method chaining, excellent testability, JSON integration, and error handling capabilities. HttpClient-based supporting modern async communication patterns.

HTTP Client.NETFluent APIURL BuildingTestable

GitHub Overview

tmenier/Flurl

Fluent URL builder and testable HTTP client for .NET

Stars4,363
Watchers97
Forks397
Created:February 16, 2014
Language:C#
License:MIT License

Topics

c-sharpdotnethttprest-clienturl-builder

Star History

tmenier/Flurl Star History
Data as of: 10/22/2025, 09:55 AM

Library

Flurl

Overview

Flurl is "a fluent URL builder and testable HTTP client for .NET" developed as a modern HTTP library gaining attention in the .NET ecosystem. With the concept of "fluent and chainable syntax," it provides an intuitive API that integrates URL building and HTTP communication. Comprehensively supporting features necessary for web API integration such as JSON processing, authentication, testing capabilities, and query parameter handling, it has established itself as a next-generation HTTP client that works across diverse platforms through .NET Standard compliance.

Details

Flurl 2025 edition has established a solid position as an innovative library proposing a new paradigm for .NET HTTP clients. Unlike traditional HttpClient, its fluent API design enables writing a series of processes from URL construction to HTTP request execution with intuitive method chaining. Supporting a wide range of platforms including .NET Framework, .NET Core, Xamarin, and UWP, it achieves high productivity in modern .NET development. The design specialized for testing capabilities, making HTTP communication mocking extremely easy in unit tests, is a major distinguishing feature not found in other libraries.

Key Features

  • Fluent API Design: Readable and chainable method chaining syntax
  • Integrated URL Building: Unified builder pattern for URLs and HTTP requests
  • Comprehensive JSON Processing: Automatic serialization and deserialization
  • Excellent Testing Capabilities: Design specialized for mocking and testing
  • Multi-platform Support: Wide environment support through .NET Standard compliance
  • Flexible Authentication System: Easy implementation of OAuth, Bearer Token, and custom authentication

Pros and Cons

Pros

  • Improved code readability and maintainability through fluent API syntax
  • Intuitive development experience with integrated URL building and HTTP requests
  • Extremely easy HTTP communication mocking in unit tests
  • Significantly improved development efficiency through automated JSON processing
  • Wide platform support through .NET Standard compliance
  • Rich HTTP methods and header customization features

Cons

  • Learning cost for developers unfamiliar with fluent APIs
  • Debate over string extension method usage (though optional)
  • Visibility challenges due to dependency hiding in large projects
  • Functional limitations compared to HttpClient (some low-level controls)
  • Need for careful consideration in enterprise adoption due to being relatively new
  • Flexibility limitations when requiring complex HTTP configurations

Reference Pages

Code Examples

Installation and Basic Setup

# Install Flurl HTTP client (includes URL building features)
Install-Package Flurl.Http

# For URL building only
Install-Package Flurl

# Using .NET CLI
dotnet add package Flurl.Http

# Verification in Package Manager Console
Get-Package Flurl.Http

Basic HTTP Requests (GET/POST/PUT/DELETE)

using Flurl;
using Flurl.Http;

// Basic GET request
var users = await "https://api.example.com/users"
    .GetJsonAsync<List<User>>();

Console.WriteLine($"Retrieved users count: {users.Count}");

// GET request with query parameters
var filteredUsers = await "https://api.example.com/users"
    .SetQueryParams(new { 
        page = 1, 
        limit = 10, 
        sort = "created_at",
        status = "active" 
    })
    .GetJsonAsync<ApiResponse<User>>();

// Dynamic URL construction and GET request
var userId = 123;
var userDetail = await "https://api.example.com"
    .AppendPathSegment("users")
    .AppendPathSegment(userId)
    .SetQueryParam("include", "profile,settings")
    .GetJsonAsync<UserDetail>();

Console.WriteLine($"User name: {userDetail.Name}");

// POST request (sending JSON)
var newUser = new User
{
    Name = "John Doe",
    Email = "[email protected]",
    Age = 30,
    Department = "Development"
};

var createdUser = await "https://api.example.com/users"
    .WithHeader("Authorization", "Bearer your-jwt-token")
    .PostJsonAsync(newUser)
    .ReceiveJson<User>();

Console.WriteLine($"Created user ID: {createdUser.Id}");

// PUT request (data update)
var updatedData = new { Name = "Jane Doe", Email = "[email protected]" };

var updatedUser = await $"https://api.example.com/users/{userId}"
    .WithOAuthBearerToken("your-oauth-token")
    .PutJsonAsync(updatedData)
    .ReceiveJson<User>();

// DELETE request
var deleteResponse = await $"https://api.example.com/users/{userId}"
    .WithHeader("Authorization", "Bearer your-token")
    .DeleteAsync();

if (deleteResponse.IsSuccessStatusCode)
{
    Console.WriteLine("User deleted successfully");
}

// Form data submission
var loginResult = await "https://api.example.com/login"
    .PostUrlEncodedAsync(new { 
        username = "testuser", 
        password = "secret123",
        remember_me = true 
    })
    .ReceiveJson<LoginResponse>();

// File upload
var uploadResult = await "https://api.example.com/upload"
    .WithOAuthBearerToken("your-token")
    .PostMultipartAsync(mp => {
        mp.AddFile("file", "/path/to/document.pdf", "application/pdf");
        mp.AddString("category", "documents");
        mp.AddString("public", "false");
    })
    .ReceiveJson<UploadResponse>();

Console.WriteLine($"Upload completed: {uploadResult.FileId}");

Advanced Configuration and Customization (Headers, Authentication, Timeout, etc.)

using Flurl;
using Flurl.Http;
using Flurl.Http.Configuration;

// Custom header configuration
var response = await "https://api.example.com/data"
    .WithHeaders(new {
        User_Agent = "MyApp/1.0 (Flurl Client)",
        Accept = "application/json",
        Accept_Language = "en-US,ja-JP",
        X_API_Version = "v2",
        X_Request_ID = "req-12345"
    })
    .GetJsonAsync<ApiData>();

// Authentication configuration (multiple patterns)

// Bearer Token authentication
var bearerData = await "https://api.example.com/protected"
    .WithOAuthBearerToken("your-jwt-token")
    .GetJsonAsync<ProtectedData>();

// Basic authentication
var basicAuthData = await "https://api.example.com/private"
    .WithBasicAuth("username", "password")
    .GetJsonAsync<PrivateData>();

// Custom header authentication
var apiKeyData = await "https://api.example.com/secure"
    .WithHeader("X-API-Key", "your-api-key")
    .WithHeader("X-Client-Id", "client-123")
    .GetJsonAsync<SecureData>();

// Timeout configuration
try
{
    var slowData = await "https://api.example.com/slow-endpoint"
        .WithTimeout(TimeSpan.FromSeconds(30))  // 30-second timeout
        .GetJsonAsync<SlowData>();
}
catch (FlurlHttpTimeoutException)
{
    Console.WriteLine("Request timed out");
}

// Cookie usage
var session = await "https://api.example.com/login"
    .WithCookies(out var cookies)  // Create cookie container
    .PostJsonAsync(new { username = "user", password = "pass" })
    .ReceiveJson<LoginResult>();

// Subsequent requests with the same cookie session
var userData = await "https://api.example.com/user-data"
    .WithCookies(cookies)  // Use previous cookies
    .GetJsonAsync<UserData>();

// SSL configuration and client certificates
var secureClient = new FlurlClient("https://secure-api.example.com")
    .Configure(settings => {
        settings.HttpClientFactory = new DefaultHttpClientFactory();
        // Client certificate configuration is done through HttpClientHandler
    });

var secureData = await secureClient
    .Request("data")
    .GetJsonAsync<SecureApiData>();

// Proxy configuration
FlurlHttp.Configure(settings => {
    settings.HttpClientFactory = new DefaultHttpClientFactory();
    // Proxy configuration is done through HttpClientHandler
});

// Global configuration customization
FlurlHttp.Configure(settings => {
    settings.Timeout = TimeSpan.FromSeconds(60);  // Default timeout
    settings.AllowedHttpStatusRange = "200-299,404";  // Allowed status range
});

// Client-specific configuration
var apiClient = new FlurlClient("https://api.example.com")
    .Configure(settings => {
        settings.Timeout = TimeSpan.FromSeconds(30);
        settings.BeforeCall = call => {
            Console.WriteLine($"Call started: {call.Request.Url}");
        };
        settings.AfterCall = call => {
            Console.WriteLine($"Call completed: {call.Response.StatusCode}");
        };
    });

// Rate limiting support
var rateLimitedData = await "https://api.example.com/rate-limited"
    .WithHeader("X-RateLimit-User", "user123")
    .OnError(call => {
        if (call.Response?.StatusCode == 429) // Too Many Requests
        {
            var retryAfter = call.Response.Headers.FirstOrDefault("Retry-After");
            Console.WriteLine($"Rate limited: retry after {retryAfter} seconds");
        }
    })
    .GetJsonAsync<RateLimitedData>();

Error Handling and Retry Functionality

using Flurl;
using Flurl.Http;
using Polly;
using Polly.Extensions.Http;

// Comprehensive error handling
async Task<T> SafeApiCall<T>(string url) where T : class
{
    try
    {
        return await url
            .WithTimeout(30)
            .GetJsonAsync<T>();
    }
    catch (FlurlHttpTimeoutException)
    {
        Console.WriteLine("Timeout error: Request did not complete within time limit");
        return null;
    }
    catch (FlurlHttpException ex)
    {
        Console.WriteLine($"HTTP error: {ex.StatusCode}");
        
        if (ex.StatusCode == 401)
        {
            Console.WriteLine("Authentication error: Please check your token");
        }
        else if (ex.StatusCode == 403)
        {
            Console.WriteLine("Permission error: Access denied");
        }
        else if (ex.StatusCode == 404)
        {
            Console.WriteLine("Not found: Resource does not exist");
        }
        else if (ex.StatusCode == 429)
        {
            Console.WriteLine("Rate limit: Please wait before retrying");
        }
        else if (ex.StatusCode >= 500)
        {
            Console.WriteLine("Server error: Problem on server side");
            var errorDetails = await ex.GetResponseStringAsync();
            Console.WriteLine($"Error details: {errorDetails}");
        }
        
        return null;
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Unexpected error: {ex.Message}");
        return null;
    }
}

// Usage example
var userData = await SafeApiCall<UserData>("https://api.example.com/users/123");
if (userData != null)
{
    Console.WriteLine($"User name: {userData.Name}");
}

// Flurl's built-in error handling
var result = await "https://api.example.com/data"
    .OnError(call => {
        Console.WriteLine($"Error occurred: {call.Exception.Message}");
        Console.WriteLine($"URL: {call.Request.Url}");
        Console.WriteLine($"Status: {call.Response?.StatusCode}");
        
        // Detailed error logging
        LogApiError(call);
    })
    .OnErrorAsync(async call => {
        // Asynchronous error handling
        await LogApiErrorAsync(call);
    })
    .GetJsonAsync<ApiData>();

// Advanced retry functionality using Polly library
private static readonly HttpPolicyWrap<HttpResponseMessage> RetryPolicy = 
    Policy.WrapAsync(
        // Retry with exponential backoff
        HttpPolicyExtensions
            .HandleTransientHttpError()
            .OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests)
            .WaitAndRetryAsync(
                retryCount: 3,
                sleepDurationProvider: retryAttempt => 
                    TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // 2, 4, 8 seconds
                onRetry: (outcome, timespan, retryCount, context) => {
                    Console.WriteLine($"Retry {retryCount}: after {timespan} seconds");
                }),
        
        // Circuit breaker
        HttpPolicyExtensions
            .HandleTransientHttpError()
            .CircuitBreakerAsync(
                handledEventsAllowedBeforeBreaking: 3,
                durationOfBreak: TimeSpan.FromSeconds(30),
                onBreak: (exception, duration) => {
                    Console.WriteLine($"Circuit breaker opened: for {duration} seconds");
                },
                onReset: () => {
                    Console.WriteLine("Circuit breaker reset");
                })
    );

// HTTP client configuration using Polly policy
FlurlHttp.Configure(settings => {
    settings.HttpClientFactory = new PollyHttpClientFactory(RetryPolicy);
});

// Manual retry implementation
async Task<T> RetryApiCall<T>(string url, int maxRetries = 3) where T : class
{
    var delay = TimeSpan.FromSeconds(1);
    
    for (int attempt = 1; attempt <= maxRetries; attempt++)
    {
        try
        {
            return await url.GetJsonAsync<T>();
        }
        catch (FlurlHttpException ex) when (
            ex.StatusCode >= 500 || 
            ex.StatusCode == 429 ||
            ex.StatusCode == 408) // Retryable status codes
        {
            if (attempt == maxRetries)
            {
                Console.WriteLine($"Maximum attempts reached: {ex.StatusCode}");
                throw;
            }
            
            Console.WriteLine($"Attempt {attempt} failed. Retrying in {delay.TotalSeconds} seconds...");
            await Task.Delay(delay);
            delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); // Exponential backoff
        }
    }
    
    return null;
}

// Detailed status code handling
var response = await "https://api.example.com/status-check"
    .AllowHttpStatus("200-299,404") // Treat 404 as success too
    .GetAsync();

switch (response.StatusCode)
{
    case 200:
        var data = await response.GetJsonAsync<ApiData>();
        Console.WriteLine($"Success: {data}");
        break;
    case 401:
        Console.WriteLine("Authentication error: New token required");
        await RefreshAuthToken();
        break;
    case 403:
        Console.WriteLine("Permission error: Contact administrator");
        break;
    case 404:
        Console.WriteLine("Resource not found (normal case)");
        break;
    case 429:
        var retryAfter = response.Headers.FirstOrDefault("Retry-After");
        Console.WriteLine($"Rate limited: wait {retryAfter} seconds");
        break;
    default:
        Console.WriteLine($"Unexpected status: {response.StatusCode}");
        break;
}

// Custom exception handling class
public class ApiClient
{
    private readonly string _baseUrl;
    private readonly string _apiKey;
    
    public ApiClient(string baseUrl, string apiKey)
    {
        _baseUrl = baseUrl;
        _apiKey = apiKey;
    }
    
    public async Task<T> GetAsync<T>(string endpoint) where T : class
    {
        try
        {
            return await _baseUrl
                .AppendPathSegment(endpoint)
                .WithHeader("X-API-Key", _apiKey)
                .WithTimeout(30)
                .GetJsonAsync<T>();
        }
        catch (FlurlHttpException ex)
        {
            await HandleApiException(ex);
            throw;
        }
    }
    
    private async Task HandleApiException(FlurlHttpException ex)
    {
        var errorResponse = await ex.GetResponseStringAsync();
        
        // Logging
        Console.WriteLine($"API Error: {ex.StatusCode} - {errorResponse}");
        
        // Notify external monitoring system
        await NotifyMonitoringSystem(ex);
    }
    
    private async Task NotifyMonitoringSystem(FlurlHttpException ex)
    {
        // Implementation for notifying monitoring system
        await Task.CompletedTask;
    }
}

Concurrent Processing and Performance Optimization

using Flurl;
using Flurl.Http;
using System.Collections.Concurrent;

// Parallel fetching of multiple URLs
async Task<List<T>> FetchMultipleEndpoints<T>(IEnumerable<string> endpoints) where T : class
{
    var semaphore = new SemaphoreSlim(5, 5); // Maximum 5 parallel
    var results = new ConcurrentBag<T>();
    
    var tasks = endpoints.Select(async endpoint => {
        await semaphore.WaitAsync();
        try
        {
            var data = await endpoint.GetJsonAsync<T>();
            results.Add(data);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error {endpoint}: {ex.Message}");
        }
        finally
        {
            semaphore.Release();
        }
    });
    
    await Task.WhenAll(tasks);
    return results.ToList();
}

// Usage example
var endpoints = new[]
{
    "https://api.example.com/users",
    "https://api.example.com/posts", 
    "https://api.example.com/comments",
    "https://api.example.com/categories"
};

var allData = await FetchMultipleEndpoints<ApiData>(endpoints);
Console.WriteLine($"Retrieved data count: {allData.Count}");

// Pagination-aware complete data fetching
async Task<List<T>> FetchAllPages<T>(string baseUrl, string accessToken) where T : class
{
    var allItems = new List<T>();
    var page = 1;
    const int pageSize = 100;
    
    while (true)
    {
        try
        {
            var response = await baseUrl
                .SetQueryParams(new { page, per_page = pageSize })
                .WithOAuthBearerToken(accessToken)
                .GetJsonAsync<PagedResponse<T>>();
            
            if (response.Items == null || !response.Items.Any())
                break;
            
            allItems.AddRange(response.Items);
            Console.WriteLine($"Page {page} retrieved: {response.Items.Count} items");
            
            if (!response.HasMore || response.Items.Count < pageSize)
                break;
                
            page++;
            
            // API load reduction
            await Task.Delay(100);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error on page {page}: {ex.Message}");
            break;
        }
    }
    
    Console.WriteLine($"Total items retrieved: {allItems.Count}");
    return allItems;
}

// Efficient batch processing
async Task<BatchResult<T>> ProcessBatch<T>(IEnumerable<BatchRequest> requests) where T : class
{
    var results = new ConcurrentDictionary<string, T>();
    var errors = new ConcurrentDictionary<string, Exception>();
    
    var parallelOptions = new ParallelOptions
    {
        MaxDegreeOfParallelism = Environment.ProcessorCount
    };
    
    await Parallel.ForEachAsync(requests, parallelOptions, async (request, ct) => {
        try
        {
            var result = await request.Url
                .WithHeaders(request.Headers)
                .WithTimeout(request.Timeout ?? TimeSpan.FromSeconds(30))
                .GetJsonAsync<T>(cancellationToken: ct);
                
            results.TryAdd(request.Id, result);
        }
        catch (Exception ex)
        {
            errors.TryAdd(request.Id, ex);
        }
    });
    
    return new BatchResult<T>
    {
        Successful = results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
        Failed = errors.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
        TotalProcessed = requests.Count()
    };
}

// Cached client with caching functionality
public class CachedApiClient
{
    private readonly MemoryCache _cache;
    private readonly string _baseUrl;
    
    public CachedApiClient(string baseUrl)
    {
        _baseUrl = baseUrl;
        _cache = new MemoryCache(new MemoryCacheOptions
        {
            SizeLimit = 100
        });
    }
    
    public async Task<T> GetWithCache<T>(string endpoint, TimeSpan? cacheExpiry = null) where T : class
    {
        var cacheKey = $"{_baseUrl}/{endpoint}";
        
        if (_cache.TryGetValue(cacheKey, out T cachedResult))
        {
            Console.WriteLine($"Cache hit: {cacheKey}");
            return cachedResult;
        }
        
        var result = await _baseUrl
            .AppendPathSegment(endpoint)
            .GetJsonAsync<T>();
        
        var cacheOptions = new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = cacheExpiry ?? TimeSpan.FromMinutes(5),
            Size = 1
        };
        
        _cache.Set(cacheKey, result, cacheOptions);
        Console.WriteLine($"Cache added: {cacheKey}");
        
        return result;
    }
}

// Streaming processing (for large files)
async Task DownloadLargeFile(string fileUrl, string localPath, IProgress<long> progress = null)
{
    using var response = await fileUrl
        .WithTimeout(TimeSpan.FromMinutes(30))
        .GetStreamAsync();
    
    using var fileStream = File.Create(localPath);
    
    var buffer = new byte[8192];
    long totalBytesRead = 0;
    int bytesRead;
    
    while ((bytesRead = await response.ReadAsync(buffer, 0, buffer.Length)) > 0)
    {
        await fileStream.WriteAsync(buffer, 0, bytesRead);
        totalBytesRead += bytesRead;
        
        progress?.Report(totalBytesRead);
    }
    
    Console.WriteLine($"Download completed: {localPath} ({totalBytesRead:N0} bytes)");
}

// Download with progress usage example
var progress = new Progress<long>(bytesDownloaded => {
    Console.WriteLine($"Download progress: {bytesDownloaded:N0} bytes");
});

await DownloadLargeFile(
    "https://api.example.com/files/large-dataset.zip",
    "/tmp/dataset.zip", 
    progress
);

Framework Integration and Test Implementation

using Flurl;
using Flurl.Http;
using Flurl.Http.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Xunit;

// API service for ASP.NET Core integration
public interface IApiService
{
    Task<T> GetAsync<T>(string endpoint) where T : class;
    Task<T> PostAsync<T>(string endpoint, object data) where T : class;
}

public class FlurlApiService : IApiService
{
    private readonly IFlurlClient _client;
    
    public FlurlApiService(IFlurlClient client)
    {
        _client = client;
    }
    
    public async Task<T> GetAsync<T>(string endpoint) where T : class
    {
        return await _client
            .Request(endpoint)
            .GetJsonAsync<T>();
    }
    
    public async Task<T> PostAsync<T>(string endpoint, object data) where T : class
    {
        return await _client
            .Request(endpoint)
            .PostJsonAsync(data)
            .ReceiveJson<T>();
    }
}

// DI configuration in Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    // Register Flurl client
    services.AddSingleton<IFlurlClient>(sp => 
        new FlurlClient("https://api.example.com")
            .Configure(settings => {
                settings.Timeout = TimeSpan.FromSeconds(30);
                settings.BeforeCall = call => {
                    var logger = sp.GetService<ILogger<FlurlApiService>>();
                    logger?.LogInformation($"API call: {call.Request.Url}");
                };
            })
    );
    
    services.AddScoped<IApiService, FlurlApiService>();
}

// Unit test implementation
public class ApiServiceTests
{
    [Fact]
    public async Task GetUsers_ShouldReturnUserList()
    {
        // Arrange
        using var httpTest = new HttpTest();
        
        var expectedUsers = new[]
        {
            new User { Id = 1, Name = "John Doe" },
            new User { Id = 2, Name = "Jane Smith" }
        };
        
        httpTest.RespondWithJson(expectedUsers);
        
        var apiService = new FlurlApiService(
            new FlurlClient("https://api.example.com")
        );
        
        // Act
        var users = await apiService.GetAsync<User[]>("users");
        
        // Assert
        Assert.Equal(2, users.Length);
        Assert.Equal("John Doe", users[0].Name);
        
        // Verify HTTP call
        httpTest.ShouldHaveCalled("https://api.example.com/users")
            .WithVerb(HttpMethod.Get)
            .Times(1);
    }
    
    [Fact]
    public async Task CreateUser_WithValidData_ShouldReturnCreatedUser()
    {
        // Arrange
        using var httpTest = new HttpTest();
        
        var newUser = new { Name = "Mike Johnson", Email = "[email protected]" };
        var createdUser = new User { Id = 3, Name = "Mike Johnson", Email = "[email protected]" };
        
        httpTest.RespondWithJson(createdUser, 201);
        
        var apiService = new FlurlApiService(
            new FlurlClient("https://api.example.com")
        );
        
        // Act
        var result = await apiService.PostAsync<User>("users", newUser);
        
        // Assert
        Assert.Equal(3, result.Id);
        Assert.Equal("Mike Johnson", result.Name);
        
        // Verify request content
        httpTest.ShouldHaveCalled("https://api.example.com/users")
            .WithVerb(HttpMethod.Post)
            .WithContentType("application/json")
            .WithRequestJson(newUser);
    }
    
    [Fact]
    public async Task GetUser_WhenNotFound_ShouldThrowException()
    {
        // Arrange
        using var httpTest = new HttpTest();
        
        httpTest.RespondWith("User not found", 404);
        
        var apiService = new FlurlApiService(
            new FlurlClient("https://api.example.com")
        );
        
        // Act & Assert
        var exception = await Assert.ThrowsAsync<FlurlHttpException>(
            () => apiService.GetAsync<User>("users/999")
        );
        
        Assert.Equal(404, exception.StatusCode);
    }
    
    [Fact]
    public async Task ApiCall_WithTimeout_ShouldThrowTimeoutException()
    {
        // Arrange
        using var httpTest = new HttpTest();
        
        httpTest.SimulateTimeout();
        
        var apiService = new FlurlApiService(
            new FlurlClient("https://api.example.com")
                .Configure(settings => settings.Timeout = TimeSpan.FromMilliseconds(100))
        );
        
        // Act & Assert
        await Assert.ThrowsAsync<FlurlHttpTimeoutException>(
            () => apiService.GetAsync<User[]>("users")
        );
    }
}

// Integration test example
public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Startup>>
{
    private readonly WebApplicationFactory<Startup> _factory;
    
    public ApiIntegrationTests(WebApplicationFactory<Startup> factory)
    {
        _factory = factory;
    }
    
    [Fact]
    public async Task GetWeatherForecast_ShouldReturnForecast()
    {
        // Arrange
        var client = _factory.CreateClient();
        var baseUrl = client.BaseAddress.ToString();
        
        // Act
        var forecast = await baseUrl
            .AppendPathSegment("WeatherForecast")
            .GetJsonAsync<WeatherForecast[]>();
        
        // Assert
        Assert.NotEmpty(forecast);
        Assert.All(forecast, f => Assert.InRange(f.TemperatureC, -20, 55));
    }
}

// Test helpers for mocking
public static class FlurlTestHelpers
{
    public static HttpTest SetupMockApi(string baseUrl)
    {
        var httpTest = new HttpTest();
        
        // Configure common response headers
        httpTest.WithSettings(settings => {
            settings.FakeHttpMethod = true;
            settings.BeforeCall = call => {
                call.Response.Headers.Add("X-Test-Mode", "true");
            };
        });
        
        return httpTest;
    }
    
    public static void MockSuccessResponse<T>(this HttpTest httpTest, T data, int statusCode = 200)
    {
        httpTest.RespondWithJson(data, statusCode);
    }
    
    public static void MockErrorResponse(this HttpTest httpTest, string message, int statusCode)
    {
        httpTest.RespondWith(message, statusCode);
    }
}

// Test helper usage example
[Fact]
public async Task TestWithHelper()
{
    using var httpTest = FlurlTestHelpers.SetupMockApi("https://api.example.com");
    
    httpTest.MockSuccessResponse(new { Message = "Success" });
    
    var result = await "https://api.example.com/test"
        .GetJsonAsync<dynamic>();
    
    Assert.Equal("Success", result.Message);
}