README

marianoguerra/atproto/xrpc does not have a README file

#
Transport

pub(open) trait Transport {
async fn send(Self, HttpRequest) -> HttpResponse
fn describe(Self) -> String
}

A thing that can send an XRPC request and return what came back.

Deliberately dumb. An implementation has exactly three obligations, and all three are things a hand-written one gets wrong:

  • Lower-case the response header names.
  • Do not forward content-length; most HTTP clients compute their own, and sending both risks two conflicting headers on the wire.
  • Do not let the underlying client's errors escape. Translate them, or a caller's catch sees a type from a library it never imported.

#
XrpcError

pub(all) suberror XrpcError {
RequestFailed(String)
Status(status~ : Int, error~ : String, message~ : String?, headers~ : Map[String, String])
RateLimited(retry_after_millis~ : Int64?, error~ : String, message~ : String?)
Decode(
DecodeError
)
Encode(String)
} derive(
Debug
)

impl Show for XrpcError

#
XrpcError::describe_error

fn XrpcError::describe_error(self : XrpcError) -> String

#
XrpcError::error_name

fn XrpcError::error_name(self : XrpcError) -> String

The machine-readable name: the error field of the body, or a name derived from the status when the server did not send one.

#
XrpcError::is_expired_token

fn XrpcError::is_expired_token(self : XrpcError) -> Bool

The access token has expired and a refresh should be attempted.

The condition is upstream's and it is not just "401": a PDS may answer 400 with ExpiredToken, and a client that only checked the status would log the user out instead of refreshing.

#
XrpcError::message

fn XrpcError::message(self : XrpcError) -> String?

#
XrpcError::retry_after_millis

fn XrpcError::retry_after_millis(self : XrpcError) -> Int64?

How long to wait before retrying, if the server said.

None means it did not, and the caller should pick its own backoff -- the reference implementation uses min(30s, 500ms * 2^attempt).

#
XrpcError::should_retry

fn XrpcError::should_retry(self : XrpcError) -> Bool

Whether the same request is worth sending again.

The status set is the reference implementation's RETRYABLE_HTTP_STATUS_CODES. A transport failure is retryable because it may never have reached the server; a 4xx other than these is not, because sending it again produces the same answer.

#
XrpcError::status

fn XrpcError::status(self : XrpcError) -> Int?

#
Body

pub(all) enum Body {
Empty
Json(
LexValue
)
Blob(bytes~ : Bytes, mime_type~ : String)
} derive(Eq,
Debug
)

What goes in the request body.

#
Credential

pub(all) enum Credential {
Anonymous
Bearer(String)
} derive(Eq,
Debug
)

How the caller proves who it is.

#
HttpRequest

pub(all) struct HttpRequest {
url : String
http_method : String
headers : Map[String, String]
body : Bytes
} derive(Eq,
Debug
)

One outbound call, already reduced to bytes.

#
HttpRequest::body_text

fn HttpRequest::body_text(self : HttpRequest) -> String

The body as text, for logging and tests. Lossy by design: a blob upload is not text and printing it should not fail.

#
HttpRequest::content_length

fn HttpRequest::content_length(self : HttpRequest) -> Int

The UTF-8 byte count, which is what Content-Length must be -- and is not the string length, because MoonBit strings are UTF-16.

#
HttpResponse

pub(all) struct HttpResponse {
status : Int
headers : Map[String, String]
body : Bytes
} derive(Eq,
Debug
)

#
HttpResponse::body_text

fn HttpResponse::body_text(self : HttpResponse) -> String

The body as text. Lossy, because a malformed body must still be reportable rather than turning into a second, less useful error.

#
HttpResponse::content_type

fn HttpResponse::content_type(self : HttpResponse) -> String?

The media type, without parameters -- application/json from application/json; charset=utf-8.

#
HttpResponse::header

fn HttpResponse::header(self : HttpResponse, name : String) -> String?

