Repository F# setup
open System
open System.Net.Http
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.HttpClient
open Axial.PlatformService

Reliability

Per-Request Timeouts

HttpClient.Timeout is one global setting that throws TaskCanceledException, indistinguishable from real cancellation. An Axial timeout is per request and produces a dedicated typed error:

The examples on this page run against a scripted service that replies with fixed statuses in turn and records each request, so they can check how many attempts were made. The double is the ScriptedHttp from the testing guide:

Show page setup
// Setup for the checked examples on this page.
open System
open System.Net.Http
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.HttpClient
open Axial.PlatformService
/// Fails the docs test when an example's result differs from the value shown.
let shouldEqual expected actual =
    if actual <> expected then failwithf "Expected %A but got %A" expected actual
Verified
open Axial.HttpClient.DSL

type TestEnv =
    { Http: IHttp; Clock: IClock }
    interface IHasHttp with
        member this.Http = this.Http
    interface IHasClock with
        member this.Clock = this.Clock

/// An HTTP service that replies with each status and body in turn, and records the requests it received.
type ScriptedHttp(replies: (int * string) list) =
    let remaining = Collections.Generic.Queue(replies)
    let sent = ResizeArray<HttpRequest>()
    member _.Sent = List.ofSeq sent
    member this.Env = { Http = this; Clock = Clock.live }

    interface IHttp with
        member _.Send(request) =
            Flow.delay (fun () ->
                sent.Add request
                let status, body = remaining.Dequeue()
                Flow.succeed (Response.create (DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)) status body))

type User = { Id: int; Name: string }

/// A hand-written decoder for {"id":1,"name":"Ada"}; a JSON library's decoder fits the same signature.
let decodeUser (json: string) : Result<User, string> =
    let found = Text.RegularExpressions.Regex.Match(json, """^\{"id":(\d+),"name":"([^"]*)"\}$""")

    if found.Success then Ok { Id = int found.Groups[1].Value; Name = found.Groups[2].Value }
    else Error $"not a user: {json}"
Requires & types
Requires: open Axial; open Axial.HttpClient; open Axial.PlatformService; open System (packages: Axial, Axial.HttpClient, Axial.PlatformService)
val decodeUser: string -> Result<User,string>
Verified
let slowReport : Flow<TestEnv, HttpError, HttpResponse> =
    GET $"https://api.example.com/slow-report"
    |> timeout (TimeSpan.FromSeconds 5.0)
    |> fetch
Requires & types
Requires: open Axial; open Axial.HttpClient; open Axial.HttpClient.DSL; open System (packages: Axial, Axial.HttpClient)
val slowReport: Flow<TestEnv,HttpError,HttpResponse>

A handler that accepts the connection and never responds leaves only the per-request timeout to finish the call:

Verified
let stalledHandler () =
    { new HttpMessageHandler() with
        member _.SendAsync(request: HttpRequestMessage, cancellationToken: CancellationToken) : Task<HttpResponseMessage> =
            task {
                do! Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken)
                return null
            } }

let timeoutEnv =
    { Http = Http.live Clock.live (new HttpClient(stalledHandler ()))
      Clock = Clock.live }
Requires & types
Requires: open Axial.HttpClient; open Axial.PlatformService; open System.Net.Http; open System.Threading; open System.Threading.Tasks (packages: Axial.HttpClient, Axial.PlatformService)
val stalledHandler: unit -> HttpMessageHandler
val timeoutEnv: TestEnv
Verified
let shortTimeout =
    GET $"https://api.example.com/slow-report"
    |> timeout (TimeSpan.FromMilliseconds 50.0)
    |> fetch

match shortTimeout |> Flow.run timeoutEnv with
| Exit.Failure(Cause.Fail(HttpError.TimedOut(request, timeout))) ->
    timeout |> shouldEqual (TimeSpan.FromMilliseconds 50.0)
    request |> shouldEqual "GET https://api.example.com/slow-report"
| other -> failwithf "expected HttpError.TimedOut, got %A" other
Requires & types
Requires: open Axial; open Axial.HttpClient; open Axial.HttpClient.DSL; open System (packages: Axial, Axial.HttpClient)
val shortTimeout: Flow<TestEnv,HttpError,HttpResponse>

The checked block uses a 50ms deadline so the docs test does not sleep; slowReport keeps the readable 5s value. The TimedOut case carries the redacted request line and the timeout that elapsed.

Interrupting the workflow is Cause.Interrupt, never HttpError.TimedOut; the runtime token is the one that asked, so the failure stays outside the typed error channel:

