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

Merged
Grandiras merged 1 commit from issue-117-xrpc-transport into main 2026-09-24 19:17:38 +00:00
Owner

Closes #117.

One exception model for the whole SDK, and an XRPC transport that is safe to share between clients and callers, streams large responses, and cannot hang on a rate limit.

Error model

  • AtProtoException (namespace ATProtoNet) is the abstract base of every exception the SDK raises itself. Argument errors, misuse, cancellation and transport faults (HttpRequestException when no response arrived) stay as .NET's own.
  • XrpcException : AtProtoException (namespace ATProtoNet.Http) replaces AtProtoHttpException. It carries Nsid, Error (never null: without an envelope it gets the XRPC name for the status, e.g. UpstreamFailure for 502), ErrorMessage, StatusCode (HttpStatusCode, no longer shadowing HttpRequestException's), ResponseBody, Headers and Is(name).
    • XrpcRateLimitException (429 that could not be waited out): RetryAfter, RateLimit.
    • XrpcAuthenticationException: any 401, plus ExpiredToken / InvalidToken (a PDS answers those with 400).
    • XrpcResponseFormatException : AtProtoException: a 2xx body that does not deserialize (XRPC calls, RecordCollection<T>, RepoClient.GetRecordAsync<T>), with Nsid and the JsonException inside.
    • XrpcErrors: constants for the generic XRPC names and the common com.atproto.* ones.
  • One type on both sides. ATProtoNet.Server.Xrpc.XrpcException is deleted; hosted endpoints throw the core type. SpaceVerificationException derives from it. The routing's behaviour is unchanged: the {error, message} body (message falls back to the error name as before), the status, and Headers (WWW-Authenticate) are written as before. I did not keep a same-named subclass in ATProtoNet.Server.Xrpc: every file that uses both namespaces would hit CS0104 ambiguity, which is worse than the one-line using migration.
  • Rebased on AtProtoException: OAuthException (ErrorCode renamed Error), SpaceCredentialException, SpaceTokenException, SpaceRepoVerificationException, JetstreamConnectException (gains an Error init property), JetstreamArchiveException. PlcException / DidWebException are left to the identity issue.
  • One error-body reader (XrpcResponseReader, internal) replaces the copies in XrpcClient, AtProtoHttpException, SpaceCredentialProvider (its duplicate XrpcErrorBody is gone), JetstreamArchiveClient and JetstreamDictionaryClient. The Jetstream archive client also uses its Retry-After parser (9.11). It reuses #151's primitives: error bodies are read through BoundedContent.ReadBoundedAsync with a 64 KiB cap (past it the body is not read and the status names the error), and the envelope fields through JsonElementExtensions.GetStringOrNull.
  • No string literals: SDK code matches via XrpcErrors / ex.Is(...); server throws use XrpcErrors.InvalidRequest. RecordCollection.ExistsAsync now treats only RecordNotFound as absence (it also accepted any InvalidRequest).
  • docs/error-handling.md is rewritten around the hierarchy, and documents the namespaces, what is not wrapped, rate limits, timeouts and server-side use.

Transport (XrpcClient)

  • Service URL held by the transport. Requests use absolute URIs (root-relative /xrpc/, as the spec requires); HttpClient.BaseAddress is never read or written. AtProtoClient.PdsUrl / SetPdsUrl(string) become Uri ServiceUrl / SetServiceUrl(Uri). InstanceUrl always wins over a supplied client's BaseAddress (same for PdsAdminClient and PdsAdminOptions.Url). SetServiceUrl keeps the HTTPS-unless-loopback rule, since it is fed from DID documents and OAuth sessions; the constructor keeps trusting configured URLs (e.g. http://pds:3000 in a container network) but now rejects non-absolute and non-http(s) ones up front.
  • User-Agent per request from the new AtProtoClientOptions.UserAgent (default ATProtoNet/<version>; null leaves the HttpClient's default). No SDK code appends to a shared DefaultRequestHeaders any more (AtProtoClient, PdsAdminClient, SpaceReader; the Blazor OAuth service only sets it on the client it owns; the Aspire named client's dead ATProtoNet.Aspire/1.0 default is removed).
  • Proxy/labelers with or without a session, plus XrpcCallOptions (Proxy, AcceptLabelers, Headers, Timeout) on AtProtoClient.QueryAsync / ProcedureAsync. The client-wide SetProxy / SetLabelers stay as defaults (I did not obsolete them; they are fine for single-user clients). Session calls (createSession, refreshSession, getSession, deleteSession, createAccount) are never routed through the client-wide proxy, since sending them unauthenticated-with-proxy would now reroute sign-in to the AppView. Authorization / DPoP cannot be set through Headers. A Timeout throws TimeoutException; the caller's own token still throws OperationCanceledException. Session state (tokens + DPoP key) is swapped as one immutable snapshot.
  • Streaming reads: ResponseHeadersRead + JsonSerializer.DeserializeAsync from the stream (F5).
  • Shared handler (F6): AtProtoHttp.SharedHandler, a SocketsHttpHandler with AutomaticDecompression = All, PooledConnectionLifetime = 5 min, ConnectTimeout = 10 s, used with disposeHandler: false by every SDK-owned client (AtProtoClient, PdsAdminClient, SpaceCredentialProvider/readers, JetstreamArchiveClient, JetstreamDictionaryClient, PlcClient, DidWebResolver, the Blazor OAuth service). Falls back to HttpClientHandler where sockets are unsupported (Blazor WASM). The named DI clients (AddAtProtoServer, AddAtProto, AddAtProtoScoped, AddAtProtoClient, AddAtProtoSpaces, AddAtProtoPdsAdmin) get ConfigurePrimaryHttpMessageHandler(AtProtoHttp.CreateHandler), nothing else (#136 redesigns DI). This needs InternalsVisibleTo for ATProtoNet.Server (already added by #151) and ATProtoNet.Blazor (added here) in the core csproj.
  • Rate limits (F7, 9.11, 1.12 client side): AtProtoClientOptions.RateLimit = XrpcRateLimitOptions { MaxRetries = 3, MaxDelay = 30 s }. A requested wait beyond MaxDelay throws XrpcRateLimitException immediately with RetryAfter; Retry-After is parsed as seconds or HTTP date, falling back to RateLimit-Reset; up to 10 % jitter; our own exponential back-off is clamped to MaxDelay. MaxRetries = 0 lets an outer resilience handler own retries.
  • Timestamps: AtProtoJsonDefaults.FormatTimestamp / NowTimestamp now format in the invariant culture; under th-TH every createdAt the SDK stamped carried a Buddhist-calendar year. XrpcParams formats timestamps through it.
  • Query formatting (5.5, F26): XrpcQueryBuilder is merged into XrpcParams (XrpcParams.From(object?)), with a per-type property cache. DateTime/DateTimeOffset → yyyy-MM-ddTHH:mm:ss.fffZ (invariant culture and calendar; Unspecified taken as UTC), enums → their JSON name via AtProtoJsonDefaults.Options (honours [JsonStringEnumMemberName]), identifiers → ToString(). New overloads: Add(string, long?), Add(string, DateTimeOffset?), Add<T>(string, T?) where T : struct, ISpanFormattable.
  • XrpcStreamResponse : IAsyncDisposable, IDisposable (Content, ContentType, ContentLength) from SyncClient.GetBlobAsync/GetRepoAsync and SpaceClient.GetBlobAsync/GetRepoAsync; it owns the HttpResponseMessage. A failed download now disposes its response too.
  • Uploads send from and rewind to the caller's initial position through a non-disposing HttpContent (Content-Length computed from that position). A non-seekable stream that needs a retry throws InvalidOperationException("…cannot be rewound…") with the XRPC error as inner exception, instead of resending an empty body.
  • Collapsed API: one QueryAsync<T>, ProcedureAsync<T> / ProcedureAsync (body as object?, serialized by runtime type), UploadAsync<T>, DownloadAsync, and one ReadJsonAsync; ProcedureWithRefreshTokenAsync goes through the same pipeline. The three internal proxy overloads became XrpcCallOptions. Removed: BaseUrl, IsAuthenticated, UpdateDPoPNonce, the body-less QueryAsync, _useDPoP, the no-op IDisposable. ILogger is optional.
  • XrpcClient is internal. Nothing public exposed it (every sub-client constructor was already internal, and AtProtoClient never returned it); its only doc mention was managed-pds.md. #122 is the place for a public transport seam, designed as such rather than inherited from this class. The ~200 Lexicon call sites were updated mechanically (ProcedureAsync<TReq, TResp> → ProcedureAsync<TResp>, void generic calls → non-generic, positional cancellation tokens named); EmptyResponseBodyTests caught the one void call that would have silently rebound to the deserializing overload.

Breaking changes and migration

Change Migration
AtProtoHttpException → XrpcException (no longer an HttpRequestException); ErrorType → Error catch (XrpcException ex) when (ex.Is(XrpcErrors.X)); catch XrpcException where HttpRequestException was caught for XRPC errors
Server ATProtoNet.Server.Xrpc.XrpcException removed; status is HttpStatusCode (also on SpaceVerificationException) using ATProtoNet.Http;, HttpStatusCode.NotFound for StatusCodes.Status404NotFound
SDK exceptions derive from AtProtoException; OAuthException.ErrorCode → Error recompile; ex.Error
Deserialization failures throw XrpcResponseFormatException catch it instead of JsonException / InvalidOperationException
PdsUrl / SetPdsUrl(string) → ServiceUrl / SetServiceUrl(Uri) SetServiceUrl(new Uri(url))
Supplied HttpClient.BaseAddress ignored put the URL in InstanceUrl / PdsAdminOptions.Url
QueryAsync / ProcedureAsync gain XrpcCallOptions? options before the token pass cancellationToken: by name
Downloads return XrpcStreamResponse await using var r = …; r.Content
XrpcClient internal; MaxRateLimitRetries → AtProtoClientOptions.RateLimit.MaxRetries use AtProtoClient.QueryAsync / ProcedureAsync

Behaviour changes without an API break (CHANGELOG Changed): compressed responses, 30 s rate-limit cap, proxy/labeler headers without a session, Aspire/Blazor UA handling, SpaceReaders sharing the provider's HttpClient (SpaceReader.Dispose now releases nothing).

Verification

  • dotnet build (full solution, --no-incremental): 0 warnings, 0 errors.

  • Unit tests: 2,824 passed, 0 failed, 5 skipped (2,829 total), on top of main at 06becd0 (#149, #150, #151). The old AtProtoHttpException and XrpcQueryBuilder tests were rewritten into XrpcExceptionTests and XrpcParamsTests.

  • New tests: XrpcTransportTests (BaseAddress ignored/untouched, SetServiceUrl and ApplyOAuthSessionAsync after a request on a shared client, 100 clients on one HttpClient leave DefaultRequestHeaders.UserAgent empty and send one UA, per-client/null UA, proxy+labelers without a session, per-call options vs defaults, 50 concurrent calls with different proxies, session calls unproxied, reserved headers, timeout vs caller cancellation, no-buffer streaming via a content that records whether HttpClient buffered it, format/empty-body errors, download metadata and disposal on success and failure, upload position/rewind/non-seekable, DPoP nonce retry with nonce/htu checked in the proof), XrpcExceptionTests (properties, hierarchy for all rebased types, envelope/HTML/malformed bodies, 401/token-error mapping, 429 with Retry-After seconds and HTTP date, RateLimit-Reset fallback), XrpcClientRateLimitTests (cap exceeded throws at once with RetryAfter = 1 day or ~3 h from RateLimit-Reset, HTTP date in past/future, retries exhausted, options wiring), XrpcParamsTests (merged builder tests; ISO timestamps under th-TH/fi-FI/ar-SA, Unspecified/Local DateTime, camelCase and [JsonStringEnumMemberName] enums, identifiers, cached reflection), AtProtoHttpTests (handler settings, owned clients share the handler, disposing an owner leaves it usable, AddAtProtoServer's named client), RecordCollectionErrorTests (ExistsAsync only on RecordNotFound, format errors from GetAsync and GetRecordAsync<T>), TimestampFormatTests (FormatTimestamp under th-TH, ja-JP, fi-FI, ar-SA), and error-body tests for the 64 KiB cap. The DPoP nonce test decodes the proof with #151's Jwt.TryDecode.

  • Benchmark (the audit's XrpcBench, QueryAsync<FeedResponse>, 50 items / 185,834 B, in-memory handler, BenchmarkDotNet MediumRun, same machine and run):

    Mean Allocated Gen2 / 1k ops
    XrpcClient Bearer, this branch 388.9 µs ± 5.9 391.7 KB 0
    ResponseContentRead + ReadFromJsonAsync (what v0.6 did) 503.7 µs ± 17.4 572.8 KB 58.6
    v0.6 XrpcClient Bearer, from the audit's run 458.7 µs 587.3 KB 58.6

    About −20 % to −23 % time, −32 % allocation, no Gen2 collections. DPoP adds ~20 µs and ~11 KB per call in the ShortRun (the MediumRun DPoP row was noisy); proof generation is unchanged here.

Rebase onto #149, #150, #151

Rebased onto main after #149, #150 and #151. Semantic conflicts fixed: #149's new UpdateProfileAsync caught AtProtoHttpException for InvalidSwap / RecordNotFound; it now matches XrpcErrors and reads the stored profile through RepoClient.GetRecordAsync<ProfileRecord>, so a malformed one surfaces as XrpcResponseFormatException. #151's new OAuthException.ErrorCode test assertion was moved to Error; its InternalsVisibleTo ATProtoNet.Server line is kept and only the Blazor line is added. SpaceTokenException keeps #151's changes and derives from AtProtoException.

Review follow-up

Addressing the claude-bot review (COMMENT):

  1. Base URLs with a query or fragment. AtProtoHttp.NormalizeBaseUrl (both overloads) now rejects them with ArgumentException, like the constructors' ValidateServiceUrl, and builds the result from scheme + authority + path (GetLeftPart(UriPartial.Path)) instead of the raw string. It also requires http/https: on Unix a rooted path such as /xrpc otherwise parses as an absolute file: URI. ValidateServiceUrl checks the same with the caller's parameter name, so InstanceUrl, SetServiceUrl, PdsAdminOptions.Url, PlcClient's directory URL and SpaceCredentialProvider.CreateReaderAsync's host URL all refuse such URLs up front. Endpoints read from DID documents go through a new TryNormalizeBaseUrl and fail in each caller's own terms: SpaceCredentialProvider.ResolveHostAsync throws SpaceCredentialException, and the managing-app client throws the InvalidOperationException the access policy already treats as a refusal. Tests cover both overloads (trailing slashes, ports, paths, ?x=1, bare ?, #frag, bare #, relative, non-http), ValidateServiceUrl's parameter name, AtProtoClient's constructor and SetServiceUrl, the reader host check, a DID document with a bad endpoint, and the managing-app refusal. CHANGELOG Changed has a bullet for it.
  2. Direct and the proxy default. The request path did already suppress the client-wide default for IsDirect calls. The precedence is now explicit in one place: ResolveProxy / ResolveLabelers are a per-call value if set, otherwise nothing for a direct call, otherwise the client default. Direct is just IsDirect = true, and IsDirect now suppresses the labeler default too, instead of relying on an empty AcceptLabelers list. The Proxy, AcceptLabelers and IsDirect XML docs state the precedence and list the session calls. SessionCalls_AreNotRoutedThroughTheDefaultProxy now calls SetProxy and SetLabelers, then LoginAsync, RefreshSessionAsync, ResumeSessionAsync, Server.CreateAccountAsync and LogoutAsync. It asserts that none of the five session requests carries atproto-proxy or atproto-accept-labelers, and that an ordinary call in the same test carries both. A second test pins that an explicit per-call Proxy still wins on a direct call.

Deferred / flagged

  • AtProtoClientOptions.InstanceUrl stays a string (config binding, builder, Aspire settings); a Uri there belongs with the options redesign in #136.
  • The api.md "not-found story" (FindAsync / FindRecordAsync) and WithDefaults(XrpcCallOptions) views are not in the issue checklist and are left out.
  • The Aspire resilience-handler change (1.12's package side), ConfigureAwait(false) (5.9), and DPoP nonce sharing across clients (F3) are later PRs.

🤖 Generated with Claude Code

Closes #117. One exception model for the whole SDK, and an XRPC transport that is safe to share between clients and callers, streams large responses, and cannot hang on a rate limit. ## Error model - **`AtProtoException`** (namespace `ATProtoNet`) is the abstract base of every exception the SDK raises itself. Argument errors, misuse, cancellation and transport faults (`HttpRequestException` when no response arrived) stay as .NET's own. - **`XrpcException : AtProtoException`** (namespace `ATProtoNet.Http`) replaces `AtProtoHttpException`. It carries `Nsid`, `Error` (never null: without an envelope it gets the XRPC name for the status, e.g. `UpstreamFailure` for 502), `ErrorMessage`, `StatusCode` (`HttpStatusCode`, no longer shadowing `HttpRequestException`'s), `ResponseBody`, `Headers` and `Is(name)`. - `XrpcRateLimitException` (429 that could not be waited out): `RetryAfter`, `RateLimit`. - `XrpcAuthenticationException`: any 401, plus `ExpiredToken` / `InvalidToken` (a PDS answers those with 400). - `XrpcResponseFormatException : AtProtoException`: a 2xx body that does not deserialize (XRPC calls, `RecordCollection<T>`, `RepoClient.GetRecordAsync<T>`), with `Nsid` and the `JsonException` inside. - `XrpcErrors`: constants for the generic XRPC names and the common `com.atproto.*` ones. - **One type on both sides.** `ATProtoNet.Server.Xrpc.XrpcException` is deleted; hosted endpoints throw the core type. `SpaceVerificationException` derives from it. The routing's behaviour is unchanged: the `{error, message}` body (message falls back to the error name as before), the status, and `Headers` (`WWW-Authenticate`) are written as before. I did not keep a same-named subclass in `ATProtoNet.Server.Xrpc`: every file that uses both namespaces would hit CS0104 ambiguity, which is worse than the one-line `using` migration. - **Rebased on `AtProtoException`:** `OAuthException` (`ErrorCode` renamed `Error`), `SpaceCredentialException`, `SpaceTokenException`, `SpaceRepoVerificationException`, `JetstreamConnectException` (gains an `Error` init property), `JetstreamArchiveException`. `PlcException` / `DidWebException` are left to the identity issue. - **One error-body reader** (`XrpcResponseReader`, internal) replaces the copies in `XrpcClient`, `AtProtoHttpException`, `SpaceCredentialProvider` (its duplicate `XrpcErrorBody` is gone), `JetstreamArchiveClient` and `JetstreamDictionaryClient`. The Jetstream archive client also uses its `Retry-After` parser (9.11). It reuses #151's primitives: error bodies are read through `BoundedContent.ReadBoundedAsync` with a 64 KiB cap (past it the body is not read and the status names the error), and the envelope fields through `JsonElementExtensions.GetStringOrNull`. - **No string literals:** SDK code matches via `XrpcErrors` / `ex.Is(...)`; server throws use `XrpcErrors.InvalidRequest`. `RecordCollection.ExistsAsync` now treats only `RecordNotFound` as absence (it also accepted any `InvalidRequest`). - `docs/error-handling.md` is rewritten around the hierarchy, and documents the namespaces, what is not wrapped, rate limits, timeouts and server-side use. ## Transport (`XrpcClient`) - **Service URL held by the transport.** Requests use absolute URIs (root-relative `/xrpc/`, as the spec requires); `HttpClient.BaseAddress` is never read or written. `AtProtoClient.PdsUrl` / `SetPdsUrl(string)` become `Uri ServiceUrl` / `SetServiceUrl(Uri)`. `InstanceUrl` always wins over a supplied client's `BaseAddress` (same for `PdsAdminClient` and `PdsAdminOptions.Url`). `SetServiceUrl` keeps the HTTPS-unless-loopback rule, since it is fed from DID documents and OAuth sessions; the constructor keeps trusting configured URLs (e.g. `http://pds:3000` in a container network) but now rejects non-absolute and non-http(s) ones up front. - **User-Agent per request** from the new `AtProtoClientOptions.UserAgent` (default `ATProtoNet/<version>`; `null` leaves the `HttpClient`'s default). No SDK code appends to a shared `DefaultRequestHeaders` any more (`AtProtoClient`, `PdsAdminClient`, `SpaceReader`; the Blazor OAuth service only sets it on the client it owns; the Aspire named client's dead `ATProtoNet.Aspire/1.0` default is removed). - **Proxy/labelers with or without a session**, plus **`XrpcCallOptions`** (`Proxy`, `AcceptLabelers`, `Headers`, `Timeout`) on `AtProtoClient.QueryAsync` / `ProcedureAsync`. The client-wide `SetProxy` / `SetLabelers` stay as defaults (I did not obsolete them; they are fine for single-user clients). Session calls (`createSession`, `refreshSession`, `getSession`, `deleteSession`, `createAccount`) are never routed through the client-wide proxy, since sending them unauthenticated-with-proxy would now reroute sign-in to the AppView. `Authorization` / `DPoP` cannot be set through `Headers`. A `Timeout` throws `TimeoutException`; the caller's own token still throws `OperationCanceledException`. Session state (tokens + DPoP key) is swapped as one immutable snapshot. - **Streaming reads:** `ResponseHeadersRead` + `JsonSerializer.DeserializeAsync` from the stream (F5). - **Shared handler (F6):** `AtProtoHttp.SharedHandler`, a `SocketsHttpHandler` with `AutomaticDecompression = All`, `PooledConnectionLifetime = 5 min`, `ConnectTimeout = 10 s`, used with `disposeHandler: false` by every SDK-owned client (`AtProtoClient`, `PdsAdminClient`, `SpaceCredentialProvider`/readers, `JetstreamArchiveClient`, `JetstreamDictionaryClient`, `PlcClient`, `DidWebResolver`, the Blazor OAuth service). Falls back to `HttpClientHandler` where sockets are unsupported (Blazor WASM). The named DI clients (`AddAtProtoServer`, `AddAtProto`, `AddAtProtoScoped`, `AddAtProtoClient`, `AddAtProtoSpaces`, `AddAtProtoPdsAdmin`) get `ConfigurePrimaryHttpMessageHandler(AtProtoHttp.CreateHandler)`, nothing else (#136 redesigns DI). This needs `InternalsVisibleTo` for `ATProtoNet.Server` (already added by #151) and `ATProtoNet.Blazor` (added here) in the core csproj. - **Rate limits (F7, 9.11, 1.12 client side):** `AtProtoClientOptions.RateLimit` = `XrpcRateLimitOptions { MaxRetries = 3, MaxDelay = 30 s }`. A requested wait beyond `MaxDelay` throws `XrpcRateLimitException` immediately with `RetryAfter`; `Retry-After` is parsed as seconds or HTTP date, falling back to `RateLimit-Reset`; up to 10 % jitter; our own exponential back-off is clamped to `MaxDelay`. `MaxRetries = 0` lets an outer resilience handler own retries. - **Timestamps:** `AtProtoJsonDefaults.FormatTimestamp` / `NowTimestamp` now format in the invariant culture; under `th-TH` every `createdAt` the SDK stamped carried a Buddhist-calendar year. `XrpcParams` formats timestamps through it. - **Query formatting (5.5, F26):** `XrpcQueryBuilder` is merged into `XrpcParams` (`XrpcParams.From(object?)`), with a per-type property cache. `DateTime`/`DateTimeOffset` → `yyyy-MM-ddTHH:mm:ss.fffZ` (invariant culture and calendar; `Unspecified` taken as UTC), enums → their JSON name via `AtProtoJsonDefaults.Options` (honours `[JsonStringEnumMemberName]`), identifiers → `ToString()`. New overloads: `Add(string, long?)`, `Add(string, DateTimeOffset?)`, `Add<T>(string, T?) where T : struct, ISpanFormattable`. - **`XrpcStreamResponse : IAsyncDisposable, IDisposable`** (`Content`, `ContentType`, `ContentLength`) from `SyncClient.GetBlobAsync`/`GetRepoAsync` and `SpaceClient.GetBlobAsync`/`GetRepoAsync`; it owns the `HttpResponseMessage`. A failed download now disposes its response too. - **Uploads** send from and rewind to the caller's initial position through a non-disposing `HttpContent` (Content-Length computed from that position). A non-seekable stream that needs a retry throws `InvalidOperationException("…cannot be rewound…")` with the XRPC error as inner exception, instead of resending an empty body. - **Collapsed API:** one `QueryAsync<T>`, `ProcedureAsync<T>` / `ProcedureAsync` (body as `object?`, serialized by runtime type), `UploadAsync<T>`, `DownloadAsync`, and one `ReadJsonAsync`; `ProcedureWithRefreshTokenAsync` goes through the same pipeline. The three internal proxy overloads became `XrpcCallOptions`. Removed: `BaseUrl`, `IsAuthenticated`, `UpdateDPoPNonce`, the body-less `QueryAsync`, `_useDPoP`, the no-op `IDisposable`. `ILogger` is optional. - **`XrpcClient` is internal.** Nothing public exposed it (every sub-client constructor was already internal, and `AtProtoClient` never returned it); its only doc mention was `managed-pds.md`. #122 is the place for a public transport seam, designed as such rather than inherited from this class. The ~200 Lexicon call sites were updated mechanically (`ProcedureAsync<TReq, TResp>` → `ProcedureAsync<TResp>`, void generic calls → non-generic, positional cancellation tokens named); `EmptyResponseBodyTests` caught the one void call that would have silently rebound to the deserializing overload. ## Breaking changes and migration | Change | Migration | |---|---| | `AtProtoHttpException` → `XrpcException` (no longer an `HttpRequestException`); `ErrorType` → `Error` | `catch (XrpcException ex) when (ex.Is(XrpcErrors.X))`; catch `XrpcException` where `HttpRequestException` was caught for XRPC errors | | Server `ATProtoNet.Server.Xrpc.XrpcException` removed; status is `HttpStatusCode` (also on `SpaceVerificationException`) | `using ATProtoNet.Http;`, `HttpStatusCode.NotFound` for `StatusCodes.Status404NotFound` | | SDK exceptions derive from `AtProtoException`; `OAuthException.ErrorCode` → `Error` | recompile; `ex.Error` | | Deserialization failures throw `XrpcResponseFormatException` | catch it instead of `JsonException` / `InvalidOperationException` | | `PdsUrl` / `SetPdsUrl(string)` → `ServiceUrl` / `SetServiceUrl(Uri)` | `SetServiceUrl(new Uri(url))` | | Supplied `HttpClient.BaseAddress` ignored | put the URL in `InstanceUrl` / `PdsAdminOptions.Url` | | `QueryAsync` / `ProcedureAsync` gain `XrpcCallOptions? options` before the token | pass `cancellationToken:` by name | | Downloads return `XrpcStreamResponse` | `await using var r = …; r.Content` | | `XrpcClient` internal; `MaxRateLimitRetries` → `AtProtoClientOptions.RateLimit.MaxRetries` | use `AtProtoClient.QueryAsync` / `ProcedureAsync` | Behaviour changes without an API break (CHANGELOG *Changed*): compressed responses, 30 s rate-limit cap, proxy/labeler headers without a session, Aspire/Blazor UA handling, `SpaceReader`s sharing the provider's `HttpClient` (`SpaceReader.Dispose` now releases nothing). ## Verification - `dotnet build` (full solution, `--no-incremental`): **0 warnings, 0 errors**. - Unit tests: **2,824 passed, 0 failed, 5 skipped** (2,829 total), on top of main at `06becd0` (#149, #150, #151). The old `AtProtoHttpException` and `XrpcQueryBuilder` tests were rewritten into `XrpcExceptionTests` and `XrpcParamsTests`. - New tests: `XrpcTransportTests` (BaseAddress ignored/untouched, `SetServiceUrl` and `ApplyOAuthSessionAsync` after a request on a shared client, 100 clients on one `HttpClient` leave `DefaultRequestHeaders.UserAgent` empty and send one UA, per-client/`null` UA, proxy+labelers without a session, per-call options vs defaults, 50 concurrent calls with different proxies, session calls unproxied, reserved headers, timeout vs caller cancellation, no-buffer streaming via a content that records whether HttpClient buffered it, format/empty-body errors, download metadata and disposal on success and failure, upload position/rewind/non-seekable, DPoP nonce retry with `nonce`/`htu` checked in the proof), `XrpcExceptionTests` (properties, hierarchy for all rebased types, envelope/HTML/malformed bodies, 401/token-error mapping, 429 with `Retry-After` seconds and HTTP date, `RateLimit-Reset` fallback), `XrpcClientRateLimitTests` (cap exceeded throws at once with `RetryAfter` = 1 day or ~3 h from `RateLimit-Reset`, HTTP date in past/future, retries exhausted, options wiring), `XrpcParamsTests` (merged builder tests; ISO timestamps under `th-TH`/`fi-FI`/`ar-SA`, `Unspecified`/`Local` `DateTime`, camelCase and `[JsonStringEnumMemberName]` enums, identifiers, cached reflection), `AtProtoHttpTests` (handler settings, owned clients share the handler, disposing an owner leaves it usable, `AddAtProtoServer`'s named client), `RecordCollectionErrorTests` (`ExistsAsync` only on `RecordNotFound`, format errors from `GetAsync` and `GetRecordAsync<T>`), `TimestampFormatTests` (`FormatTimestamp` under `th-TH`, `ja-JP`, `fi-FI`, `ar-SA`), and error-body tests for the 64 KiB cap. The DPoP nonce test decodes the proof with #151's `Jwt.TryDecode`. - Benchmark (the audit's `XrpcBench`, `QueryAsync<FeedResponse>`, 50 items / 185,834 B, in-memory handler, BenchmarkDotNet MediumRun, same machine and run): | | Mean | Allocated | Gen2 / 1k ops | |---|---|---|---| | `XrpcClient` Bearer, this branch | 388.9 µs ± 5.9 | 391.7 KB | 0 | | `ResponseContentRead` + `ReadFromJsonAsync` (what v0.6 did) | 503.7 µs ± 17.4 | 572.8 KB | 58.6 | | v0.6 `XrpcClient` Bearer, from the audit's run | 458.7 µs | 587.3 KB | 58.6 | About −20 % to −23 % time, −32 % allocation, no Gen2 collections. DPoP adds ~20 µs and ~11 KB per call in the ShortRun (the MediumRun DPoP row was noisy); proof generation is unchanged here. ## Rebase onto #149, #150, #151 Rebased onto main after #149, #150 and #151. Semantic conflicts fixed: #149's new `UpdateProfileAsync` caught `AtProtoHttpException` for `InvalidSwap` / `RecordNotFound`; it now matches `XrpcErrors` and reads the stored profile through `RepoClient.GetRecordAsync<ProfileRecord>`, so a malformed one surfaces as `XrpcResponseFormatException`. #151's new `OAuthException.ErrorCode` test assertion was moved to `Error`; its `InternalsVisibleTo ATProtoNet.Server` line is kept and only the Blazor line is added. `SpaceTokenException` keeps #151's changes and derives from `AtProtoException`. ## Review follow-up Addressing the claude-bot review (COMMENT): 1. **Base URLs with a query or fragment.** `AtProtoHttp.NormalizeBaseUrl` (both overloads) now rejects them with `ArgumentException`, like the constructors' `ValidateServiceUrl`, and builds the result from scheme + authority + path (`GetLeftPart(UriPartial.Path)`) instead of the raw string. It also requires `http`/`https`: on Unix a rooted path such as `/xrpc` otherwise parses as an absolute `file:` URI. `ValidateServiceUrl` checks the same with the caller's parameter name, so `InstanceUrl`, `SetServiceUrl`, `PdsAdminOptions.Url`, `PlcClient`'s directory URL and `SpaceCredentialProvider.CreateReaderAsync`'s host URL all refuse such URLs up front. Endpoints read from DID documents go through a new `TryNormalizeBaseUrl` and fail in each caller's own terms: `SpaceCredentialProvider.ResolveHostAsync` throws `SpaceCredentialException`, and the managing-app client throws the `InvalidOperationException` the access policy already treats as a refusal. Tests cover both overloads (trailing slashes, ports, paths, `?x=1`, bare `?`, `#frag`, bare `#`, relative, non-http), `ValidateServiceUrl`'s parameter name, `AtProtoClient`'s constructor and `SetServiceUrl`, the reader host check, a DID document with a bad endpoint, and the managing-app refusal. CHANGELOG *Changed* has a bullet for it. 2. **`Direct` and the proxy default.** The request path did already suppress the client-wide default for `IsDirect` calls. The precedence is now explicit in one place: `ResolveProxy` / `ResolveLabelers` are a per-call value if set, otherwise nothing for a direct call, otherwise the client default. `Direct` is just `IsDirect = true`, and `IsDirect` now suppresses the labeler default too, instead of relying on an empty `AcceptLabelers` list. The `Proxy`, `AcceptLabelers` and `IsDirect` XML docs state the precedence and list the session calls. `SessionCalls_AreNotRoutedThroughTheDefaultProxy` now calls `SetProxy` and `SetLabelers`, then `LoginAsync`, `RefreshSessionAsync`, `ResumeSessionAsync`, `Server.CreateAccountAsync` and `LogoutAsync`. It asserts that none of the five session requests carries `atproto-proxy` or `atproto-accept-labelers`, and that an ordinary call in the same test carries both. A second test pins that an explicit per-call `Proxy` still wins on a direct call. ## Deferred / flagged - `AtProtoClientOptions.InstanceUrl` stays a `string` (config binding, builder, Aspire settings); a `Uri` there belongs with the options redesign in #136. - The api.md "not-found story" (`FindAsync` / `FindRecordAsync`) and `WithDefaults(XrpcCallOptions)` views are not in the issue checklist and are left out. - The Aspire resilience-handler change (1.12's package side), `ConfigureAwait(false)` (5.9), and DPoP nonce sharing across clients (F3) are later PRs. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
refactor(http)!: one error model and a robust XRPC transport (closes #117)
All checks were successful
CI / pds-integration (pull_request) Successful in 26s
CI / build-and-test (pull_request) Successful in 57s
5e49fbcf1e
The SDK had two unrelated XRPC exception types (the client's AtProtoHttpException,
an HttpRequestException, and the server's XrpcException), nine exceptions deriving
straight from Exception, five copies of the error-body parser, and callers matching
error names as string literals. There is now one hierarchy under AtProtoException,
one XrpcException for both client and server, XrpcErrors constants, and one reader.

The transport was unsafe to share: it wrote HttpClient.BaseAddress (which throws
after the first request, breaking SetPdsUrl and ApplyOAuthSessionAsync on reused
clients), appended a User-Agent token to the shared default headers per client,
kept proxy/labeler headers in global setters, and dropped them without a session.
It now holds its service URL, sets every header per request, and takes per-call
XrpcCallOptions. It also streams JSON instead of buffering it, sends through a
shared decompressing handler with a bounded connection lifetime, caps rate-limit
waits, formats query values culture-invariantly, rewinds uploads to the caller's
position, and returns downloads as a disposable XrpcStreamResponse.

AtProtoJsonDefaults.FormatTimestamp gets the same invariant-culture fix: under a
Thai locale every createdAt the SDK stamped carried a Buddhist-calendar year.

XrpcClient becomes internal: nothing public exposed it, and #122 adds an explicit
transport seam for third-party sub-clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

@claude /review

@claude /review
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/587); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-152/run-587.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

💬 Verdict: comment — notes only, nothing blocking.

This is a large, well-documented refactor and the visible portions are consistent with the PR description: exception hierarchy, CHANGELOG entries, docs, and the mechanical server-side call-site updates (XrpcErrors.InvalidRequest instead of string literals, HttpStatusCode instead of StatusCodes.Status*, ServiceUrl/SetServiceUrl instead of PdsUrl/SetPdsUrl) all line up correctly with each other.

Important caveat: the diff for src/ATProtoNet/Http/XrpcClient.cs is truncated mid-method (cuts off inside SendAsync, before ApplyAuthHeader, the DPoP-nonce retry loop, the rate-limit retry loop, BuildUri, and response/error handling are shown). That is the highest-risk part of this PR — concurrent header application on a shared HttpClient, DPoP nonce replay, and the new bounded rate-limit wait logic — and I could not verify its correctness from what's visible. I'd want a follow-up pass (or the full file) before treating this as fully reviewed.

One small, low-severity issue spotted in the visible code below (AtProtoHttp.NormalizeBaseUrl(Uri)), plus a design detail worth a sanity check (XrpcClient.Direct) called out inline. Everything else visible (constructors validating URLs, PdsAdminClient no longer trusting a supplied HttpClient.BaseAddress, the DI extension methods wiring ConfigurePrimaryHttpMessageHandler(AtProtoHttp.CreateHandler), the Blazor OAuth service change, the UpdateProfileAsync rebase) looks correct and matches the stated behavior.


⏱ 1m 54s · 1 turns · tokens: 78.8k in / 11.9k out (+23.9k cache-read)
📜 Full session transcript

💬 **Verdict: comment** — notes only, nothing blocking. This is a large, well-documented refactor and the visible portions are consistent with the PR description: exception hierarchy, CHANGELOG entries, docs, and the mechanical server-side call-site updates (`XrpcErrors.InvalidRequest` instead of string literals, `HttpStatusCode` instead of `StatusCodes.Status*`, `ServiceUrl`/`SetServiceUrl` instead of `PdsUrl`/`SetPdsUrl`) all line up correctly with each other. Important caveat: the diff for `src/ATProtoNet/Http/XrpcClient.cs` is truncated mid-method (cuts off inside `SendAsync`, before `ApplyAuthHeader`, the DPoP-nonce retry loop, the rate-limit retry loop, `BuildUri`, and response/error handling are shown). That is the highest-risk part of this PR — concurrent header application on a shared `HttpClient`, DPoP nonce replay, and the new bounded rate-limit wait logic — and I could not verify its correctness from what's visible. I'd want a follow-up pass (or the full file) before treating this as fully reviewed. One small, low-severity issue spotted in the visible code below (`AtProtoHttp.NormalizeBaseUrl(Uri)`), plus a design detail worth a sanity check (`XrpcClient.Direct`) called out inline. Everything else visible (constructors validating URLs, `PdsAdminClient` no longer trusting a supplied `HttpClient.BaseAddress`, the DI extension methods wiring `ConfigurePrimaryHttpMessageHandler(AtProtoHttp.CreateHandler)`, the Blazor OAuth service change, the `UpdateProfileAsync` rebase) looks correct and matches the stated behavior. --- ⏱ 1m 54s · 1 turns · tokens: 78.8k in / 11.9k out (+23.9k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-152/run-587.md) <!-- claude-verdict:comment --> <!-- claude-reviewed-head:5e49fbcf1e1a552f17743f78420adebfe4d9b769 -->
@ -0,0 +68,4 @@
/// <inheritdoc cref="NormalizeBaseUrl(string)"/>
internal static Uri NormalizeBaseUrl(Uri url) =>
url.AbsolutePath.EndsWith('/') ? url : new Uri(url.AbsoluteUri.TrimEnd('/') + "/", UriKind.Absolute);
Collaborator

NormalizeBaseUrl(Uri url) builds the normalized URI from url.AbsoluteUri (which includes any query string/fragment) rather than just the path. If a service URL ever carried a query string and didn't already end in / on its path (e.g. https://pds.example/base?x=1), this produces https://pds.example/base?x=1/ — a slash appended after the query string, corrupting the URL. Same issue applies to the string overload just above (url.TrimEnd('/') + "/" operates on the raw string including any query). Low real-world impact since service base URLs essentially never carry query strings, but worth guarding against (e.g. build from UriBuilder.Path instead of AbsoluteUri) since this is now used for admin/space endpoints as well as the main service URL.

`NormalizeBaseUrl(Uri url)` builds the normalized URI from `url.AbsoluteUri` (which includes any query string/fragment) rather than just the path. If a service URL ever carried a query string and didn't already end in `/` on its path (e.g. `https://pds.example/base?x=1`), this produces `https://pds.example/base?x=1/` — a slash appended after the query string, corrupting the URL. Same issue applies to the `string` overload just above (`url.TrimEnd('/') + "/"` operates on the raw string including any query). Low real-world impact since service base URLs essentially never carry query strings, but worth guarding against (e.g. build from `UriBuilder.Path` instead of `AbsoluteUri`) since this is now used for admin/space endpoints as well as the main service URL.
@ -725,0 +618,4 @@
}
// ──────────────────────────────────────────────────────────
// Types
Collaborator

Direct sets AcceptLabelers = [] and IsDirect = true but leaves Proxy at its default (null), which per XrpcCallOptions.Proxy's own doc means "use the client's default, if any." For this to actually keep session calls (createSession/refreshSession/etc.) from being routed through the client-wide SetProxy default — as the PR description promises — the (truncated, not visible in this diff) request-building code must special-case options.IsDirect to suppress the proxy header regardless of the client default. Worth double-checking that path exists and is exercised by a test, since a regression here would silently start proxying sign-in traffic through an AppView once a caller has called SetProxy.

`Direct` sets `AcceptLabelers = []` and `IsDirect = true` but leaves `Proxy` at its default (`null`), which per `XrpcCallOptions.Proxy`'s own doc means "use the client's default, if any." For this to actually keep session calls (createSession/refreshSession/etc.) from being routed through the client-wide `SetProxy` default — as the PR description promises — the (truncated, not visible in this diff) request-building code must special-case `options.IsDirect` to suppress the proxy header regardless of the client default. Worth double-checking that path exists and is exercised by a test, since a regression here would silently start proxying sign-in traffic through an AppView once a caller has called `SetProxy`.
Author
Owner

Addressed both review points in a5c1e48 (see Review follow-up in the description):

  1. NormalizeBaseUrl (both overloads) and the shared URL validator now reject a query or fragment. DID-document endpoints go through TryNormalizeBaseUrl. Tests cover each case.
  2. The proxy/labeler precedence is explicit: per-call value, else nothing for session calls, else the client default. A test sets a client-wide proxy and labelers and asserts that sign-in, refresh, resume, create-account and logout send neither header.

@claude /review. The previous pass only saw a truncated diff of src/ATProtoNet/Http/XrpcClient.cs. Please read that file in full from the PR branch: header application, the DPoP nonce retry, the bounded rate-limit wait, BuildUri, and response/error handling.

Addressed both review points in `a5c1e48` (see *Review follow-up* in the description): 1. `NormalizeBaseUrl` (both overloads) and the shared URL validator now reject a query or fragment. DID-document endpoints go through `TryNormalizeBaseUrl`. Tests cover each case. 2. The proxy/labeler precedence is explicit: per-call value, else nothing for session calls, else the client default. A test sets a client-wide proxy and labelers and asserts that sign-in, refresh, resume, create-account and logout send neither header. @claude /review. The previous pass only saw a truncated diff of `src/ATProtoNet/Http/XrpcClient.cs`. Please read that file in full from the PR branch: header application, the DPoP nonce retry, the bounded rate-limit wait, `BuildUri`, and response/error handling.
Grandiras force-pushed issue-117-xrpc-transport from 5e49fbcf1e
All checks were successful
CI / pds-integration (pull_request) Successful in 26s
CI / build-and-test (pull_request) Successful in 57s
to a5c1e487c2
All checks were successful
CI / pds-integration (pull_request) Successful in 27s
CI / build-and-test (pull_request) Successful in 1m0s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 7s
2026-09-24 19:15:37 +00:00
Compare
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/588); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-152/run-588.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Both blocking issues from my previous pass are fixed, verified directly against the branch source (the diff for XrpcClient.cs was truncated again, so I read the full files from origin/issue-117-xrpc-transport rather than relying on the diff alone).

  1. AtProtoHttp.NormalizeBaseUrl query/fragment corruption — now fixed correctly. Both overloads reject a URL with a query or fragment via HasQueryOrFragment before building the result, and the result is built from url.GetLeftPart(UriPartial.Path) rather than the raw string/AbsoluteUri, so the append-after-query corruption I flagged can no longer happen. ValidateServiceUrl and TryNormalizeBaseUrl apply the same checks consistently, and callers (PdsAdminClient, AtProtoClient, SpaceCredentialProvider, SimpleSpaceManagingAppClient, SpaceWriteNotifier) were updated to go through them.

  2. Direct/proxy-precedence for session calls — now explicit and correct. XrpcClient.ResolveProxy/ResolveLabelers implement the documented precedence (per-call value wins; else nothing for IsDirect; else the client-wide default), and XrpcClient.Direct (IsDirect = true) is passed from ServerClient on all the session calls that need to bypass the client-wide proxy/labeler defaults: createSession, getSession, deleteSession, createAccount, and refreshSession (via ProcedureWithRefreshTokenAsync, which also uses Direct). This matches the PR description's claim and the described test coverage.

I did not re-review the entire ~2,800-line diff line by line this pass since the prior review already covered the bulk of it and found it sound; this pass was targeted at confirming the two previously-raised issues were actually resolved (not just claimed) by reading the current file contents directly. Both are. No new issues found.


⏱ 0m 46s · 16 turns · tokens: 101.6k in / 4.0k out (+1725.5k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Both blocking issues from my previous pass are fixed, verified directly against the branch source (the diff for `XrpcClient.cs` was truncated again, so I read the full files from `origin/issue-117-xrpc-transport` rather than relying on the diff alone). 1. **`AtProtoHttp.NormalizeBaseUrl` query/fragment corruption** — now fixed correctly. Both overloads reject a URL with a query or fragment via `HasQueryOrFragment` before building the result, and the result is built from `url.GetLeftPart(UriPartial.Path)` rather than the raw string/`AbsoluteUri`, so the append-after-query corruption I flagged can no longer happen. `ValidateServiceUrl` and `TryNormalizeBaseUrl` apply the same checks consistently, and callers (`PdsAdminClient`, `AtProtoClient`, `SpaceCredentialProvider`, `SimpleSpaceManagingAppClient`, `SpaceWriteNotifier`) were updated to go through them. 2. **`Direct`/proxy-precedence for session calls** — now explicit and correct. `XrpcClient.ResolveProxy`/`ResolveLabelers` implement the documented precedence (per-call value wins; else nothing for `IsDirect`; else the client-wide default), and `XrpcClient.Direct` (`IsDirect = true`) is passed from `ServerClient` on all the session calls that need to bypass the client-wide proxy/labeler defaults: `createSession`, `getSession`, `deleteSession`, `createAccount`, and `refreshSession` (via `ProcedureWithRefreshTokenAsync`, which also uses `Direct`). This matches the PR description's claim and the described test coverage. I did not re-review the entire ~2,800-line diff line by line this pass since the prior review already covered the bulk of it and found it sound; this pass was targeted at confirming the two previously-raised issues were actually resolved (not just claimed) by reading the current file contents directly. Both are. No new issues found. --- ⏱ 0m 46s · 16 turns · tokens: 101.6k in / 4.0k out (+1725.5k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-152/run-588.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:a5c1e487c262074a5b23f2a25910072b5e00ae76 -->
Author
Owner

Supplementary review of the XrpcClient.cs sections the bot's truncated diff hid (SendAsync, GetRateLimitDelay, EnsureReplayableAsync, ReadJsonAsync, BuildUri, CreateMessage, ApplyAuthorization, ObserveResponseHeaders). Findings:

  • Each attempt rebuilds the message, so it gets a fresh DPoP proof with a new jti and re-created content. The nonce retry fires at most once per call. 429 retries are bounded by MaxRetries, and a requested wait above MaxDelay falls through to the typed exception rather than sleeping.
  • The response is disposed on every non-returning path. JSON is streamed with ResponseHeadersRead, and format errors become XrpcResponseFormatException.
  • Credentials are read once from an immutable snapshot, and proxy/labeler headers are independent of auth.

Nothing blocking found.

Supplementary review of the `XrpcClient.cs` sections the bot's truncated diff hid (`SendAsync`, `GetRateLimitDelay`, `EnsureReplayableAsync`, `ReadJsonAsync`, `BuildUri`, `CreateMessage`, `ApplyAuthorization`, `ObserveResponseHeaders`). Findings: - Each attempt rebuilds the message, so it gets a fresh DPoP proof with a new `jti` and re-created content. The nonce retry fires at most once per call. 429 retries are bounded by `MaxRetries`, and a requested wait above `MaxDelay` falls through to the typed exception rather than sleeping. - The response is disposed on every non-returning path. JSON is streamed with `ResponseHeadersRead`, and format errors become `XrpcResponseFormatException`. - Credentials are read once from an immutable snapshot, and proxy/labeler headers are independent of auth. Nothing blocking found.
Grandiras deleted branch issue-117-xrpc-transport 2026-09-24 19:17:38 +00:00
Sign in to join this conversation.
No description provided.