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 |
|
Malformed input (e.g., invalid AT-URI, missing required field) |
401 |
|
Access token expired -- handled automatically (see below) |
401 |
|
No session or invalid credentials |
404 |
(varies) |
Resource not found (deleted post, unknown DID) |
429 |
|
Too many requests -- handled automatically (see below) |
500 |
|
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.
namespace FSharp
--------------------
namespace Microsoft.FSharp
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>
<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>
<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>
<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>
<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 int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
val string: value: 'T -> string
--------------------
type string = System.String
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://<authority>[/<collection>[/<rkey>]]</c>. Maximum length is 8192 characters. </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/at-uri-scheme </remarks>
<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>
<summary>The AT-URI of the post record.</summary>
<summary>The HTTP status code returned by the server (e.g. 400, 401, 429, 500).</summary>
<summary>A human-readable error message from the response body, or <c>None</c> if absent.</summary>
module Result from Microsoft.FSharp.Core
--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
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>