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

Responses And Errors

The Response Transcript

Every exchange produces a complete HttpResponse. The examples on this page run against a scripted service that replies with fixed statuses and bodies:

Show page setup
// Setup for the checked examples on this page.
open System
open Axial
open Axial.HttpClient
/// 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 User = { Id: int; Name: string }

type TestEnv =
    { Http: IHttp }
    interface IHasHttp with
        member this.Http = this.Http

/// 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}"

/// A double that replies with one fixed status/body and records the requests it received.
/// The multi-reply scripted double used by the reliability examples lives in the testing guide.
type RecordingHttp(status: int, body: string) =
    let sent = ResizeArray<HttpRequest>()
    member _.Sent = List.ofSeq sent
    member this.Env : TestEnv = { Http = this }

    interface IHttp with
        member _.Send(request) =
            sent.Add request
            Flow.succeed (Response.create (DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)) status body)
Requires & types
Requires: open Axial; open Axial.HttpClient; open System (packages: Axial, Axial.HttpClient)
val decodeUser: string -> Result<User,string>
Verified
let summary : Flow<TestEnv, HttpError, int * string * string option> =
    flow {
        let! response = Http.get "https://api.example.com/users" |> Http.send
        let etag = response |> Response.tryHeader "ETag"   // case-insensitive
        return response.StatusCode, response.Text, etag
    }
Requires & types
Requires: open Axial; open Axial.HttpClient (packages: Axial, Axial.HttpClient)
val summary: Flow<TestEnv,HttpError,(int * string * string option)>
Verified
summary |> Flow.run (RecordingHttp(200, "[]").Env) |> shouldEqual (Exit.Success(200, "[]", None))
Requires & types
Requires: open Axial (packages: Axial)

response.Duration records how long the exchange took, measured with the service's IClock.

  • StatusCode, ReasonPhrase, and Headers (response plus content headers, in arrival order).
  • Body is the exact bytes; Text is decoded with the response charset, defaulting to UTF-8. Neither is redacted, so treat them as sensitive: a body may contain credentials or personal data.
  • Request is the redacted request line (secret query values and headers are replaced with ***), so the request line is safe to log as-is. Do not log the full transcript without bounding Body/Text.
  • StartedAt and Duration time the full exchange including body download.

Typed JSON Decoding

Response.json and the fetchJson/Http.getJson terminals take a decoder of type string -> Result<'value, string>. Any JSON library fits that shape:

Verified
let user (userId: int) : Flow<TestEnv, HttpError, User> =
    GET $"https://api.example.com/users/{userId}"
    |> fetchJson decodeUser   // Reified.Schema, Thoth, or hand-written
Requires & types
Requires: open Axial; open Axial.HttpClient; open Axial.HttpClient.DSL (packages: Axial, Axial.HttpClient)
val user: int -> Flow<TestEnv,HttpError,User>
Verified
user 1 |> Flow.run (RecordingHttp(200, """{"id":1,"name":"Ada"}""").Env) |> shouldEqual (Exit.Success { Id = 1; Name = "Ada" })

match user 1 |> Flow.run (RecordingHttp(200, "not json").Env) with
| Exit.Failure(Cause.Fail(HttpError.DecodeFailed _)) -> ()
| other -> failwithf "expected a decode failure, got %A" other
Requires & types
Requires: open Axial; open Axial.HttpClient (packages: Axial, Axial.HttpClient)

A decoder failure becomes HttpError.DecodeFailed(message, response). It carries the full transcript, so the error handler can log the offending payload without re-fetching it.

To POST a value and decode the reply in one step:

Verified
let encodeUser (user: User) = $"{{\"id\":{user.Id},\"name\":\"{user.Name}\"}}"

let created (newUser: User) : Flow<TestEnv, HttpError, User> =
    Http.postJson encodeUser decodeUser "https://api.example.com/users" newUser
Requires & types
Requires: open Axial; open Axial.HttpClient (packages: Axial, Axial.HttpClient)
val encodeUser: User -> string
val created: User -> Flow<TestEnv,HttpError,User>
Verified
let service = RecordingHttp(201, """{"id":7,"name":"Ada"}""")
created { Id = 0; Name = "Ada" } |> Flow.run service.Env |> shouldEqual (Exit.Success { Id = 7; Name = "Ada" })
service.Sent |> List.map (Request.plan >> _.Body) |> shouldEqual [ "application/json (22 characters)" ]
Requires & types
Requires: open Axial; open Axial.HttpClient (packages: Axial, Axial.HttpClient)
val service: RecordingHttp

One Error Type

HTTP failures are cases of HttpError. Handle the cases your workflow can recover from, and fall back to HttpError.describe for the rest:

Verified
let explain (error: HttpError) =
    match error with
    | HttpError.Status response -> $"status {response.StatusCode}"              // outside the expectation, full transcript
    | HttpError.DecodeFailed(message, _) -> $"unexpected body: {message}"       // did not decode, full transcript
    | other -> HttpError.describe other
Requires & types
Requires: open Axial.HttpClient (packages: Axial.HttpClient)
val explain: HttpError -> string
Verified
match user 1 |> Flow.run (RecordingHttp(503, "").Env) with
| Exit.Failure(Cause.Fail error) -> explain error |> shouldEqual "status 503"
| other -> failwithf "expected a status error, got %A" other
Requires & types
Requires: open Axial (packages: Axial)

Interrupting the workflow is not one of them: the request is abandoned and the flow ends with Cause.Interrupt, so no error handler has to recognize a cancellation it did not ask for.

The full six-case union, together with HttpError.describe, HttpError.tryResponse, HttpError.isTransient, and HttpError.transientRetry, is listed in the HttpError reference.

Statuses Are Data, Not Exceptions

Http.send fails with HttpError.Status for anything outside the request's expectation (2xx by default). When a "failure" status is a normal outcome, widen the expectation and branch on the code:

Verified
let findUser (userId: int) : Flow<TestEnv, HttpError, User option> =
    flow {
        let! response =
            GET $"https://api.example.com/users/{userId}"
            |> expect [ 200; 404 ]
            |> fetch
        if response.StatusCode = 404 then return None
        else return! response |> Response.json decodeUser |> Result.map Some
    }
Requires & types
Requires: open Axial; open Axial.HttpClient; open Axial.HttpClient.DSL (packages: Axial, Axial.HttpClient)
val findUser: int -> Flow<TestEnv,HttpError,User option>
Verified
findUser 9 |> Flow.run (RecordingHttp(404, "").Env) |> shouldEqual (Exit.Success None)
findUser 1 |> Flow.run (RecordingHttp(200, """{"id":1,"name":"Ada"}""").Env) |> shouldEqual (Exit.Success(Some { Id = 1; Name = "Ada" }))
Requires & types
Requires: open Axial (packages: Axial)

Http.sendResult skips status interpretation entirely and returns whatever arrived; use it when a proxy or health check needs the raw exchange.

When Not To Decode

fetchText and fetchBytes return the body directly for HTML scraping, file downloads, and pass-through proxying. Use fetchJson only when the payload should become a typed value; decoding a body you will immediately re-serialize wastes the transcript you already have.