refactor(http)!: one error model and a robust XRPC transport #117

Closed
opened 2026-09-24 12:39:02 +00:00 by Grandiras · 0 comments
Owner

Part of the v0.7.0 update cycle (wave 2). One exception model, and an XRPC transport that is correct under shared or singleton use, fast on large responses, and bounded when rate-limited.

Error model

  • AtProtoException base for every SDK exception. The core XrpcException : AtProtoException carries Nsid, Error (the XRPC error name), ErrorMessage, HttpStatusCode StatusCode, ResponseBody, Headers and bool Is(string error). Add XrpcRateLimitException (RetryAfter, RateLimit), XrpcAuthenticationException, XrpcResponseFormatException, and XrpcErrors constants (RecordNotFound, InvalidSwap, ExpiredToken, …).
  • The server's ATProtoNet.Server.Xrpc.XrpcException becomes the core type, so one type serves both sides.
  • Rebase the other SDK exceptions on AtProtoException, with consistent Error naming where cheap: OAuthException.ErrorCode, SpaceCredentialException, SpaceTokenException, SpaceRepoVerificationException, JetstreamConnectException, JetstreamArchiveException, server SpaceVerificationException. The DID-resolution exceptions are unified by the identity issue.
  • One internal XRPC error-body reader replaces the 5 copies (XrpcClient, AtProtoHttpException, SpaceCredentialProvider, whose duplicate XrpcErrorBody goes, JetstreamArchiveClient, JetstreamDictionaryClient).
  • SDK code matches XrpcErrors constants, never string literals. RecordCollection "not found" matches RecordNotFound only (today any InvalidRequest counts).

Transport (XrpcClient)

  • Hold the service Uri and build absolute request URIs; never mutate HttpClient.BaseAddress. Today SetPdsUrl / ApplyOAuthSessionAsync throw once the HttpClient has sent a request, and an injected client's BaseAddress silently overrides InstanceUrl. Replace them with Uri ServiceUrl { get; } / SetServiceUrl(Uri).
  • Set User-Agent per request (AtProtoClientOptions.UserAgent) and never append to a shared DefaultRequestHeaders. Today 100 clients on one HttpClient produce a 1,899-character header.
  • Send atproto-proxy and atproto-accept-labelers on unauthenticated calls too. Add per-call XrpcCallOptions (Proxy, AcceptLabelers, Headers, Timeout) so concurrent callers on a shared client don't race on global setters.
  • Read JSON responses with HttpCompletionOption.ResponseHeadersRead and deserialize from the stream. For a 185 KB timeline this measured −20 % time, −32 % allocation, and no Gen2 GCs (58 per 1,000 calls before).
  • Every SDK-owned HttpClient uses one internal shared SocketsHttpHandler with AutomaticDecompression = All, PooledConnectionLifetime = 5 min and a connect timeout. The existing named DI clients get the same primary-handler settings. The SDK sends no Accept-Encoding today, and a timeline is 185 KB uncompressed vs 12 KB gzipped.
  • Rate limits: cap the wait (MaxRateLimitDelay, default about 30 s) and throw XrpcRateLimitException beyond it. Today a daily-window limit can sleep for hours. Parse the HTTP-date form of Retry-After, add jitter, and put this in AtProtoClientOptions.RateLimit.
  • Query formatting: DateTime/DateTimeOffset as ISO-8601 UTC (today 09/24/2026 12:00:00 +00:00); enums via their JSON names; identifier types via ToString(). Add XrpcParams overloads for long?, DateTimeOffset? and ISpanFormattable. Merge XrpcQueryBuilder into XrpcParams.
  • Streaming downloads (blob, CAR, repo) return an XrpcStreamResponse : IAsyncDisposable (Content, ContentType, ContentLength) that owns and disposes the HttpResponseMessage.
  • Uploads rewind to the caller's initial stream position, not 0. A non-seekable stream that needs a retry (DPoP nonce, 429) fails clearly.
  • Deserialization failures surface as XrpcResponseFormatException.
  • Collapse the overloads: one QueryAsync and one ProcedureAsync family plus one JSON reader. Remove the 0-reference members and the no-op IDisposable. ILogger? becomes optional. Decide whether XrpcClient becomes internal, coordinating with the records/custom-XRPC issue, which wants an extension seam.

Out of scope: auth-header and DPoP application internals (the primitives issue), session refresh (the session issue), and DI and package changes (the restructure issue).

CHANGELOG: Breaking changes (exception types, Uri-typed URL members, removed setters) with migration notes; Changed (compression, rate-limit cap); Fixed (BaseAddress, User-Agent, headers, formatting).

