LiteHttp 1.0.0

dotnet add package LiteHttp --version 1.0.0
                    
NuGet\Install-Package LiteHttp -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LiteHttp" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LiteHttp" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="LiteHttp" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LiteHttp --version 1.0.0
                    
#r "nuget: LiteHttp, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LiteHttp@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LiteHttp&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=LiteHttp&version=1.0.0
                    
Install as a Cake Tool

LiteHttp

A production-grade, allocation-minimal HTTP client wrapper for .NET 8+, built on SocketsHttpHandler.

Features

  • Minimal allocations — wraps a single HttpClient + SocketsHttpHandler as a thread-safe singleton
  • Typed JSON send/receive via System.Text.Json
  • Automatic retry with exponential backoff and full jitter
  • Per-request timeout, headers, bearer token, and API key overrides (fluent API)
  • Streaming support via IAsyncEnumerable<string> with zero body buffering
  • Form-urlencoded, multipart/form-data, and raw multipart (mixed, related, …) helpers
  • Dependency injection support — singleton, named clients, and factory pattern
  • Optional Polly resilience pipeline (circuit breaker + retry) via Microsoft.Extensions.Resilience

Installation

dotnet add package LiteHttp

Build requires .NET 8 SDK or later. See CHANGELOG.md for release history.

Quick start

using LiteHttp;

// Create once and reuse for the lifetime of the application.
var client = new LiteHttpClient(new LiteHttpClientOptions
{
    BaseAddress       = new Uri("https://api.example.com"),
    DefaultTimeout    = TimeSpan.FromSeconds(15),
    DefaultMaxRetries = 3,
});

// GET + deserialize
var post = await client.GetJsonAsync<Post>("/posts/1");

// POST JSON (raw response)
using var response = await client.PostJsonAsync("/posts", new NewPost(1, "Hello", "Body"));
response.EnsureSuccessStatusCode();

// POST JSON (typed response) — throws LiteHttpRequestException (with the response body) on failure
var created = await client.PostJsonAsync<NewPost, Post>("/posts", new NewPost(1, "Title", "Body"));

Configuration

Pass LiteHttpClientOptions at construction time. All properties are init-only.

Property Type Default Description
BaseAddress Uri? null Base URI prepended to all relative paths
DefaultTimeout TimeSpan 30 s Request timeout (overridable per-request)
ConnectTimeout TimeSpan 10 s TCP connection timeout
PooledConnectionLifetime TimeSpan 5 min DNS refresh interval for pooled connections
PooledConnectionIdleTimeout TimeSpan 2 min Idle connection eviction threshold
MaxConnectionsPerServer int 20 Max concurrent connections per endpoint
DefaultMaxRetries int 3 Retry attempts per request (0 = no retry). Each attempt gets its own DefaultTimeout window — see Retry behavior
MaxRetryAfterDelay TimeSpan 60 s Upper bound on how long a retry wait may be when honoring a server's Retry-After header
UseCookies bool false Enable cookie container
DefaultHeaders IReadOnlyDictionary<string, string>? null Headers sent on every request
JsonSerializerOptions JsonSerializerOptions? null Custom JSON serializer options

Per-request options

Every method accepts an optional Action<RequestOptions> configure delegate for per-request overrides.

Property Type Description
Timeout TimeSpan? Overrides DefaultTimeout for this request
MaxRetries int? Overrides DefaultMaxRetries for this request
Headers Dictionary<string, string>? Additional headers merged for this request
BearerToken string? Sets Authorization: Bearer <token>
ApiKey string? Sets X-Api-Key: <key>

Fluent helpers on RequestOptions:

// Bearer token
var post = await client.GetJsonAsync<Post>("/posts/1",
    opts => opts.WithBearer("my-jwt-token"));

// API key
var data = await client.GetJsonAsync<Data>("/data",
    opts => opts.WithApiKey("secret-key"));

// Custom header
using var response = await client.SendAsync(HttpMethod.Get, "/endpoint", body: null,
    opts =>
    {
        opts.Timeout    = TimeSpan.FromSeconds(5);
        opts.MaxRetries = 0;
        opts.WithHeader("X-Request-ID", Guid.NewGuid().ToString());
    });

HTTP methods

GET

// Typed response
var post = await client.GetJsonAsync<Post>("/posts/1", ct: cancellationToken);

POST

// Raw HttpResponseMessage
using var response = await client.PostJsonAsync("/posts", payload, ct: ct);

// Typed response (calls EnsureSuccessStatusCode internally)
var created = await client.PostJsonAsync<NewPost, Post>("/posts", payload, ct: ct);

PUT / PATCH

// Body-less
using var published = await client.PutAsync("/posts/1/publish", ct: ct);
using var archived   = await client.PatchAsync("/posts/1/archive", ct: ct);

// JSON body
using var put   = await client.PutJsonAsync("/posts/1", updated, ct: ct);
using var patch = await client.PatchJsonAsync("/posts/1", delta, ct: ct);

