CSharpEssentials.Http
6.5.2
dotnet add package CSharpEssentials.Http --version 6.5.2
NuGet\Install-Package CSharpEssentials.Http -Version 6.5.2
<PackageReference Include="CSharpEssentials.Http" Version="6.5.2" />
<PackageVersion Include="CSharpEssentials.Http" Version="6.5.2" />
<PackageReference Include="CSharpEssentials.Http" />
paket add CSharpEssentials.Http --version 6.5.2
#r "nuget: CSharpEssentials.Http, 6.5.2"
#:package CSharpEssentials.Http@6.5.2
#addin nuget:?package=CSharpEssentials.Http&version=6.5.2
#tool nuget:?package=CSharpEssentials.Http&version=6.5.2
CSharpEssentials.Http
HttpClient extensions that return Result<T> instead of throwing exceptions. Built-in status-code-to-error mapping, fluent request builder, Polly resilience, and redirect handling.
Features
- Result-based HTTP:
GetFromJsonAsResultAsync,PostAsJsonAsResultAsync,PutAsJsonAsResultAsync,PatchAsJsonAsResultAsync,DeleteAsResultAsync. - Automatic Error Mapping: HTTP codes mapped to
ErrorType(Validation, Unauthorized, NotFound, Conflict, etc.). - Fluent Request Builder: Chain headers, query params, JSON content, and redirects.
- Polly Integration: Retry, timeout, and circuit breaker pipelines.
- Query String Helpers: Build URIs from dictionaries or anonymous objects.
- SSRF Guard (.NET 9+, not available on netstandard2.1):
AddSsrfGuard()blocks requests to private, loopback, link-local and metadata addresses, re-validated on every redirect hop.
Installation
dotnet add package CSharpEssentials.Http
Usage
GET, POST, PUT, PATCH, DELETE as Result
using CSharpEssentials.Http;
using CSharpEssentials.ResultPattern;
HttpClient client = new() { BaseAddress = new Uri("https://api.example.com") };
Result<User> user = await client.GetFromJsonAsResultAsync<User>(new Uri("/users/1", UriKind.Relative));
Result<User> created = await client.PostAsJsonAsResultAsync<User>(
new Uri("/users", UriKind.Relative),
new { Name = "Alice", Age = 30 });
Result deleted = await client.DeleteAsResultAsync(new Uri("/users/1", UriKind.Relative));
PutAsJsonAsResultAsync<T> and PatchAsJsonAsResultAsync<T> follow the same shape. PostAsResultAsync and PutAsResultAsync send a ready-made HttpContent and return a plain Result, and SendAsResultAsync / SendAsResultAsync<T> send an HttpRequestMessage. JSON uses EnhancedJsonSerializerOptions.DefaultOptions unless you pass options.
A 2xx response whose body is empty or cannot be deserialized to T returns a NotFound error. A response read from an HttpContent you already hold goes through ReadAsStringAsResultAsync and ReadFromJsonAsResultAsync<T>; a JsonException there becomes an ErrorType.Validation error.
Status code mapping
A non-success status becomes an Error with code Http.<status> (for example Http.404) and the description HTTP request failed with status code 404 (NotFound).. HttpStatusCodeMapper.ToErrorType(statusCode) and HttpStatusCodeMapper.ToError(statusCode, description) are public.
| Status | ErrorType |
|---|---|
| 400, 411, 413, 414, 415, 416, 417, 422 | Validation |
| 401, 407 | Unauthorized |
| 403 | Forbidden |
| 404, 410 | NotFound |
| 409, 412, 429 | Conflict |
| 408 and every 5xx | Unexpected |
| 402, 405, 406 and every other status | Failure |
Errors, timeouts and cancellation
The HttpClient extensions above, PostAsResultAsync, PutAsResultAsync, SendAsResultAsync, SendWithRedirectsAsResultAsync, HttpRequestBuilder.AsResultAsync and the HttpContent readers (ReadAsStringAsResultAsync, ReadFromJsonAsResultAsync<T>) follow the Result.TryAsync rule: only your own token propagates.
- Cancelling the
cancellationTokenyou pass throwsOperationCanceledException. - An HTTP timeout, that is a
TaskCanceledExceptionwhoseInnerExceptionis aTimeoutException(whatHttpClient.Timeoutthrows), returns a failedResultwithErrorType.Unexpectedand codeHttp.Timeout. - A Polly attempt or total timeout from
Microsoft.Extensions.Http.Resilienceor another Polly handler surfaces asTimeoutRejectedException, so it keepsErrorType.Unexpectedwith codeTimeoutRejectedException, notHttp.Timeout. - Any other cancellation, for example a token a handler links or owns, returns a failed
ResultwithErrorType.Unexpectedand the exception type name as code. - Other transport exceptions return
ErrorType.Unexpectedwith the exception type name as code; an SSRF guard block returnsHttp.SsrfBlocked(see below).
Behaviour change in 6.5.0: before, an HttpClient.Timeout or any other cancelled token threw OperationCanceledException. Handle the failed Result instead; check for the Http.Timeout code to tell a timeout apart.
HttpRequestBuilder
Result<User> result = await HttpRequestBuilder
.Get("https://api.example.com/users/1")
.WithHeader("Accept", "application/json")
.WithQuery("include", "profile")
.AsResultAsync<User>(client);
Result created = await HttpRequestBuilder
.Post("/users")
.WithJsonContent(new { Name = "Bob" })
.AsResultAsync(client);
Query values (WithQuery, Uri.WithQueryString) work with relative and absolute URIs: .Get("/users/1").WithQuery("include", "profile") builds /users/1?include=profile, merged with any existing query and placed before a fragment. Relative request URIs are resolved against HttpClient.BaseAddress.
Factories: Get, Post, Put, Patch, Delete (string or Uri). Builders: WithMethod, WithUri, WithHeader, WithHeaders, WithQuery (name and value, a dictionary, or an object value), WithRoute, WithEnumConventions, WithContent, WithJsonContent, FollowRedirects. Build() returns a Result<HttpRequestMessage>.
Enums in route, query and body
Enums with [StringEnum] are written as wire names; WithEnumConventions(conventions, EnumWireFormat.Number) writes numbers for servers that accept only integers. Flags and enum collections become repeated query keys, and an undefined value is never sent.
using CSharpEssentials.Enums;
using CSharpEssentials.Json;
Result<Order> result = await HttpRequestBuilder
.Get("https://api.example.com/orders/{status}")
.WithEnumConventions(EnumConventions.Default)
.WithRoute("status", OrderStatus.PendingApproval) // /orders/pending_approval
.WithQuery("permissions", Permissions.Read | Permissions.Write) // ?permissions=read&permissions=write
.AsResultAsync<Order>(client, options);
Build options once with new JsonSerializerOptions(JsonSerializerDefaults.Web).AddEnumConventions(conventions, EnumReadMode.Data, writeAs). Responses are then read tolerantly: wire names, member names, aliases and numbers, with the [EnumFallback] member for values a newer server adds. Plain enums without [StringEnum] keep the framework behavior.
Roll out consumers before producers: a server moves its output from Number to String only after every client reads names.
Query String Builder
Uri uri = new Uri("https://api.example.com/search");
Uri withQuery = uri.WithQueryString("q", "csharp").Value;
Uri withDict = uri.WithQueryString(new Dictionary<string, string?> { { "page", "1" } }).Value;
Uri withObject = uri.WithQueryString(new { Q = "csharp", Page = 2 }).Value; // property names become the keys
Result<string> query = new { Q = "csharp", Page = 2 }.ToQueryString(); // "Q=csharp&Page=2"
WithQueryString returns a Result<Uri>. Null values are skipped, values are escaped, and enums follow the enum conventions like in the builder. The object overloads read public properties by reflection.
Polly Resilience
HttpClientResilienceExtensions builds Polly v8 pipelines on top of CSharpEssentials.Resilience:
var pipeline = HttpClientResilienceExtensions.CreateResiliencePipeline(
maxRetryAttempts: 3,
timeout: TimeSpan.FromSeconds(30));
Result<User> result = await pipeline.ExecuteAsResultAsync(async token =>
await client.GetFromJsonAsResultAsync<User>(new Uri("/users/1", UriKind.Relative), cancellationToken: token));
CreateRetryPipeline, CreateTimeoutPipeline, CreateCircuitBreakerPipeline and CreateResiliencePipeline (retry plus timeout) return a ResiliencePipeline; the generic forms return a ResiliencePipeline<Result<T>>. CreateRetryPolicy, CreateTimeoutPolicy, CreateCircuitBreakerPolicy and CreateResiliencePolicy return a ResiliencePolicy instead, whose ExecuteAsync takes the same callback:
ResiliencePolicy policy = HttpClientResilienceExtensions.CreateResiliencePolicy(maxRetryAttempts: 3, timeout: TimeSpan.FromSeconds(30));
Result<User> viaPolicy = await policy.ExecuteAsync(token =>
client.GetFromJsonAsResultAsync<User>(new Uri("/users/1", UriKind.Relative), cancellationToken: token));
maxRetryAttempts counts retries after the first attempt. The timeout defaults to 30 seconds. Failed Result values are retried like exceptions, except Unauthorized, Forbidden, NotFound and Validation errors.
Redirect Following
Result<User> result = await HttpRequestBuilder
.Get("/legacy-url")
.FollowRedirects(maxRedirects: 3)
.AsResultAsync<User>(client);
SSRF Guard
Use it when the URL comes from a user, a tenant or a third party: webhooks, link previews, image fetchers, import-by-URL. Without a guard, a caller can point your server at http://169.254.169.254/ (cloud metadata), http://localhost:6379/ or an internal admin panel. The response then leaks back to them, or the request alone does damage.
services.AddHttpClient("webhooks")
.AddSsrfGuard(options =>
{
options.MaxResponseContentLength = 1_048_576; // 1 MB, enforced while streaming
options.Timeout = TimeSpan.FromSeconds(10);
});
What the guard does:
- Checks resolved addresses, not host names. A
ConnectCallbackonSocketsHttpHandlerresolves DNS itself and checks every address the name returns. If any address is blocked, the request fails, so a name that resolves to[8.8.8.8, 10.0.0.5]is rejected. The socket connects to an address that was already checked, so DNS rebinding between the check and the connect does not work. IP literals (http://127.0.0.1/,http://[::1]/) go through the same check. - Follows redirects itself.
AllowAutoRedirectis off. The guard follows 301/302/303/307/308 itself (default 3 hops) and checks scheme, port and address again on every hop. 303, and 301/302 after a POST, become GET with no body. 307/308 keep the method and body.Authorization,Proxy-AuthorizationandCookieare removed when a redirect changes scheme, host or port. - Disables proxies and the cookie container. A proxy would resolve the name instead of the guard, so the guard turns off
UseProxy.UseCookiesis off, so cookies set by one host are never sent to another. If the primary handler is replaced or reconfigured with a proxy or cookies afterAddSsrfGuard, the first request throwsInvalidOperationException. The primary handler is an internal wrapper around thatSocketsHttpHandler; to tune it, cast((DelegatingHandler)handler).InnerHandlertoSocketsHttpHandler. - Never uses HTTP/3. HTTP/3 runs over QUIC, which does not go through
ConnectCallback, so it would skip the address check. On every hop the guard capsrequest.Versionat HTTP/2 and turnsRequestVersionOrHigherintoRequestVersionOrLower, which also rules out anAlt-Svcupgrade. - Checks the final request again at the primary handler. Handlers added after
AddSsrfGuardrun between the guard and the socket handler. The primary handler applies the HTTP/2 cap and the request policy (scheme, port,RequestPolicy) once more on the request it actually sends, so such a handler cannot switch to HTTP/3 or rewrite the URI to a blocked scheme or port. - Allows only https on port 443 by default. Set
AllowHttp = trueto allow http (port 80 is then added). Adding entries toAllowedPortsreplaces the default ports.
Blocked by default: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24 (6to4 relay anycast), 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255, ::, ::1, ::ffff:0:0:0/96 (SIIT IPv4-translated), 100::/64 (discard-only), fc00::/7, fe80::/10, fec0::/10 (deprecated site-local), ff00::/8, 2001::/32 (Teredo), 2001:2::/48 (benchmarking), 2001:10::/28 (ORCHID), 2001:db8::/32, 3fff::/20 (documentation), 5f00::/16 (SRv6 SIDs), 64:ff9b:1::/48 (local-use NAT64). IPv6 addresses that embed one of the IPv4 ranges are blocked too: IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d), SIIT (::ffff:0:a.b.c.d), NAT64 (64:ff9b::/96) and 6to4 (2002::/16). :: and ::1 are IPv6 addresses in their own right and are not treated as IPv4-compatible. BlockedNetworks matches either the raw address or the embedded IPv4 address. AllowedNetworks matches only the raw address, so 0.0.0.0/0 does not allow ::1. The address policy is asked about the embedded IPv4 address and, when it differs, the raw address too; both must be allowed.
| Option | Default | Meaning |
|---|---|---|
AllowHttp |
false |
Allow http in addition to https |
AllowedPorts |
empty (443, plus 80 with AllowHttp) |
Ports a request or redirect may target |
MaxRedirects |
3 |
Redirect hops; 0 means any redirect throws SsrfBlockedException (the 3xx response is not returned) |
MaxResponseContentLength |
null |
Byte limit, checked against Content-Length and while the body streams |
Timeout |
null |
Total time for all hops plus reading the body |
AllowedNetworks / BlockedNetworks |
empty | IPNetwork exceptions, for example IPNetwork.Parse("10.20.0.0/16") |
AllowedHosts / BlockedHosts |
empty | Host exceptions, exact or *.example.com. *.example.com matches subdomains only, not example.com itself; add both if you need both. Case, a trailing dot and IDN (Unicode or xn-- form) do not matter |
AddressPolicy |
null |
IOutboundAddressPolicy for this client only |
RequestPolicy |
null |
IOutboundRequestPolicy for this client only |
Block lists win over allow lists, and allow lists win over the address policy. A blocked request throws SsrfBlockedException, an HttpRequestException with a Reason of RequestNotAllowed, HostNotAllowed, AddressNotAllowed, RedirectLimitExceeded, ResponseTooLarge or Timeout. The message never contains the resolved address, the query string or user info; read BlockedAddress and the full RequestUri from the exception when you need them for logs. The *AsResultAsync extensions turn it into a failed Result with ErrorType.Forbidden, code Http.SsrfBlocked and the Reason under the reason metadata key. This applies to every reason, including Timeout and ResponseTooLarge, so check the reason metadata to tell a slow or oversized response from a blocked target.
A limit or timeout hit while the body is buffered (for example LoadIntoBufferAsync) can surface as an HttpRequestException whose InnerException is the SsrfBlockedException. Walk the InnerException chain instead of catching only SsrfBlockedException; the *AsResultAsync extensions already do this.
To replace the rules, give your own IOutboundAddressPolicy (which IP addresses may be reached) or IOutboundRequestPolicy (which URIs may be requested). The guard takes the policy from the options first, then from the container, then falls back to the built-in default:
// One client only
services.AddHttpClient("webhooks")
.AddSsrfGuard(options => options.AddressPolicy = new MyAddressPolicy());
// Every guarded client in the container that does not set its own policy
services.AddSingleton<IOutboundAddressPolicy, MyAddressPolicy>();
Limitations:
- The guard is not available on
netstandard2.1, because it needsSocketsHttpHandler.ConnectCallback. - It cannot be combined with an outbound proxy. If you have to use a proxy, enforce the rules on the proxy.
- A custom policy registered in the container applies to every guarded client in that container. Use
AddressPolicy/RequestPolicyon the options to scope a policy to one client. - Policies should be pure and idempotent: the request policy runs twice per hop (in the guard and again in the primary handler), and the address policy may run for both the raw and the normalized form of an address.
- HTTP/3 is never used, because QUIC connections bypass
ConnectCallback. The cap is enforced at the primary handler, so it also holds when a handler added afterAddSsrfGuardraises the version. Such a handler still sees the request before the final check, so do not let it send the request through anotherHttpClient. - Only new connections are checked; a pooled connection was checked when it opened. Configure the primary handler with
ConnectTimeoutandPooledConnectionLifetime(for example throughConfigurePrimaryHttpMessageHandler((handler, _) => ...)) if DNS answers must be re-checked periodically. - Synchronous
HttpClient.Sendis not supported. A synchronousReadon the response stream checks the timeout only before each read, and throws the sameSsrfBlockedExceptionas the asynchronous path. - A 307/308 redirect sends the request body again, also to another origin, the same as the default .NET redirect handling. Bodies that can only be read once (forward-only streams) fail on that hop. Do not put secrets in the body of a request whose redirects you do not trust.
- A wildcard in
AllowedHostsskips the address check for every name under it. Use it only for domains whose DNS you control; otherwise anyone who can add a record there can point it at an internal address. - Retry and resilience handlers should not retry
SsrfBlockedException: the same request is blocked again. Exclude it from the handled exceptions (for exampleShouldHandlein Polly).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. 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 is compatible. 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 is compatible. 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. net11.0 is compatible. |
| .NET Core | netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.1 is compatible. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.1
- CSharpEssentials.Errors (>= 6.5.2)
- CSharpEssentials.Json (>= 6.5.2)
- CSharpEssentials.Resilience (>= 6.5.2)
- CSharpEssentials.Results (>= 6.5.2)
- Polly.Core (>= 8.0.0 && < 9.0.0)
- System.Net.Http.Json (>= 9.0.0)
- System.Text.Json (>= 9.0.4)
-
net10.0
- CSharpEssentials.Errors (>= 6.5.2)
- CSharpEssentials.Json (>= 6.5.2)
- CSharpEssentials.Resilience (>= 6.5.2)
- CSharpEssentials.Results (>= 6.5.2)
- Microsoft.Extensions.Configuration (>= 9.0.4)
- Microsoft.Extensions.Configuration.Binder (>= 9.0.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Http (>= 9.0.4)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
- Polly.Core (>= 8.0.0 && < 9.0.0)
-
net11.0
- CSharpEssentials.Errors (>= 6.5.2)
- CSharpEssentials.Json (>= 6.5.2)
- CSharpEssentials.Resilience (>= 6.5.2)
- CSharpEssentials.Results (>= 6.5.2)
- Microsoft.Extensions.Configuration (>= 9.0.4)
- Microsoft.Extensions.Configuration.Binder (>= 9.0.4)
- Microsoft.Extensions.Http (>= 9.0.4)
- Polly.Core (>= 8.0.0 && < 9.0.0)
-
net9.0
- CSharpEssentials.Errors (>= 6.5.2)
- CSharpEssentials.Json (>= 6.5.2)
- CSharpEssentials.Resilience (>= 6.5.2)
- CSharpEssentials.Results (>= 6.5.2)
- Microsoft.Extensions.Configuration (>= 9.0.4)
- Microsoft.Extensions.Configuration.Binder (>= 9.0.4)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.4)
- Microsoft.Extensions.Http (>= 9.0.4)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
- Polly.Core (>= 8.0.0 && < 9.0.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on CSharpEssentials.Http:
| Package | Downloads |
|---|---|
|
CSharpEssentials
A comprehensive C# library enhancing functional programming capabilities with type-safe monads (Maybe, Result), discriminated unions (Any), and robust error handling. Features include: domain-driven design support, enhanced Entity Framework integration, testable time management, JSON utilities, and LINQ extensions. Built for modern C# development with focus on maintainability, testability, and functional programming principles. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 6.5.2 | 107 | 10/10/2026 |
| 6.5.1 | 101 | 10/10/2026 |
| 6.5.0 | 101 | 10/9/2026 |
| 6.4.0 | 103 | 10/9/2026 |
| 6.3.0 | 113 | 10/8/2026 |
| 6.2.0 | 111 | 10/8/2026 |
| 6.1.0 | 112 | 10/7/2026 |
| 6.0.0 | 116 | 10/7/2026 |
| 5.2.1 | 109 | 10/7/2026 |
| 5.2.0 | 115 | 10/7/2026 |
| 5.1.0 | 108 | 10/7/2026 |
| 5.0.0 | 112 | 10/6/2026 |
| 4.1.0 | 116 | 10/6/2026 |
| 4.0.0 | 112 | 10/5/2026 |
| 3.2.3 | 350 | 5/31/2026 |
| 3.2.2 | 170 | 5/31/2026 |
| 3.2.1 | 164 | 5/31/2026 |
| 3.2.0 | 156 | 5/30/2026 |
| 3.1.0 | 168 | 5/27/2026 |
| 3.0.8 | 163 | 5/20/2026 |