Verified
let interruptedSource = new CancellationTokenSource()
interruptedSource.Cancel()

let interruptedTimeout =
    GET $"https://api.example.com/slow-report"
    |> timeout (TimeSpan.FromMilliseconds 50.0)
    |> fetch

match interruptedTimeout.RunSynchronously(timeoutEnv, interruptedSource.Token) with
| Exit.Failure Cause.Interrupt -> ()
| other -> failwithf "expected Cause.Interrupt, got %A" other
Requires & types
Requires: open Axial; open Axial.HttpClient.DSL; open System; open System.Threading (packages: Axial, Axial.HttpClient)
val interruptedSource: CancellationTokenSource
val interruptedTimeout: Flow<TestEnv,HttpError,HttpResponse>

The live service enforces the timeout with a linked cancellation source, so the connection is torn down when the deadline passes, not merely abandoned.

Retry Only Transient Failures

Retrying a 404 or a decode failure wastes time and can duplicate side effects. HttpError.isTransient classifies exactly the failures where a retry can help: connection failures, timeouts, and 408/429/5xx statuses.

Verified
let firstUser : Flow<TestEnv, HttpError, User> =
    Http.getJson decodeUser "https://api.example.com/users/1"
    |> Http.retryTransient 3 (TimeSpan.FromMilliseconds 1.0)
Requires & types
Requires: open Axial; open Axial.HttpClient; open System (packages: Axial, Axial.HttpClient)
val firstUser: Flow<TestEnv,HttpError,User>

Two 503 replies are transient, so the third attempt succeeds:

Verified
let flaky = ScriptedHttp([ 503, ""; 503, ""; 200, """{"id":1,"name":"Ada"}""" ])
firstUser |> Flow.run flaky.Env |> shouldEqual (Exit.Success { Id = 1; Name = "Ada" })
flaky.Sent.Length |> shouldEqual 3
Requires & types
Requires: open Axial (packages: Axial)
val flaky: ScriptedHttp

retryTransient 3 makes up to 3 retries after the first attempt. The example above passes a 1ms base delay so the docs test does not sleep, producing a 1ms, 2ms, 4ms schedule. The DSL shorthand withRetries 3 applies the same exponential schedule with the 200ms base delay instead, so its retries wait 200ms, 400ms, then 800ms. A permanent failure such as HttpError.Status 404 or HttpError.DecodeFailed fails immediately on the first attempt. To combine the retry with other schedules, take the schedule itself: HttpError.transientRetry 3 (TimeSpan.FromMilliseconds 200.0) is the Schedule that retryTransient passes to Flow.retry.

For full control, describe the retry yourself and pass it to Flow.retry. A Retry record names each choice:

Verified
let notRateLimited error =
    HttpError.isTransient error
    && (match error with HttpError.Status r -> r.StatusCode <> 429 | _ -> true)

let patientUser : Flow<TestEnv, HttpError, User> =
    Http.getJson decodeUser "https://api.example.com/users/1"
    |> Flow.retry (Retry.schedule { Retry.defaults with Retries = 5; When = notRateLimited })
Requires & types
Requires: open Axial; open Axial.HttpClient (packages: Axial, Axial.HttpClient)
val notRateLimited: HttpError -> bool
val patientUser: Flow<TestEnv,HttpError,User>

A 429 is transient, but this policy leaves it to the caller, so it is not retried:

Verified
let limited = ScriptedHttp([ 429, "" ])

match patientUser |> Flow.run limited.Env with
| Exit.Failure(Cause.Fail(HttpError.Status response)) -> response.StatusCode |> shouldEqual 429
| other -> failwithf "expected a 429, got %A" other

limited.Sent.Length |> shouldEqual 1
Requires & types
Requires: open Axial; open Axial.HttpClient (packages: Axial, Axial.HttpClient)
val limited: ScriptedHttp

The same retry as a Schedule pipeline adds what a record cannot express, such as jitter:

Verified
let jitteredUser : Flow<TestEnv, HttpError, User> =
    Http.getJson decodeUser "https://api.example.com/users/1"
    |> Flow.retry (
        Schedule.exponential (TimeSpan.FromMilliseconds 100.0)
        |> Schedule.jitteredWith (System.Random().NextDouble)
        |> Schedule.recursAtMost 5
        |> Schedule.whileInput notRateLimited)
Requires & types
Requires: open Axial; open Axial.HttpClient; open System (packages: Axial, Axial.HttpClient)
val jitteredUser: Flow<TestEnv,HttpError,User>