Part of the v0.7.0 update cycle (wave 2). One exception model, and an XRPC transport that is correct under shared or singleton use, fast on large responses, and bounded when rate-limited. ## Error model - [ ] `AtProtoException` base for every SDK exception. The core `XrpcException : AtProtoException` carries `Nsid`, `Error` (the XRPC error name), `ErrorMessage`, `HttpStatusCode StatusCode`, `ResponseBody`, `Headers` and `bool Is(string error)`. Add `XrpcRateLimitException` (`RetryAfter`, `RateLimit`), `XrpcAuthenticationException`, `XrpcResponseFormatException`, and `XrpcErrors` constants (`RecordNotFound`, `InvalidSwap`, `ExpiredToken`, …). - [ ] The server's `ATProtoNet.Server.Xrpc.XrpcException` becomes the core type, so one type serves both sides. - [ ] Rebase the other SDK exceptions on `AtProtoException`, with consistent `Error` naming where cheap: `OAuthException.ErrorCode`, `SpaceCredentialException`, `SpaceTokenException`, `SpaceRepoVerificationException`, `JetstreamConnectException`, `JetstreamArchiveException`, server `SpaceVerificationException`. The DID-resolution exceptions are unified by the identity issue. - [ ] One internal XRPC error-body reader replaces the 5 copies (`XrpcClient`, `AtProtoHttpException`, `SpaceCredentialProvider`, whose duplicate `XrpcErrorBody` goes, `JetstreamArchiveClient`, `JetstreamDictionaryClient`). - [ ] SDK code matches `XrpcErrors` constants, never string literals. `RecordCollection` "not found" matches `RecordNotFound` only (today any `InvalidRequest` counts). ## Transport (`XrpcClient`) - [ ] Hold the service `Uri` and build absolute request URIs; **never mutate `HttpClient.BaseAddress`**. Today `SetPdsUrl` / `ApplyOAuthSessionAsync` throw once the `HttpClient` has sent a request, and an injected client's `BaseAddress` silently overrides `InstanceUrl`. Replace them with `Uri ServiceUrl { get; }` / `SetServiceUrl(Uri)`. - [ ] Set `User-Agent` per request (`AtProtoClientOptions.UserAgent`) and never append to a shared `DefaultRequestHeaders`. Today 100 clients on one `HttpClient` produce a 1,899-character header. - [ ] Send `atproto-proxy` and `atproto-accept-labelers` on unauthenticated calls too. Add per-call `XrpcCallOptions` (`Proxy`, `AcceptLabelers`, `Headers`, `Timeout`) so concurrent callers on a shared client don't race on global setters. - [ ] Read JSON responses with `HttpCompletionOption.ResponseHeadersRead` and deserialize from the stream. For a 185 KB timeline this measured −20 % time, −32 % allocation, and no Gen2 GCs (58 per 1,000 calls before). - [ ] Every SDK-owned `HttpClient` uses one internal shared `SocketsHttpHandler` with `AutomaticDecompression = All`, `PooledConnectionLifetime = 5 min` and a connect timeout. The existing named DI clients get the same primary-handler settings. The SDK sends no `Accept-Encoding` today, and a timeline is 185 KB uncompressed vs 12 KB gzipped. - [ ] Rate limits: cap the wait (`MaxRateLimitDelay`, default about 30 s) and throw `XrpcRateLimitException` beyond it. Today a daily-window limit can sleep for hours. Parse the HTTP-date form of `Retry-After`, add jitter, and put this in `AtProtoClientOptions.RateLimit`. - [ ] Query formatting: `DateTime`/`DateTimeOffset` as ISO-8601 UTC (today `09/24/2026 12:00:00 +00:00`); enums via their JSON names; identifier types via `ToString()`. Add `XrpcParams` overloads for `long?`, `DateTimeOffset?` and `ISpanFormattable`. Merge `XrpcQueryBuilder` into `XrpcParams`. - [ ] Streaming downloads (blob, CAR, repo) return an `XrpcStreamResponse : IAsyncDisposable` (`Content`, `ContentType`, `ContentLength`) that owns and disposes the `HttpResponseMessage`. - [ ] Uploads rewind to the caller's initial stream position, not `0`. A non-seekable stream that needs a retry (DPoP nonce, 429) fails clearly. - [ ] Deserialization failures surface as `XrpcResponseFormatException`. - [ ] Collapse the overloads: one `QueryAsync` and one `ProcedureAsync` family plus one JSON reader. Remove the 0-reference members and the no-op `IDisposable`. `ILogger?` becomes optional. Decide whether `XrpcClient` becomes internal, coordinating with the records/custom-XRPC issue, which wants an extension seam. Out of scope: auth-header and DPoP application internals (the primitives issue), session refresh (the session issue), and DI and package changes (the restructure issue). CHANGELOG: `Breaking changes` (exception types, `Uri`-typed URL members, removed setters) with migration notes; `Changed` (compression, rate-limit cap); `Fixed` (BaseAddress, User-Agent, headers, formatting).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#117
No description provided.