// Typed variants
var result = await client.PutJsonAsync<NewPost, Post>("/posts/1", updated, ct: ct);

DELETE

using var response = await client.DeleteAsync("/posts/1", ct: ct);

Form POST

var fields = new[]
{
    new KeyValuePair<string, string>("title", "Test"),
    new KeyValuePair<string, string>("body",  "Hello"),
};
using var response = await client.PostFormAsync("/submit", fields, ct: ct);

Multipart / file upload

PostMultipartAsync has two overloads depending on the content type you need.

multipart/form-data — builder delegate; the client creates the MultipartFormDataContent for you:

using var response = await client.PostMultipartAsync("/upload", form =>
{
    form.Add(new StringContent("My title"), "title");
    form.Add(new StreamContent(fileStream), "file", "report.pdf");
}, ct: ct);

Raw MultipartContent factory — full control over the subtype (mixed, related, or any custom value). The factory is called on each retry attempt to produce a fresh, unread instance:

using var response = await client.PostMultipartAsync("/batch", () =>
{
    var content = new MultipartContent("mixed");
    content.Add(new StringContent("{\"op\":\"query\"}", Encoding.UTF8, "application/json"));
    content.Add(new ByteArrayContent(binaryData) { Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } });
    return content;
}, ct: ct);

Streaming (SSE / NDJSON)

