Header menu logo FSharp.ATProto

Error Handling

Every fallible operation in FSharp.ATProto returns Task<Result<'T, XrpcError>>. No exceptions are thrown for protocol-level failures. This guide covers the error type, the taskResult computation expression for chaining these operations, and the automatic retry behaviour built into the XRPC layer.

Code samples throughout the docs use taskResult {}, the computation expression explained on this page.

The taskResult CE

The taskResult computation expression is defined in FSharp.ATProto.Core and auto-opened into scope. It chains Task<Result<'T, 'E>> values with automatic error short-circuiting: if any let! binding produces an Error, the entire expression returns that error immediately without executing subsequent steps.

let workflow =
    taskResult {
        let! agent = Bluesky.login "https://bsky.social" "handle.bsky.social" "app-password"
        let! post = Bluesky.post agent "Hello from F#!"
        let! like = Bluesky.like agent post
        return post
    }

If Bluesky.login fails, the post and like are never attempted. The error propagates out as the result of the whole expression.

Without taskResult, the equivalent code requires manual matching at each step:

let workflowManual =
    task {
        let! loginResult = Bluesky.login "https://bsky.social" "handle.bsky.social" "app-password"
        match loginResult with
        | Error err -> return Error err
        | Ok agent ->
            let! postResult = Bluesky.post agent "Hello from F#!"
            match postResult with
            | Error err -> return Error err
            | Ok post ->
                let! likeResult = Bluesky.like agent post
                match likeResult with
                | Error err -> return Error err
                | Ok _ -> return Ok post
    }

We recommend taskResult for most use cases.

XrpcError

All errors are represented as an XrpcError record:

type XrpcError =
    { StatusCode: int
      Error: string option
      Message: string option }

Common errors you may encounter:

Status

Error String

Meaning

400

InvalidRequest

Malformed input (e.g., invalid AT-URI, missing required field)

401

ExpiredToken

Access token expired -- handled automatically (see below)

401

AuthenticationRequired

No session or invalid credentials

404

(varies)

Resource not found (deleted post, unknown DID)

429

RateLimitExceeded

Too many requests -- handled automatically (see below)

500

InternalServerError

Server-side failure

Automatic Retry

The XRPC layer handles two transient error cases transparently. You do not need to implement retry logic for these.

Expired tokens (401). When a request fails with ExpiredToken, the library automatically refreshes the session using the refresh JWT stored in the AtpAgent, then retries the original request once with the new access token. If the refresh itself fails, that error is returned to the caller.

Rate limiting (429). When a request is rate-limited, the library reads the Retry-After header from the response, waits for the specified duration (defaulting to 1 second if the header is absent), then retries once. If the retry also fails, its error is returned.

All other errors are returned immediately with no retry.

Handling Errors

At the boundary of your program, match on the result to handle success and failure:

let run =
    task {
        let! result =
            taskResult {
                let! agent = Bluesky.login "https://bsky.social" "handle.bsky.social" "app-password"
                let! post = Bluesky.post agent "Hello!"
                return post
            }

        match result with
        | Ok post ->
            printfn "Posted: %s" (AtUri.value post.Uri)
        | Error err ->
            printfn "Failed (%d): %s"
                err.StatusCode
                (err.Message |> Option.defaultValue "unknown")
    }

You can also pattern match on specific error codes to take different actions:

match result with
| Ok post -> printfn "Posted: %s" (AtUri.value post.Uri)
| Error { StatusCode = 401 } -> printfn "Not authenticated"
| Error { StatusCode = 400; Message = Some msg } -> printfn "Bad request: %s" msg
| Error err -> printfn "Unexpected error (%d)" err.StatusCode

When to Use task vs taskResult

Use taskResult when you have a chain of fallible operations and want errors to short-circuit through the whole chain. This is the common case for workflows like "log in, fetch data, do something with it."

Use task {} when you want to handle each error individually at the call site -- for example, if a failure at step 2 should trigger a different recovery path rather than aborting the whole workflow:

let taskExample =
    task {
        let! agent = Bluesky.login "https://bsky.social" "handle.bsky.social" "app-password"
        match agent with
        | Error err -> printfn "Login failed: %A" err
        | Ok agent ->
            let! postResult = Bluesky.post agent "Hello!"
            match postResult with
            | Ok post -> printfn "Posted: %s" (AtUri.value post.Uri)
            | Error _ -> printfn "Post failed, but continuing..."
    }

For pagination-specific error handling, see the Pagination guide.

Multiple items
namespace FSharp

--------------------
namespace Microsoft.FSharp
namespace FSharp.ATProto
namespace FSharp.ATProto.Syntax
namespace FSharp.ATProto.Core
namespace FSharp.ATProto.Bluesky
val agent: AtpAgent
module Unchecked from Microsoft.FSharp.Core.Operators
val defaultof<'T> : 'T
Multiple items
module AtpAgent from FSharp.ATProto.Core
<summary> Functions for creating and authenticating <see cref="AtpAgent" /> instances. </summary>

