Trellis.Http — API Reference
Package: Trellis.Http
Namespace: Trellis.Http
Purpose: Bridge Task<HttpResponseMessage> into Task<Result<HttpResponseMessage>> pipelines and deserialize JSON payloads into Result<T> / Result<Maybe<T>>.
See also: trellis-api-cookbook.md — recipes using this package, trellis-api-http-abstractions.md for the wrapped HttpError and moved HTTP value types.
Bare
ToResultAsync()is strict by default: non-2xx responses become typed Trellis failures instead of remaining on the success track.
Use this file when
- You are adapting
HttpClientcalls into TrellisResultpipelines. - You need optional-resource behavior where
404meansMaybe<T>.None. - You need response disposal rules for
HttpResponseMessagewhen chaining status mapping and JSON reads.
Patterns Index
| Goal | Canonical API / pattern | See |
|---|---|---|
| Strictly fail non-2xx responses | client.GetAsync(...).ToResultAsync() |
HttpResponseExtensions |
| Map one expected status to a domain error | HandleNotFoundAsync, HandleConflictAsync, HandleUnauthorizedAsync |
HttpResponseExtensions |
| Map several statuses | ToResultAsync(status => status switch { ... }) |
Multi-status mapping |
| Inspect an error body before deciding | Body-aware ToResultAsync((response, ct) => ...) |
Body-aware mapping |
| Deserialize a required JSON body | .ReadJsonAsync(jsonTypeInfo, ct) |
HttpResponseExtensions |
| Deserialize optional JSON body | .ReadJsonMaybeAsync(jsonTypeInfo, ct) |
HttpResponseExtensions |
Treat 404 as expected absence |
.ReadJsonOrNoneOn404Async(jsonTypeInfo, ct) |
HttpResponseExtensions |
Common traps
- Once you call any
ReadJson*terminal helper, the response is disposed by the helper. ReadJsonMaybeAsynctreats204,205, empty body, and JSONnullasMaybe.None; invalid JSON returnsFail<Unexpected>.- Use
ReadJsonOrNoneOn404Asyncfor optional reads. Do not hand-roll a separate 404 branch unless you need custom behavior.
Type
HttpResponseExtensions
public static class HttpResponseExtensions
| Signature | Returns | Notes |
|---|---|---|
ToResultAsync(this Task<HttpResponseMessage> response, Func<HttpStatusCode, Error?>? statusMap = null) |
Task<Result<HttpResponseMessage>> |
When statusMap is null, 2xx statuses pass through as Ok(response) and non-2xx statuses map to typed Trellis errors with response-header context preserved — see Strict default with header context. When supplied, a null return passes through; a non-null Error becomes Fail and the underlying response is disposed. |
ToResultAsync(this Task<HttpResponseMessage> response, Func<HttpResponseMessage, CancellationToken, Task<Error?>> mapper, CancellationToken ct = default) |
Task<Result<HttpResponseMessage>> |
Body-aware bridge. The mapper is invoked only when IsSuccessStatusCode == false. null return -> Ok(response); non-null -> Fail (response disposed). |
HandleNotFoundAsync(this Task<HttpResponseMessage> response, Error.NotFound error) |
Task<Result<HttpResponseMessage>> |
Maps 404 to Fail(error) (response disposed); any other status passes through as Ok(response). Throws ArgumentNullException when error is null. |
HandleConflictAsync(this Task<HttpResponseMessage> response, Error.Conflict error) |
Task<Result<HttpResponseMessage>> |
Maps 409 to Fail(error) (response disposed); pass through otherwise. Throws ArgumentNullException when error is null. |
HandleUnauthorizedAsync(this Task<HttpResponseMessage> response, Error.AuthenticationRequired error) |
Task<Result<HttpResponseMessage>> |
Maps 401 to Fail(error) (response disposed); pass through otherwise. Throws ArgumentNullException when error is null. |
ReadJsonAsync<T>(this Task<Result<HttpResponseMessage>> response, JsonTypeInfo<T> jsonTypeInfo, CancellationToken ct = default) where T : notnull |
Task<Result<T>> |
Already-failed input short-circuits with the upstream error. Otherwise reads the body and deserializes; non-success status, 204, 205, empty body, null payload, or JsonException (caught) all map to Fail<Unexpected>. JSON-parse failures use only JsonException.LineNumber / BytePositionInLine — never Message, never Path (which can include user-controlled dictionary keys), never the response body — so user data echoed by the upstream cannot leak into the failure detail. Always disposes the response after reading, including on the null-jsonTypeInfo path. |
ReadJsonMaybeAsync<T>(this Task<Result<HttpResponseMessage>> response, JsonTypeInfo<T> jsonTypeInfo, CancellationToken ct = default) where T : notnull |
Task<Result<Maybe<T>>> |
Already-failed input short-circuits. Non-success status -> Fail<Unexpected>. 204, 205, empty body, JSON null -> Ok(Maybe.None). Invalid JSON maps to Fail<Unexpected> using only JsonException.LineNumber / BytePositionInLine — never Message, never Path, never the response body — matching ReadJsonAsync<T>'s sanitization policy. Always disposes the response, including on the null-jsonTypeInfo path. |
ReadJsonOrNoneOn404Async<T>(this Task<HttpResponseMessage> response, JsonTypeInfo<T> jsonTypeInfo, CancellationToken ct = default) where T : notnull |
Task<Result<Maybe<T>>> |
Terminal optional-resource helper. 404 -> Ok(Maybe.None); other non-2xx statuses use strict status mapping; 204, 205, empty body, and JSON null keep ReadJsonMaybeAsync semantics. Always disposes the response. |
Business API default. Bare
ToResultAsync()is now the safe default for domain-facing HTTP clients. UseHandleNotFoundAsync,HandleConflictAsync,HandleUnauthorizedAsync, or an explicitstatusMaponly when the endpoint needs domain-specific error payloads.
Strict default with header context
When statusMap is omitted, the default mapper in Trellis.Http/src/HttpResponseExtensions.cs translates known upstream statuses to typed Trellis errors. The 405/406/412/413/415/416/428 shapes stay wrapped in Error.TransportFault(new HttpError.*(...)); the remaining statuses use the transport-neutral core cases.
| Status | Becomes |
|---|---|
400 |
Error.InvalidInput.ForRule(code: "http.bad-request") |
401 |
new Error.AuthenticationRequired() |
403 |
new Error.Forbidden("http.forbidden") |
404 |
new Error.NotFound(resource) |
405 (with Allow) |
new Error.TransportFault(new HttpError.MethodNotAllowed(allow)) |
405 (no Allow) |
new Error.Unexpected(FaultCodes.HttpResponseFault, faultId) |
406 |
new Error.TransportFault(new HttpError.NotAcceptable(EquatableArray<string>.Empty)) |
409 |
new Error.Conflict(Resource: null, Code: "http.conflict") |
410 |
new Error.Gone(resource) |
412 |
new Error.TransportFault(new HttpError.PreconditionFailed(resource, PreconditionKind.IfMatch)) |
413 |
new Error.TransportFault(new HttpError.ContentTooLarge()) |
415 |
new Error.TransportFault(new HttpError.UnsupportedMediaType(EquatableArray<string>.Empty)) |
416 (with Content-Range length) |
new Error.TransportFault(new HttpError.RangeNotSatisfiable(length, unit)) |
422 |
Error.InvalidInput.ForRule(code: "http.unprocessable-content") |
428 |
new Error.TransportFault(new HttpError.PreconditionRequired(PreconditionKind.IfMatch)) |
429 |
new Error.RateLimited(retryAdvice) (parses Retry-After into RetryAdvice) |
501 |
new Error.Unexpected(FaultCodes.NotImplemented) |
503 |
new Error.Unavailable(Retry: retryAdvice) (parses Retry-After into RetryAdvice) |
other / 5xx default |
new Error.Unexpected(FaultCodes.HttpResponseFault, faultId) |
A 416 without a known Content-Range length, or a 405 without Allow, also falls through to new Error.Unexpected(FaultCodes.HttpResponseFault, faultId), where faultId is a freshly generated per-incident identifier carried in FaultId — the Code stays stable so telemetry can aggregate these. The JSON readers use the same shape with FaultCodes.HttpResponseNotSuccess, FaultCodes.HttpResponseNoBody, and FaultCodes.HttpResponseInvalidBody. The strict default parses Retry-After into RetryAdvice for the core 429 / 503 cases (delta-seconds → After, HTTP-date → At); WWW-Authenticate on 401 is not parsed into Error.AuthenticationRequired. Use a custom status map or the body-aware overload when an endpoint-specific contract needs richer detail.
Exception propagation
The Trellis.Http extensions deliberately do not swallow non-Result-shaped exceptions; they propagate as-is so the caller's existing try / catch strategy (or the host's middleware) handles them:
HttpRequestException— propagates from any extension when the underlyingTask<HttpResponseMessage>faults (DNS failure, connection refused, TLS handshake error, etc.).OperationCanceledException/TaskCanceledException— propagates when theCancellationTokenis signaled or the upstreamHttpClienttimes out.JsonException— caught and mapped toFail<Unexpected>byReadJsonAsync<T>,ReadJsonMaybeAsync<T>, andReadJsonOrNoneOn404Async<T>(which delegates toReadJsonMaybeAsync<T>after the 404 check). Parser details are sanitized to structured line / byte position only.
Any of these cases that surface inside a try block where a response has already been awaited still trigger the disposal contract described below before the exception escapes.
3xx redirects under strict default
HttpClient follows redirects automatically by default. Callers who set HttpClientHandler.AllowAutoRedirect = false (e.g. SSO landing-page detection) must use ToResultAsync(statusMap) or the body-aware overload to handle 3xx — the strict default folds them into Error.Unexpected along with all other unmapped statuses, which is intentional fail-fast behavior for the "domain-facing client" use case.
Disposal contract
The library owns the HttpResponseMessage lifecycle on terminal or transformative paths:
ToResultAsync(both overloads) dispose the response on theFailpath.HandleNotFoundAsync,HandleConflictAsync,HandleUnauthorizedAsyncdispose on the matched-statusFailpath.ReadJsonAsync,ReadJsonMaybeAsync, andReadJsonOrNoneOn404Asyncalways dispose after reading, success or failure.- Pass-through paths (success from bare
ToResultAsync, non-matchingHandle*, mapper returningnull) leave disposal to the caller.
In practice: once you call ReadJson*, you no longer need to dispose the response yourself.
Examples
Happy path: GET, map 404, deserialize
using System.Text.Json.Serialization;
using Trellis;
using Trellis.Http;
[JsonSerializable(typeof(TodoDto))]
internal partial class AppJsonContext : JsonSerializerContext { }
public sealed record TodoDto(Guid Id, string Title);
public Task<Result<TodoDto>> GetTodoAsync(HttpClient client, Guid id, CancellationToken ct) =>
client.GetAsync($"/todos/{id}", ct)
.HandleNotFoundAsync(new Error.NotFound(ResourceRef.For<TodoDto>(id)))
.ReadJsonAsync(AppJsonContext.Default.TodoDto, ct);
Multi-status mapping with ToResultAsync(statusMap)
public Task<Result<TodoDto>> GetTodoStrictAsync(HttpClient client, Guid id, CancellationToken ct) =>
client.GetAsync($"/todos/{id}", ct)
.ToResultAsync(status => status switch
{
HttpStatusCode.NotFound => new Error.NotFound(ResourceRef.For<TodoDto>(id)),
HttpStatusCode.Forbidden => new Error.Forbidden("todo.read"),
_ when (int)status >= 500 => new Error.Unexpected("todo.upstream-fault", Guid.NewGuid().ToString("N")) { Detail = $"upstream {status}" },
_ => null,
})
.ReadJsonAsync(AppJsonContext.Default.TodoDto, ct);
Body-aware mapping (replaces HandleFailureAsync<TContext>)
public Task<Result<TodoDto>> GetTodoWithProblemDetailsAsync(HttpClient client, Guid id, CancellationToken ct) =>
client.GetAsync($"/todos/{id}", ct)
.ToResultAsync(async (response, token) =>
{
// Read RFC 9457 problem-details body to synthesize a richer error.
var problem = await response.Content
.ReadFromJsonAsync<ProblemDetails>(cancellationToken: token);
return problem is null
? null
: new Error.Unexpected("todo.upstream-fault", Guid.NewGuid().ToString("N")) { Detail = problem.Detail ?? "upstream error" };
}, ct)
.ReadJsonAsync(AppJsonContext.Default.TodoDto, ct);
Optional resource with ReadJsonOrNoneOn404Async
public Task<Result<Maybe<TodoDto>>> FindTodoAsync(HttpClient client, Guid id, CancellationToken ct) =>
client.GetAsync($"/todos/{id}", ct)
.ReadJsonOrNoneOn404Async(AppJsonContext.Default.TodoDto, ct);
Breaking changes from v1
The v1 surface (60+ overloads across two static classes) has been collapsed into a small canonical method set. There are no shims or compatibility redirects: this is a clean cut, taken pre-GA.
| Previous API | Current replacement |
|---|---|
HandleNotFound, HandleNotFoundAsync (sync, Result<HRM>, Task<Result<HRM>> overloads) |
HandleNotFoundAsync(this Task<HttpResponseMessage>, Error.NotFound) |
HandleConflict* |
HandleConflictAsync(this Task<HttpResponseMessage>, Error.Conflict) |
HandleUnauthorized* |
HandleUnauthorizedAsync(this Task<HttpResponseMessage>, Error.AuthenticationRequired) |
HandleForbidden* |
Deleted. Use ToResultAsync(status => status == HttpStatusCode.Forbidden ? new Error.Forbidden(...) : null). |
HandleClientError* (4xx range), HandleServerError* (5xx range) |
Deleted. Use ToResultAsync(statusMap) with a switch over HttpStatusCode. |
EnsureSuccess, EnsureSuccessAsync (all shapes) |
Deleted. Use ToResultAsync(status => (int)status >= 400 ? error : null) or the body-aware ToResultAsync(mapper, ct). |
HandleFailureAsync<TContext> (response-shape and Result<HRM>-shape) |
Deleted. Use the body-aware ToResultAsync(mapper, ct); capture additional state via closure. |
ReadResultFromJsonAsync<T> (sync, Result<HRM>, Task<HRM>, Task<Result<HRM>>) |
Renamed ReadJsonAsync<T>(this Task<Result<HttpResponseMessage>>, JsonTypeInfo<T>, CancellationToken). |
ReadResultMaybeFromJsonAsync<T> (all shapes) |
Renamed ReadJsonMaybeAsync<T>(this Task<Result<HttpResponseMessage>>, JsonTypeInfo<T>, CancellationToken). |
Sync receivers (HttpResponseMessage, Result<HRM>) |
Deleted. Wrap with Task.FromResult(...) if needed; in practice every HttpClient call is already async. |