fix: XRPC procedures with no output throw JsonException on every call #70

Merged
Grandiras merged 2 commits from fix/empty-response-body into main 2026-07-25 22:18:00 +00:00
Owner

Fixes #69.

Every XRPC procedure whose Lexicon declares no output asked for a deserialized response, so the empty body a real server returns failed before the caller saw anything:

System.Text.Json.JsonException: The input does not contain any JSON tokens.

They failed on every call, regardless of what the server did. Not a corner of the API — PutPreferencesAsync, the six mute/unmute methods, UpdateSeenAsync, RegisterPushAsync and UpdateHandleAsync are ordinary client calls.

Change

19 call sites move from ProcedureAsync<TRequest, object> to the body-only ProcedureAsync<TRequest> overload that already existed:

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

No public signatures change — every one of these methods already returned Task and discarded the value, so the only difference is that they stop throwing.

tools.ozone.set.deleteSet is included even though its Lexicon does declare an output: the method throws the value away today, so deserializing it only creates a way to fail.

Test

The defect survived because the existing test doubles return {}, which deserializes perfectly well. EmptyResponseBodyTests is a [Theory] over all 19 NSIDs driving real AtProtoClient calls through a handler that answers 200 with an empty body, the way a real server does. Verified non-vacuous: reverting a single call site fails exactly that case.

Scope

Split out of #68 at review request. That PR keeps the seven equivalents in AdminClient, which the managed-PDS work depends on and which its live-PDS integration tests cover. The two sets of call sites are disjoint, so these can merge in either order (both touch CHANGELOG.md).

Verification

dotnet build clean; 1418 unit tests pass (20 new). Found by running PdsAdminClient against a real ghcr.io/bluesky-social/pds container for the first time in #68.

🤖 Generated with Claude Code

Fixes #69. Every XRPC procedure whose Lexicon declares **no output** asked for a deserialized response, so the empty body a real server returns failed before the caller saw anything: ``` System.Text.Json.JsonException: The input does not contain any JSON tokens. ``` They failed on every call, regardless of what the server did. Not a corner of the API — `PutPreferencesAsync`, the six mute/unmute methods, `UpdateSeenAsync`, `RegisterPushAsync` and `UpdateHandleAsync` are ordinary client calls. ## Change 19 call sites move from `ProcedureAsync<TRequest, object>` to the body-only `ProcedureAsync<TRequest>` overload that already existed: | 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` | **No public signatures change** — every one of these methods already returned `Task` and discarded the value, so the only difference is that they stop throwing. `tools.ozone.set.deleteSet` is included even though its Lexicon does declare an output: the method throws the value away today, so deserializing it only creates a way to fail. ## Test The defect survived because the existing test doubles return `{}`, which deserializes perfectly well. `EmptyResponseBodyTests` is a `[Theory]` over all 19 NSIDs driving real `AtProtoClient` calls through a handler that answers **200 with an empty body**, the way a real server does. Verified non-vacuous: reverting a single call site fails exactly that case. ## Scope Split out of #68 at review request. That PR keeps the seven equivalents in `AdminClient`, which the managed-PDS work depends on and which its live-PDS integration tests cover. The two sets of call sites are disjoint, so these can merge in either order (both touch `CHANGELOG.md`). ## Verification `dotnet build` clean; **1418 unit tests pass** (20 new). Found by running `PdsAdminClient` against a real `ghcr.io/bluesky-social/pds` container for the first time in #68. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: XRPC procedures with no output threw JsonException on every call
All checks were successful
CI / build-and-test (pull_request) Successful in 45s
4174f12aa8
Every procedure whose Lexicon declares no output asked for a deserialized
response, so the empty body a real server returns failed with "The input
does not contain any JSON tokens" before the caller saw anything. They
failed on every call regardless of what the server did.

This is not a corner of the API: PutPreferencesAsync, the six mute/unmute
methods, UpdateSeen, RegisterPush and UpdateHandle are ordinary client
calls. All 19 sites now use the body-only ProcedureAsync<TRequest>
overload that already existed. No public signatures change — each of
these methods already returned Task and discarded the value.

The defect survived because the test doubles returned `{}`, which
deserializes perfectly well. EmptyResponseBodyTests drives all 19 NSIDs
through a handler that answers 200 with an empty body, the way a real
server does; reverting any one call site fails its case.

Found by running PdsAdminClient against a real bluesky-social/pds
container in #68, which fixes the seven equivalents in AdminClient that
the managed-PDS work depends on.

closes #69

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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/74); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-70/run-74.md) — updating every ~5 minutes while the task runs.
claude-reviewer approved these changes 2026-07-25 21:15:32 +00:00
Dismissed
claude-reviewer left a comment

Verdict: approve — no blocking issues found.

