Table of Contents

Http

The DarkPeak.Functional.Http package wraps HttpClient operations in Result<T, Error>, enabling railway-oriented HTTP communication without try/catch blocks.

Installation

dotnet add package DarkPeak.Functional.Http

Basic Usage

using DarkPeak.Functional;
using DarkPeak.Functional.Http;

var result = await httpClient.GetResultAsync<Order>("/api/orders/123");

var message = result.Match(
    success: order => $"Order {order.Id} totals {order.Total:C}",
    failure: error => $"Failed: {error.Message}");

JSON Methods

All JSON methods deserialize the response body using System.Text.Json:

// GET
var order = await httpClient.GetResultAsync<Order>("/api/orders/123");

// POST
var created = await httpClient.PostResultAsync<Order>("/api/orders", newOrder);

// PUT
var updated = await httpClient.PutResultAsync<Order>("/api/orders/123", changes);

// PATCH
var patched = await httpClient.PatchResultAsync<Order>("/api/orders/123", patch);

// DELETE (no response body)
var deleted = await httpClient.DeleteResultAsync("/api/orders/123");

// DELETE (with response body)
var confirmation = await httpClient.DeleteResultAsync<DeletionResult>("/api/orders/123");

// Custom request
var request = new HttpRequestMessage(HttpMethod.Options, "/api/orders");
var options = await httpClient.SendResultAsync<OptionsResponse>(request);

// Custom request (no response body)
var head = new HttpRequestMessage(HttpMethod.Head, "/api/health");
var health = await httpClient.SendResultAsync(head);

Custom JSON Options

All JSON methods accept optional JsonSerializerOptions:

var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var result = await httpClient.GetResultAsync<Order>("/api/orders/123", options);

Non-JSON Response Types

For responses that are not JSON, use the typed GET methods:

// String — plain text, XML, HTML
var html = await httpClient.GetStringResultAsync("/api/reports/summary");

// Stream — large files, binary data (uses ResponseHeadersRead for streaming)
var stream = await httpClient.GetStreamResultAsync("/api/exports/report.csv");

// Bytes — images, files, binary content
var image = await httpClient.GetBytesResultAsync("/api/images/logo.png");

The stream variant uses HttpCompletionOption.ResponseHeadersRead so the response body is not buffered — the caller is responsible for disposing the returned Stream.

API Reference

Method Return Type Use Case
GetResultAsync<T> Result<T, Error> JSON deserialization
PostResultAsync<T> Result<T, Error> JSON POST with response
PutResultAsync<T> Result<T, Error> JSON PUT with response
PatchResultAsync<T> Result<T, Error> JSON PATCH with response
DeleteResultAsync Result<Unit, Error> DELETE without body
DeleteResultAsync<T> Result<T, Error> DELETE with JSON response
SendResultAsync<T> Result<T, Error> Custom request, JSON response
SendResultAsync Result<Unit, Error> Custom request, no body
GetStringResultAsync Result<string, Error> Plain text / XML / HTML
GetStreamResultAsync Result<Stream, Error> Streaming binary data
GetBytesResultAsync Result<byte[], Error> Binary content as byte array

Request Configuration

All methods have an overload accepting Action<HttpRequestMessage> for per-request customization such as adding headers, authentication, or correlation IDs:

var result = await httpClient.GetResultAsync<Order>("/api/orders/123", request =>
{
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
    request.Headers.Add("X-Correlation-Id", correlationId);
});

This works with all method types:

// POST with auth
var created = await httpClient.PostResultAsync<Order>("/api/orders", newOrder, request =>
{
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
});

// DELETE with auth
var deleted = await httpClient.DeleteResultAsync("/api/orders/123", request =>
{
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
});

// Stream with auth
var stream = await httpClient.GetStreamResultAsync("/api/exports/report.csv", request =>
{
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
});

// PATCH with ETag
var patched = await httpClient.PatchResultAsync<Order>("/api/orders/123", patch, request =>
{
    request.Headers.Add("If-Match", etag);
});

Error Mapping

Non-success HTTP status codes are automatically mapped to the most specific Error subtype:

Status Code Error Type
400 Bad Request BadRequestError
401 Unauthorized UnauthorizedError
403 Forbidden ForbiddenError
404 Not Found NotFoundError
409 Conflict ConflictError
422 Unprocessable Entity ValidationError
5xx Server Error ExternalServiceError
Other HttpError

