Repository F# setup
open System
open System.Net.Http
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.HttpClient
open Axial.PlatformServiceResponses And Errors
This page shows how one response transcript and one error type replace scattered status checks and exception handling.
The Response Transcript
Every exchange produces a complete HttpResponse:
let workflow =
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, response.Duration, etag
}StatusCode,ReasonPhrase, andHeaders(response plus content headers, in arrival order).Bodyis the exact bytes;Textis decoded with the response charset, defaulting to UTF-8.Requestis the redacted request line, so the transcript is safe to log as-is.StartedAtandDurationtime 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:
open Axial.HttpClient.DSL
let user : Flow<#IHasHttp, HttpError, User> =
GET $"https://api.example.com/users/{userId}"
|> fetchJson (Json.deserializeResult userCodec) // Reified.Schema.Json, Thoth, or hand-writtenA decoder failure becomes HttpError.DecodeFailed(message, response) — the full transcript rides along, so the
error handler can log the offending payload without re-fetching it.
To POST a value and decode the reply in one step:
let created =
Http.postJson (Json.serialize userCodec) (Json.deserializeResult userCodec)
"https://api.example.com/users" userOne Error Type
Every way an HTTP call can fail is one case of HttpError:
match error with
| HttpError.InvalidRequest message -> ... // malformed URL or request construction
| HttpError.ConnectionFailed(request, message) -> ...// DNS, refused, dropped connection
| HttpError.TimedOut(request, timeout) -> ... // per-request timeout elapsed
| HttpError.Canceled message -> ... // the workflow was interrupted
| HttpError.Status response -> ... // status outside the expectation, full transcript
| HttpError.DecodeFailed(message, response) -> ... // body did not decode, full transcriptHttpError.describe formats any case with its redacted request context and a bounded body preview, so a single
Flow.mapError HttpError.describe produces loggable messages. HttpError.tryResponse extracts the transcript
from the cases that carry one.
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:
let findUser userId =
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
}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. Reach for fetchJson only when the payload should become a typed value; decoding a body you will
immediately re-serialize wastes the transcript you already have.

