Packages
The shared sans-io core of the gleam-atproto client packages: XRPC error vocabulary and response handling, plus the OAuth effect kernel.
Current section
Files
Jump to
Current section
Files
src/atproto_core/xrpc.gleam
//// The target-agnostic XRPC vocabulary shared by every client package:
//// transport and XRPC error types, response checking, and JSON body
//// parsing. Sync and async clients (`atproto_client`, `atproto_browser`)
//// wrap their own `send` functions around these.
import gleam/bit_array
import gleam/dynamic/decode
import gleam/http/response.{type Response}
import gleam/json
import gleam/option.{type Option, None}
import gleam/result
import gleam/string
/// A transport-level failure from an injected `send` function or from
/// request construction. Backends map their own error type onto these
/// variants; `Other` carries anything without a dedicated shape (including
/// DPoP signing failures surfaced through a wrapped client's `send`).
pub type TransportError {
InvalidUrl(url: String)
Timeout
ConnectionFailed(detail: String)
Other(detail: String)
}
/// A one-line human-readable rendering of a transport error.
pub fn transport_error_to_string(error: TransportError) -> String {
case error {
InvalidUrl(url) -> "invalid url: " <> url
Timeout -> "timed out"
ConnectionFailed(detail) -> "connection failed: " <> detail
Other(detail) -> detail
}
}
pub type XrpcError {
RequestFailed(TransportError)
/// A non-2xx response. atproto error bodies are JSON `{error, message}`; both
/// are parsed out (when present) so callers can branch on `error` (e.g.
/// `ExpiredToken`) instead of string-matching the raw body.
BadStatus(
status: Int,
error: Option(String),
message: Option(String),
body: String,
)
DecodeFailed(String)
}
/// A one-line human-readable rendering of an error, for CLI and log output.
pub fn describe(error: XrpcError) -> String {
case error {
RequestFailed(e) -> "request failed: " <> transport_error_to_string(e)
BadStatus(status, code, message, _) ->
"HTTP "
<> string.inspect(status)
<> option.map(code, fn(c) { " " <> c }) |> option.unwrap("")
<> option.map(message, fn(m) { ": " <> m }) |> option.unwrap("")
DecodeFailed(e) -> "decode failed: " <> e
}
}
/// Decode a JSON response body, wrapping decode failures as `DecodeFailed`.
pub fn parse(body: String, decoder: decode.Decoder(a)) -> Result(a, XrpcError) {
json.parse(body, decoder)
|> result.map_error(fn(e) { DecodeFailed(string.inspect(e)) })
}
/// Turn a received response into a `Result`: `Ok` on 2xx, else a `BadStatus`
/// with the atproto `error`/`message` parsed out of the body when present.
pub fn check_ok(resp: Response(String)) -> Result(Response(String), XrpcError) {
case resp.status >= 200 && resp.status < 300 {
True -> Ok(resp)
False -> {
let #(error, message) = parse_error(resp.body)
Error(BadStatus(resp.status, error, message, resp.body))
}
}
}
/// `check_ok` for byte-bodied responses (blob and image downloads); the
/// error body on a bad status is decoded leniently for the message.
pub fn check_ok_bits(
resp: Response(BitArray),
) -> Result(Response(BitArray), XrpcError) {
case resp.status >= 200 && resp.status < 300 {
True -> Ok(resp)
False -> {
let body =
bit_array.to_string(resp.body) |> result.unwrap("<binary body>")
let #(error, message) = parse_error(body)
Error(BadStatus(resp.status, error, message, body))
}
}
}
fn parse_error(body: String) -> #(Option(String), Option(String)) {
let decoder = {
use error <- decode.optional_field(
"error",
None,
decode.optional(decode.string),
)
use message <- decode.optional_field(
"message",
None,
decode.optional(decode.string),
)
decode.success(#(error, message))
}
json.parse(body, decoder) |> result.unwrap(#(None, None))
}