XRPC procedures with no output throw JsonException on every call #69

Closed
opened 2026-07-25 21:13:14 +00:00 by Grandiras · 0 comments
Owner

Summary

Every XRPC procedure whose Lexicon declares no output currently asks for a deserialized response, so it throws on the empty body a real server returns:

System.Text.Json.JsonException: The input does not contain any JSON tokens.
Expected the input to start with a valid JSON token, when isFinalBlock is true.

These methods fail on every call against a real PDS or appview, regardless of what the server does. It is not a corner of the API — PutPreferencesAsync, the six mute/unmute methods, UpdateSeenAsync, RegisterPushAsync and UpdateHandleAsync are ordinary client calls.

Cause

XrpcClient.ProcedureAsync<TRequest, TResponse> ends in ReadFromJsonAsync, which throws on an empty payload. The affected methods pass object as TResponse and discard the result:

await _xrpc.ProcedureAsync<MuteActorRequest, object>(
    "app.bsky.graph.muteActor", request, cancellationToken: cancellationToken);

A body-only overload, ProcedureAsync<TRequest>, already exists and is the correct call.

Affected call sites (19)

Client Methods
IdentityClient updateHandle, submitPlcOperation, requestPlcOperationSignature
SyncClient notifyOfUpdate, requestCrawl
ActorClient putPreferences
GraphClient muteActor, unmuteActor, muteActorList, unmuteActorList, muteThread, unmuteThread
NotificationClient updateSeen, registerPush
Ozone communication.deleteTemplate, team.deleteMember, set.addValues, set.deleteValues, set.deleteSet

18 of the 19 declare no output upstream. tools.ozone.set.deleteSet does declare one, but the method discards it, so deserializing only creates a way to fail.

The seven equivalents in AdminClient were fixed in #68, where the managed-PDS work depends on them and integration tests against a live PDS cover them.

Why the tests didn't catch it

The existing test doubles return {}, which deserializes perfectly well. The defect only appears against a server that returns nothing — which is what these endpoints do. It was found by running PdsAdminClient against a real ghcr.io/bluesky-social/pds container for the first time.

Fix

Switch the 19 call sites to ProcedureAsync<TRequest>. No public signatures change: every one of these methods already returns Task and discards the value.

Add a regression test that answers 200 with an empty body, the way a real server does, and drives all 19 NSIDs through the public client API.

Impact

Affects released versions. Any consumer calling mute/unmute, PutPreferencesAsync, UpdateSeenAsync, RegisterPushAsync, UpdateHandleAsync, RequestCrawlAsync, or the Ozone set/team/communication mutators gets an exception instead of a completed call.

## Summary Every XRPC procedure whose Lexicon declares **no output** currently asks for a deserialized response, so it throws on the empty body a real server returns: ``` System.Text.Json.JsonException: The input does not contain any JSON tokens. Expected the input to start with a valid JSON token, when isFinalBlock is true. ``` These methods fail on **every** call against a real PDS or appview, regardless of what the server does. It is not a corner of the API — `PutPreferencesAsync`, the six mute/unmute methods, `UpdateSeenAsync`, `RegisterPushAsync` and `UpdateHandleAsync` are ordinary client calls. ## Cause `XrpcClient.ProcedureAsync<TRequest, TResponse>` ends in `ReadFromJsonAsync`, which throws on an empty payload. The affected methods pass `object` as `TResponse` and discard the result: ```csharp await _xrpc.ProcedureAsync<MuteActorRequest, object>( "app.bsky.graph.muteActor", request, cancellationToken: cancellationToken); ``` A body-only overload, `ProcedureAsync<TRequest>`, already exists and is the correct call. ## Affected call sites (19) | Client | Methods | |---|---| | `IdentityClient` | `updateHandle`, `submitPlcOperation`, `requestPlcOperationSignature` | | `SyncClient` | `notifyOfUpdate`, `requestCrawl` | | `ActorClient` | `putPreferences` | | `GraphClient` | `muteActor`, `unmuteActor`, `muteActorList`, `unmuteActorList`, `muteThread`, `unmuteThread` | | `NotificationClient` | `updateSeen`, `registerPush` | | Ozone | `communication.deleteTemplate`, `team.deleteMember`, `set.addValues`, `set.deleteValues`, `set.deleteSet` | 18 of the 19 declare no `output` upstream. `tools.ozone.set.deleteSet` does declare one, but the method discards it, so deserializing only creates a way to fail. The seven equivalents in `AdminClient` were fixed in #68, where the managed-PDS work depends on them and integration tests against a live PDS cover them. ## Why the tests didn't catch it The existing test doubles return `{}`, which deserializes perfectly well. The defect only appears against a server that returns nothing — which is what these endpoints do. It was found by running `PdsAdminClient` against a real `ghcr.io/bluesky-social/pds` container for the first time. ## Fix Switch the 19 call sites to `ProcedureAsync<TRequest>`. No public signatures change: every one of these methods already returns `Task` and discards the value. Add a regression test that answers **200 with an empty body**, the way a real server does, and drives all 19 NSIDs through the public client API. ## Impact Affects released versions. Any consumer calling mute/unmute, `PutPreferencesAsync`, `UpdateSeenAsync`, `RegisterPushAsync`, `UpdateHandleAsync`, `RequestCrawlAsync`, or the Ozone set/team/communication mutators gets an exception instead of a completed call.
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#69
No description provided.