Correct, well-scoped fix. ProcedureAsync<TRequest> (verified in XrpcClient.cs:267-284) posts the body and calls EnsureSuccessAsync without touching response.Content, so it's immune to the empty-body JsonException that ProcedureAsync<TRequest, object> hit when deserializing a genuinely empty response. All 19 call-site changes match methods that already returned Task and discarded the value, so no public API changes as claimed. The new EmptyResponseBodyTests fixture is a good regression guard — it returns a real empty 200 body (unlike the old {} doubles) and exercises all 19 NSIDs through actual AtProtoClient calls, plus a coverage-count guard against future void procedures being added uncovered. The deleteSet inclusion despite its Lexicon declaring output is reasonable and clearly justified in the description. CHANGELOG entry is present and detailed per repo convention. Nothing to block on.


⏱ 0m 19s · 6 turns · tokens: 26.0k in / 1.2k out (+260.4k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Correct, well-scoped fix. `ProcedureAsync<TRequest>` (verified in `XrpcClient.cs:267-284`) posts the body and calls `EnsureSuccessAsync` without touching `response.Content`, so it's immune to the empty-body `JsonException` that `ProcedureAsync<TRequest, object>` hit when deserializing a genuinely empty response. All 19 call-site changes match methods that already returned `Task` and discarded the value, so no public API changes as claimed. The new `EmptyResponseBodyTests` fixture is a good regression guard — it returns a real empty 200 body (unlike the old `{}` doubles) and exercises all 19 NSIDs through actual `AtProtoClient` calls, plus a coverage-count guard against future void procedures being added uncovered. The `deleteSet` inclusion despite its Lexicon declaring output is reasonable and clearly justified in the description. CHANGELOG entry is present and detailed per repo convention. Nothing to block on. --- ⏱ 0m 19s · 6 turns · tokens: 26.0k in / 1.2k out (+260.4k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-70/run-74.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:4174f12aa82b020cd74b4791de98ac5f97ed2181 -->
test: tighten the empty-body fixture
All checks were successful
CI / build-and-test (pull_request) Successful in 41s
e6a340104a
From review of #68, both nits land in this file:

- _handler was assigned in the field initializer and again in the
  constructor, allocating one handler and orphaning it.
- EveryVoidProcedureIsCovered asserted the list length, which only fires
  when someone edits that list — it could not detect a new void procedure
  being added to a Lexicon client, which is the case its comment claimed
  to guard. Replaced with a check of what is actually verifiable: that no
  listed NSID is unmapped or duplicated, and a comment saying plainly that
  keeping the list current is manual.

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

Two test nits from the review of #68 land in this PR's file, both fixed in e6a3401:

  • _handler was assigned twice — field initializer and constructor — allocating one handler and orphaning it. Now assigned once.
  • EveryVoidProcedureIsCovered asserted Count == 19, which only fires if someone edits that list. It could not detect a new void procedure being added to a Lexicon client, which is what its comment claimed to guard.

The second is the interesting one, because there is no honest automated version: nothing in the test project can see a Lexicon client gaining a method. Rather than leave a check whose comment overstates it, it now verifies what's actually verifiable — that no listed NSID is unmapped or duplicated (so the list and the dispatch switch can't drift apart) — and says plainly that keeping the list current is manual.

1418 unit tests pass; build clean.

Two test nits from the review of #68 land in this PR's file, both fixed in `e6a3401`: - **`_handler` was assigned twice** — field initializer and constructor — allocating one handler and orphaning it. Now assigned once. - **`EveryVoidProcedureIsCovered` asserted `Count == 19`**, which only fires if someone edits that list. It could not detect a *new* void procedure being added to a Lexicon client, which is what its comment claimed to guard. The second is the interesting one, because there is no honest automated version: nothing in the test project can see a Lexicon client gaining a method. Rather than leave a check whose comment overstates it, it now verifies what's actually verifiable — that no listed NSID is unmapped or duplicated (so the list and the dispatch switch can't drift apart) — and says plainly that keeping the list current is manual. 1418 unit tests pass; build clean.
Author
Owner

@claude-bot Please fix the small conflict and rebase based on main to get the latest state.

@claude-bot Please fix the small conflict and rebase based on main to get the latest state.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

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

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/84); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-70/run-84.md) — updating every ~5 minutes while the task runs.
claude-bot force-pushed fix/empty-response-body from e6a340104a
All checks were successful
CI / build-and-test (pull_request) Successful in 41s
to 5106bada90
All checks were successful
CI / pds-integration (pull_request) Successful in 18s
CI / build-and-test (pull_request) Successful in 46s
Sync Closures to GitHub / sync-closure (pull_request) Successful in 7s
2026-07-25 22:15:06 +00:00
Compare
Collaborator

Rebased onto main and force-pushed to fix/empty-response-body (now 5106bad, sitting directly on 4b2c4ce).

The conflict