Transport-level failures (network errors, DNS failures, timeouts) are captured as HttpRequestError.

var result = await httpClient.GetResultAsync<Order>("/api/orders/123");

result.TapError(error =>
{
    switch (error)
    {
        case NotFoundError:
            logger.LogWarning("Order not found");
            break;
        case UnauthorizedError:
            logger.LogWarning("Authentication required");
            break;
        case ExternalServiceError e:
            logger.LogError("Server error: {Message}", e.Message);
            break;
        case HttpRequestError e:
            logger.LogError("Network failure: {Type}", e.ExceptionType);
            break;
    }
});

Chaining with Map and Bind

Results compose naturally with the core library's Map and Bind:

// Transform the success value
var orderId = await httpClient
    .PostResultAsync<Order>("/api/orders", newOrder)
    .Map(order => order.Id);

// Chain dependent calls
var invoice = await httpClient
    .GetResultAsync<Order>("/api/orders/123")
    .BindAsync(order =>
        httpClient.GetResultAsync<Invoice>($"/api/invoices/{order.InvoiceId}"));

Composition with Retry

Wrap HTTP calls in a retry policy for transient failure handling:

var result = await Retry
    .WithMaxAttempts(3)
    .WithBackoff(Backoff.Exponential(TimeSpan.FromMilliseconds(200)))
    .WithRetryWhen(error => error is ExternalServiceError or HttpRequestError)
    .OnRetry((attempt, error) =>
        logger.LogWarning("Attempt {Attempt}: {Error}", attempt, error.Message))
    .ExecuteAsync(() =>
        httpClient.GetResultAsync<Data>("/api/data"));

Composition with Circuit Breaker

Protect against cascading failures from a consistently failing dependency:

var breaker = CircuitBreaker
    .WithFailureThreshold(5)
    .WithResetTimeout(TimeSpan.FromSeconds(30))
    .WithBreakWhen(error => error is ExternalServiceError or HttpRequestError);

var result = await breaker.ExecuteAsync(
    () => httpClient.GetResultAsync<Data>("/api/data"));

Composition with MemoizeResult

Cache successful HTTP responses while allowing failed requests to be retried:

var cachedGet = MemoizeResult.FuncAsync<string, UserProfile, Error>(
    endpoint => httpClient.GetResultAsync<UserProfile>(endpoint),
    opts => opts
        .WithExpiration(TimeSpan.FromMinutes(5))
        .WithMaxSize(1000));

var profile = await cachedGet("/api/users/123");
// First call: HTTP request, caches on success
// Second call within 5 min: returns cached result
// If first call failed: second call retries the HTTP request

Full Resilience Stack

Combine caching, circuit breaking, retry, and Http extensions for production-grade resilience:

var breaker = CircuitBreaker
    .WithFailureThreshold(5)
    .WithResetTimeout(TimeSpan.FromSeconds(30))
    .WithBreakWhen(error => error is ExternalServiceError or HttpRequestError)
    .OnStateChange((from, to) =>
        logger.LogWarning("Circuit: {From} -> {To}", from, to));

var cachedFetch = MemoizeResult.FuncAsync<string, CatalogItem, Error>(
    endpoint => Retry
        .WithMaxAttempts(3)
        .WithBackoff(Backoff.Exponential(
            TimeSpan.FromMilliseconds(200),
            maxDelay: TimeSpan.FromSeconds(5)))
        .WithRetryWhen(error =>
            error is ExternalServiceError or HttpRequestError or CircuitBreakerOpenError)
        .OnRetry((attempt, error) =>
            logger.LogWarning("Retry {Attempt}: {Error}", attempt, error.Message))
        .ExecuteAsync(() =>
            breaker.ExecuteAsync(
                () => httpClient.GetResultAsync<CatalogItem>(endpoint))),
    opts => opts
        .WithExpiration(TimeSpan.FromMinutes(10))
        .WithMaxSize(500));

// Usage
var result = await cachedFetch("/api/catalog/item-42");

Request flow: Cache check → Retry loop → Circuit breaker → HTTP call

On success, the result is cached. On transient failure, the retry policy backs off and retries. If the dependency is consistently failing, the circuit opens and subsequent calls are rejected immediately until the reset timeout elapses.