#
Method

pub(all) enum Method {
Query
Procedure
} derive(Eq,
Debug
)

A query reads and a procedure writes, and that is the only thing that decides the HTTP method.

#
Method::http_method

fn Method::http_method(self : Method) -> String

#
ParamValue

pub(all) enum ParamValue {
Str(String)
Int(Int64)
Bool(Bool)
Strs(Array[String])
Ints(Array[Int64])
Bools(Array[Bool])
} derive(Eq,
Debug
)

#
Params

pub(all) struct Params {
entries : Array[(String, ParamValue)]
} derive(Eq,
Debug
)

An ordered list rather than a map, because two entries may share a key -- that is how arrays are spelled -- and because a stable order makes a request comparable in a test.

#
Params::encode

fn Params::encode(self : Params) -> String

The query string, without a leading ?, or "" when there is nothing to send. An empty array contributes nothing at all, which matches the reference implementation -- ?tags= would mean a tag that is the empty string.

#
Params::is_empty

fn Params::is_empty(self : Params) -> Bool

#
Params::length

fn Params::length(self : Params) -> Int

#
Params::new

fn Params::new() -> Params

#
Params::of

fn Params::of(entries : Array[(String, ParamValue)]) -> Params

#
Params::put

fn Params::put(self : Params, key : String, value : ParamValue) -> Unit

#
Params::put_bool

fn Params::put_bool(self : Params, key : String, value : Bool?) -> Unit

#
Params::put_int

fn Params::put_int(self : Params, key : String, value : Int64?) -> Unit

#
Params::put_opt

fn Params::put_opt(self : Params, key : String, value : ParamValue?) -> Unit

The whole reason this type exists rather than a Map: an absent optional argument must vanish, not become an empty string. Every generated call is a column of these.

#
Params::put_string

fn Params::put_string(self : Params, key : String, value : String?) -> Unit

#
Params::put_strings

fn Params::put_strings(self : Params, key : String, value : Array[String]?) -> Unit

#
USER_AGENT

let USER_AGENT : String

#
build_request

fn build_request(service : String, nsid :
Nsid
, kind : Method, params? : Params, body? : Body, credential? : Credential, accept? : String, extra_headers? : Map[String, String]) -> HttpRequest

Assembles a request. Pure, and the reason almost all of this package can be tested without a transport.

service is the origin to send to -- https://bsky.social, or the PDS from the account's DID document. A trailing slash on it is tolerated, because callers paste these from configuration and one showing up should not produce //xrpc/....

#
interpret

Turns a response into the decoded body, or into the right error.

The order matters. A 429 is a rate limit whatever its body says; a non-2xx is an error even if the body happens to parse; and only then is a 2xx body worth decoding.

#
interpret_bytes

fn interpret_bytes(response : HttpResponse) -> Bytes raise XrpcError

A 2xx whose body is not JSON: getBlob, getRepo, the video endpoints. Returns the bytes and lets the caller decide what they are.

#
percent_encode

fn percent_encode(text : String) -> String

RFC 3986 percent-encoding of everything outside the unreserved set.

Encoding goes through the UTF-8 bytes, not the string's UTF-16 code units. Getting that wrong is invisible until someone searches for a word with an accent in it, and then the query silently matches nothing.

#
status_error_name

fn status_error_name(status : Int) -> String

The name to use when the server sent a status but no error field.

The table is the reference implementation's ResponseType. Anything unlisted collapses to InvalidRequest below 500 and InternalServerError at or above, which is what upstream's fallback does.

#
to_xrpc_error

fn to_xrpc_error(e : Error) -> XrpcError

Narrows a caught Error back to this taxonomy.

Needed because variant patterns are the only way to match an error value -- the suberror TYPE name is not a pattern -- and they are only in scope inside this package. Anything from elsewhere becomes a transport failure, which is the honest reading: it came from the code that does the sending.