Only CHANGELOG.md conflicted, in the ### Fixed section. Two things were tangled there:

  • The new bullet — kept, placed after the Seven AdminClient methods bullet from #68, since they're the same defect and now read as a pair.
  • A stale Issue #66 bullet (com.atproto.sync.subscribeRepos could miss events after the handshake) — dropped. It came in as part of this branch's base (41f16e1), but #68 deliberately removed both Issue #66 entries when it deleted the in-process PDS — the PdsSequencer that bullet describes no longer exists. Re-adding it during the rebase would have documented a fix to deleted code. Confirmed by git show 4b2c4ce -- CHANGELOG.md, which shows both lines being removed.

No code conflicts: #68 touched AdminClient only, and these 19 call sites are in the Lexicon clients — the disjointness the PR description claimed held up.

Verification

No .NET SDK in this container, so I installed 10.0.302 first.

  • dotnet build -p:EnableSourceControlManagerQueries=false0 errors (1117 warnings, all pre-existing CS1591/RZ10012).
  • dotnet test tests/ATProtoNet.Tests/1103 passed, 0 failed, 5 skipped.
  • EmptyResponseBodyTests specifically — 20 passed.

Two notes on the numbers. The total is 1103, not the 1418 quoted earlier: #68 removed the in-process PDS and its test suite, so that drop is the new base, not this branch. The 5 skips are the environment-gated AspireManifestTests, which skip when no published Aspire manifest is present — also from #68, unrelated to this change.

Diff against main is unchanged in substance: 8 Lexicon client files, the new test file, and 2 added CHANGELOG.md lines.


⏱ 3m 6s · 29 turns · tokens: 39.7k in / 7.4k out (+1089.4k cache-read)
📜 Full session transcript

Rebased onto `main` and force-pushed to `fix/empty-response-body` (now `5106bad`, sitting directly on `4b2c4ce`). ## The conflict Only `CHANGELOG.md` conflicted, in the `### Fixed` section. Two things were tangled there: - **The new bullet** — kept, placed after the `Seven AdminClient methods` bullet from #68, since they're the same defect and now read as a pair. - **A stale `Issue #66` bullet** (`com.atproto.sync.subscribeRepos` could miss events after the handshake) — **dropped**. It came in as part of this branch's base (`41f16e1`), but #68 deliberately removed both `Issue #66` entries when it deleted the in-process PDS — the `PdsSequencer` that bullet describes no longer exists. Re-adding it during the rebase would have documented a fix to deleted code. Confirmed by `git show 4b2c4ce -- CHANGELOG.md`, which shows both lines being removed. No code conflicts: #68 touched `AdminClient` only, and these 19 call sites are in the Lexicon clients — the disjointness the PR description claimed held up. ## Verification No .NET SDK in this container, so I installed 10.0.302 first. - `dotnet build -p:EnableSourceControlManagerQueries=false` — **0 errors** (1117 warnings, all pre-existing CS1591/RZ10012). - `dotnet test tests/ATProtoNet.Tests/` — **1103 passed, 0 failed, 5 skipped**. - `EmptyResponseBodyTests` specifically — **20 passed**. Two notes on the numbers. The total is 1103, not the 1418 quoted earlier: #68 removed the in-process PDS and its test suite, so that drop is the new base, not this branch. The 5 skips are the environment-gated `AspireManifestTests`, which skip when no published Aspire manifest is present — also from #68, unrelated to this change. Diff against main is unchanged in substance: 8 Lexicon client files, the new test file, and 2 added `CHANGELOG.md` lines. --- ⏱ 3m 6s · 29 turns · tokens: 39.7k in / 7.4k out (+1089.4k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/issue-70/run-84.md)
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/85); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-70/run-85.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

Verdict: approve — no blocking issues found.

Same fix I approved previously — verified again against XrpcClient.cs:301-318: the body-only ProcedureAsync<TRequest> overload never reads response.Content, so it can't hit the empty-body JsonException that ProcedureAsync<TRequest, object> throws. All 19 call-site changes still line up with methods that already returned Task and discarded the value, so no public API changes. EmptyResponseBodyTests still exercises all 19 NSIDs with a genuinely empty 200 body. No issues found; nothing new introduced since my last pass.


⏱ 0m 26s · 8 turns · tokens: 25.3k in / 1.3k out (+357.6k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Same fix I approved previously — verified again against `XrpcClient.cs:301-318`: the body-only `ProcedureAsync<TRequest>` overload never reads `response.Content`, so it can't hit the empty-body `JsonException` that `ProcedureAsync<TRequest, object>` throws. All 19 call-site changes still line up with methods that already returned `Task` and discarded the value, so no public API changes. `EmptyResponseBodyTests` still exercises all 19 NSIDs with a genuinely empty 200 body. No issues found; nothing new introduced since my last pass. --- ⏱ 0m 26s · 8 turns · tokens: 25.3k in / 1.3k out (+357.6k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/ATProto.NET/pr-70/run-85.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:5106bada901b6fb5edac15a20bbd6154ba25b98e -->
Grandiras deleted branch fix/empty-response-body 2026-07-25 22:18:00 +00:00
Sign in to join this conversation.
No description provided.