--------------------
type AtpAgent = { HttpClient: HttpClient mutable BaseUrl: Uri mutable Session: AtpSession option ExtraHeaders: (string * string) list AuthenticateRequest: (HttpRequestMessage -> unit) option RefreshAuthentication: (unit -> Task<Result<unit,XrpcError>>) option OnSessionChanged: (unit -> unit) option }
<summary> Client agent for communicating with an AT Protocol Personal Data Server (PDS). Holds the HTTP client, base URL, optional authenticated session, and extra headers. </summary>
<remarks> Create an agent with <see cref="AtpAgent.create" /> or <see cref="AtpAgent.createWithClient" />, then authenticate with <see cref="AtpAgent.login" />. The agent's <see cref="Session" /> field is mutable: it is updated automatically on login and token refresh. </remarks>
<example><code> let agent = AtpAgent.create "https://bsky.social" let! session = AtpAgent.login "my-handle.bsky.social" "app-password-here" agent </code></example>
val post: PostRef
type PostRef = { Uri: AtUri Cid: Cid }
<summary> A reference to a specific version of a post record. Contains both the AT-URI (identifying the record) and the CID (identifying the exact version). Accepted by <c>like</c>, <c>repost</c>, and <c>replyTo</c>. </summary>
val workflow: System.Threading.Tasks.Task<Result<PostRef,XrpcError>>
val taskResult: TaskResultBuilder
module Bluesky from FSharp.ATProto.Bluesky
<summary> High-level convenience methods for common Bluesky operations: posting, replying, liking, reposting, following, blocking, uploading blobs, and deleting records. All methods require an authenticated <see cref="AtpAgent" />. </summary>
val login: baseUrl: string -> identifier: string -> password: string -> System.Threading.Tasks.Task<Result<AtpAgent,XrpcError>>
val post: agent: AtpAgent -> text: string -> System.Threading.Tasks.Task<Result<PostRef,XrpcError>>
<summary> Create a post with automatic rich text detection. Mentions, links, and hashtags are automatically detected and resolved to facets. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="text">The post text. Mentions (<c>@handle</c>), links (<c>https://...</c>), and hashtags (<c>#tag</c>) are auto-detected.</param>
<returns>A <see cref="PostRef" /> with the AT-URI and CID on success, or an <see cref="XrpcError" />.</returns>
<remarks> Internally calls <see cref="RichText.parse" /> to detect and resolve facets before creating the post. Unresolvable mentions are silently omitted from facets. For pre-resolved facets, use <see cref="postWithFacets" /> instead. </remarks>
<example><code> let! result = Bluesky.post agent "Hello @my-handle.bsky.social! Check out https://example.com #atproto" </code></example>
val like: LikeRef
val like: agent: AtpAgent -> target: 'a -> System.Threading.Tasks.Task<Result<LikeRef,XrpcError>> (requires member ToPostRef)
<summary> Like a post or other record. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="target">A <see cref="PostRef" /> or <see cref="TimelinePost" /> identifying the record to like.</param>
<returns>A <see cref="LikeRef" /> on success, or an <see cref="XrpcError" />. Pass the <c>LikeRef</c> to <see cref="unlike" /> to undo.</returns>
val workflowManual: System.Threading.Tasks.Task<Result<PostRef,XrpcError>>
val task: TaskBuilder
val loginResult: Result<AtpAgent,XrpcError>
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val err: XrpcError
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
val postResult: Result<PostRef,XrpcError>
val likeResult: Result<LikeRef,XrpcError>
type XrpcError = { StatusCode: int Error: string option Message: string option }
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type 'T option = Option<'T>
val run: System.Threading.Tasks.Task<unit>
val result: Result<PostRef,XrpcError>
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
Multiple items
module AtUri from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="AtUri" /> values. </summary>

--------------------
type AtUri = private | AtUri of string override ToString: unit -> string
<summary> An AT-URI that identifies a resource in the AT Protocol network. AT-URIs use the scheme <c>at://</c> followed by an authority (DID or handle), an optional collection (NSID), and an optional record key. Format: <c>at://&lt;authority&gt;[/&lt;collection&gt;[/&lt;rkey&gt;]]</c>. Maximum length is 8192 characters. </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/at-uri-scheme </remarks>
val value: AtUri -> string
<summary> Extract the string representation of an AT-URI. </summary>
<param name="atUri">The AT-URI to extract the value from.</param>
<returns>The full AT-URI string (e.g. <c>"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k2la3b"</c>).</returns>
PostRef.Uri: AtUri
<summary>The AT-URI of the post record.</summary>
XrpcError.StatusCode: int
<summary>The HTTP status code returned by the server (e.g. 400, 401, 429, 500).</summary>
XrpcError.Message: string option
<summary>A human-readable error message from the response body, or <c>None</c> if absent.</summary>
module Option from Microsoft.FSharp.Core
val defaultValue: value: 'T -> option: 'T option -> 'T
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
Multiple items
union case IdentityError.XrpcError: XrpcError -> IdentityError
<summary>An XRPC call failed (e.g., handle resolution).</summary>

--------------------
type XrpcError = { StatusCode: int Error: string option Message: string option }
<summary> Represents an error response from an XRPC endpoint. AT Protocol servers return JSON error bodies with optional <c>error</c> and <c>message</c> fields alongside an HTTP status code. </summary>
<remarks> Common error codes include <c>ExpiredToken</c> (401), <c>RateLimitExceeded</c> (429), and <c>InvalidRequest</c> (400). The <see cref="Xrpc" /> module handles <c>ExpiredToken</c> and rate-limit errors automatically. </remarks>
union case Option.Some: Value: 'T -> Option<'T>
val msg: string
val taskExample: System.Threading.Tasks.Task<unit>
val agent: Result<AtpAgent,XrpcError>

Type something to start searching.