A schedule without whileInput retries every typed error; select the transient ones so permanent failures stay fast.

Long polls that restart on demand

When local state changes, a wake signal cancels the in-flight GET and starts a fresh one. Wakes do not count as retry failures; only an HTTP failure reaches the retry policy.

The endpoint below must support long polling, so this example needs a server and is not run by the docs build (no-check).

Fragment
open Axial
open Axial.HttpClient

let pollUntilResponse (wakes: Queue<unit>) : Flow<TestEnv, HttpError, HttpResponse> =
    let request = Http.get "https://api.example.com/events?wait=30"
    let work = Http.send request |> Http.retryTransient 3 (TimeSpan.FromMilliseconds 200.0)
    FlowStream.reissueOnWake (FlowStream.fromDequeue wakes) work

Create wakes with Queue.unbounded () inside a flow, and call Queue.offer () wakes when a local change should restart the poll.

Limit Response Size

A server you do not control can send a body of any size. Request.maxResponseBytes caps it:

Verified
let page (url: string) : Flow<TestEnv, HttpError, string> =
    Http.get url
    |> Request.maxResponseBytes (2L * 1024L * 1024L)
    |> Http.text
Requires & types
Requires: open Axial; open Axial.HttpClient (packages: Axial, Axial.HttpClient)
val page: string -> Flow<TestEnv,HttpError,string>

The live service fails with HttpError.ResponseTooLarge before reading when Content-Length declares a larger body, and stops reading a streamed body once it passes the limit, so the client never buffers more than the limit. Http.send applies the same check to responses from any IHttp, including test doubles. Request.tryMaxResponseBytes reads a request's limit back, for example in a test double that enforces it.

Expected Statuses Are Part Of The Request

The expected status travels with the request, so callers cannot forget to check it:

Verified
let deleteUser (userId: int) : Flow<TestEnv, HttpError, HttpResponse> =
    DELETE $"https://api.example.com/users/{userId}"
    |> expect [ 204; 404 ]   // idempotent delete: already-gone is fine
    |> fetch
Requires & types
Requires: open Axial; open Axial.HttpClient; open Axial.HttpClient.DSL (packages: Axial, Axial.HttpClient)
val deleteUser: int -> Flow<TestEnv,HttpError,HttpResponse>
Verified
deleteUser 7 |> Flow.run (ScriptedHttp([ 404, "" ]).Env) |> Exit.map _.StatusCode |> shouldEqual (Exit.Success 404)
Requires & types
Requires: open Axial; open Axial.HttpClient (packages: Axial, Axial.HttpClient)

expectAny disables interpretation for endpoints where every status is meaningful, and Http.sendResult does the same for one call without changing the request.

When Not To Retry

Do not wrap non-idempotent POSTs in retryTransient unless the server deduplicates requests (for example with an idempotency key header): a timeout does not prove the server ignored the request. Send the key explicitly, then retry safely:

Verified
type Payment = { Amount: decimal }

let encodePayment (payment: Payment) = $"{{\"amount\":{payment.Amount}}}"
let decodeReceipt (json: string) : Result<string, string> = Ok json

let pay (idempotencyKey: string) (payment: Payment) : Flow<TestEnv, HttpError, string> =
    POST $"https://api.example.com/payments"
    |> header "Idempotency-Key" idempotencyKey
    |> jsonBodyOf encodePayment payment
    |> fetchJson decodeReceipt
    |> withRetries 3
Requires & types
Requires: open Axial; open Axial.HttpClient; open Axial.HttpClient.DSL (packages: Axial, Axial.HttpClient)
val encodePayment: Payment -> string
val decodeReceipt: string -> Result<string,string>
val pay: string -> Payment -> Flow<TestEnv,HttpError,string>

Pass the key in from the caller: the request is built once, so the retry resends the same key and the server can recognise the repeat — and two separate invocations can share a key when the caller chooses one:

Verified
let payments = ScriptedHttp([ 503, ""; 200, "receipt-1" ])
pay "idem-checkout-1" { Amount = 9.5m } |> Flow.run payments.Env |> shouldEqual (Exit.Success "receipt-1")

payments.Sent
|> List.map (fun request -> Request.plan request |> _.Headers |> List.find (fst >> (=) "Idempotency-Key") |> snd)
|> List.distinct
|> List.length
|> shouldEqual 1
Requires & types
Requires: open Axial; open Axial.HttpClient (packages: Axial, Axial.HttpClient)
val payments: ScriptedHttp