StreamLinesAsync reads with HttpCompletionOption.ResponseHeadersRead and yields non-empty lines without buffering the full body. Each line read resets an idle timeout (the request's Timeout, or DefaultTimeout) — a stream that produces no data for a full timeout window throws TimeoutException instead of hanging indefinitely.

await foreach (var line in client.StreamLinesAsync("/events", ct: ct))
{
    Console.WriteLine(line);
}

Dependency injection

using LiteHttp.Extensions;

// Single client — resolves as ILiteHttpClient
services.AddLiteHttpClient(new LiteHttpClientOptions
{
    BaseAddress    = new Uri("https://api.example.com"),
    DefaultTimeout = TimeSpan.FromSeconds(10),
});

// Consume in a service
public class MyService(ILiteHttpClient client) { ... }

Named clients

Register multiple upstream services and retrieve them via ILiteHttpClientFactory. ILiteHttpClientFactory is registered automatically by the first AddNamedLiteHttpClient call — do not register it manually.

using LiteHttp.Extensions;

services.AddNamedLiteHttpClient("payments", new LiteHttpClientOptions
{
    BaseAddress    = new Uri("https://payments.example.com"),
    DefaultTimeout = TimeSpan.FromSeconds(20),
    DefaultHeaders = new Dictionary<string, string> { ["X-Source"] = "backend-api" },
});

services.AddNamedLiteHttpClient("notifications", new LiteHttpClientOptions
{
    BaseAddress = new Uri("https://notify.example.com"),
});

// Consume in a service
public class OrderService(ILiteHttpClientFactory factory)
{
    private readonly ILiteHttpClient _payments       = factory.GetClient("payments");
    private readonly ILiteHttpClient _notifications  = factory.GetClient("notifications");
}

ILiteHttpClientFactory is only registered when at least one named client is added. Calling only AddLiteHttpClient and then injecting ILiteHttpClientFactory will throw at runtime.

DefaultLiteHttpClientFactory implements IDisposable and disposes every named client it holds when the DI container is disposed — you don't need to dispose named clients yourself.

Registering two named clients under the same name throws InvalidOperationException at startup rather than silently discarding the first registration.

Resilience (Polly)

Both methods wrap the client in a ResiliencePipeline<HttpResponseMessage> built with Microsoft.Extensions.Resilience. When active, the client's own DefaultMaxRetries is automatically set to 0 to prevent double-retrying.

Single client — resolves as ILiteHttpClient:

services.AddLiteHttpClientWithResilience(new LiteHttpClientOptions
{
    BaseAddress = new Uri("https://api.example.com"),
});

Named clients — retrieve via ILiteHttpClientFactory. Use this when multiple upstream services each need their own resilience pipeline:

services.AddNamedLiteHttpClientWithResilience("payments", new LiteHttpClientOptions
{
    BaseAddress    = new Uri("https://payments.example.com"),
    DefaultTimeout = TimeSpan.FromSeconds(20),
});

services.AddNamedLiteHttpClientWithResilience("notifications", new LiteHttpClientOptions
{
    BaseAddress = new Uri("https://notify.example.com"),
});

// Consume in a service
public class OrderService(ILiteHttpClientFactory factory)
{
    private readonly ILiteHttpClient _payments = factory.GetClient("payments");
}

Both methods accept an optional Action<ResiliencePipelineBuilder<HttpResponseMessage>> to replace the default pipeline:

services.AddNamedLiteHttpClientWithResilience("payments", options, pipeline =>
{
    pipeline.AddRetry(new RetryStrategyOptions<HttpResponseMessage> { MaxRetryAttempts = 5 });
});

Default circuit breaker parameters:

Parameter Value
Failure ratio threshold 50%
Minimum throughput 10 requests
Sampling window 30 s
Break duration 15 s

Retry behavior

The built-in retry loop (used when DefaultMaxRetries > 0 and no Polly pipeline is active) triggers on:

Status codes: 408, 429, 500, 502, 503, 504

Exceptions: HttpRequestException, TimeoutException, IOException

All of these represent failures where the request either never reached the origin's application logic or explicitly asked to be retried, so retrying is safe by default even for non-idempotent methods (POST/PATCH). A custom resilience pipeline with broader retry conditions must preserve that property itself.

Back-off uses exponential delay with full jitter, unless the response carries a Retry-After header — in which case that delay is honored instead (capped at MaxRetryAfterDelay, default 60 s):

delay = random(0, min(10 s, 200 ms × 2^attempt))   // no Retry-After header
delay = min(Retry-After, MaxRetryAfterDelay)        // Retry-After header present

Timeouts are per-attempt, not per-request. Each retry attempt gets its own fresh DefaultTimeout (or per-request Timeout) window — a slow attempt that times out consumes one retry rather than exhausting the whole request's time budget. TimeoutException is only thrown once retries are exhausted. Cancelling via your own CancellationToken is unaffected — it always propagates as OperationCanceledException, never converted to TimeoutException.

HttpRequestMessage is rebuilt on each retry attempt.

Error handling

The typed JSON overloads (GetJsonAsync<T>, PostJsonAsync<TReq,TResp>, PutJsonAsync<TReq,TResp>, PatchJsonAsync<TReq,TResp>) and StreamLinesAsync throw LiteHttpRequestException — not the body-less exception from EnsureSuccessStatusCode — on a non-success status code:

try
{
    var post = await client.GetJsonAsync<Post>("/posts/999");
}
catch (LiteHttpRequestException ex)
{
    Console.WriteLine($"{ex.StatusCode}: {ex.ResponseBody}");
}
Member Description
StatusCode The response's HttpStatusCode (inherited from HttpRequestException)
ResponseBody The full response body, if it could be read
Message Status code + a preview of the body, truncated to 2000 characters

Raw-response overloads (PostJsonAsync, PutJsonAsync, PatchJsonAsync, GetAsync, etc.) are unaffected — they still return the HttpResponseMessage as-is for you to inspect or call EnsureSuccessStatusCode() yourself.

Invalid input — a null or empty url, a null HttpMethod, or a null form/multipart delegate — throws ArgumentException/ArgumentNullException immediately, before any request is sent.

Logging

LiteHttp logs via the standard ILogger<LiteHttpClient> abstraction. Log output is controlled by your host's logging configuration — no additional setup required.

When registered via DI (AddLiteHttpClient / AddNamedLiteHttpClient), the logger is resolved optionally — if no ILogger<LiteHttpClient> is registered, the client falls back to a no-op logger instead of throwing at startup. Call AddLogging() on your host to see log output.

Level Event
Information Request sent (Sending HTTP request {Method} {Url})
Information Response received (Received HTTP response {StatusCode} for {Method} {Url} after {ElapsedMs}ms)
Warning Retry triggered (status code or transient exception)
Warning Request timed out
Trace Request and response headers (one line per header); Authorization, X-Api-Key, Cookie, and Set-Cookie values are redacted

The Information logs fire on every attempt, including retries. The elapsed time in the response log reflects the network round-trip for that attempt only, not the total time including backoff delays.

Header logging at Trace is guarded by IsEnabled — no string allocation occurs when Trace is not enabled.

To enable Trace headers in appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "LiteHttp.LiteHttpClient": "Trace"
    }
  }
}

Development

# Build the library
dotnet build src/LiteHttp/

# Build the full solution
dotnet build LiteHttp.sln

# Run all tests
dotnet test tests/LiteHttp.Tests/

# Run a specific test
dotnet test tests/LiteHttp.Tests/ --filter "FullyQualifiedName~Retries_On_503"

Project structure

src/
  LiteHttp/                          # Core library (net8.0)
    LiteHttpClient.cs                # Main client implementation
    LiteHttpClientOptions.cs         # LiteHttpClientOptions + RequestOptions
    LiteHttpRequestException.cs      # Thrown by typed overloads on non-success status
    ILiteHttpClient.cs               # Public interface
    Extensions/
      ServiceCollectionExtensions.cs # DI registration helpers
  LiteHttp.Samples/                  # Runnable usage examples
tests/
  LiteHttp.Tests/                    # xUnit test suite
sample-codes/                        # Historical reference (pre-LiteHttp)
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 273 7/21/2026
1.0.0-preview.7 70 7/15/2026
1.0.0-preview.6 94 6/25/2026
1.0.0-preview.5 68 6/25/2026
1.0.0-preview.4 71 6/21/2026
1.0.0-preview.3 65 6/18/2026
1.0.0-preview.2 60 6/18/2026