Repository navigation
fix(webhook-inbox): a stalled target read answers 503 + Retry-After, not a bare 500 (#6125) - #6322
Conversation
…not a bare 500 (#6125) WebhookInboxEndpoints.Deliver let QueryProviderStalledException from its path:{target} existence read escape, so ASP.NET answered a bare 500. Map it through InstanceAuthResponses.UnavailableOnAStalledRead (classifier AreaErrorClassifier.IsStorageUnavailable), exactly as the plugin-bundle routes do; every other fault still escapes. ReadUnavailable takes an optional error text so the body names what this route could not read. Also records the #6039 investigation on the WebhookInbox doc page: no post-creation handler matches WebhookEvent, and the reply's dispatch needs the nodeops block regardless of the issuing hub (the responder enqueues its own outgoing reply), so no endpoint-local change decouples it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new test uses an unsafe reactive impersonation scope that can leak System identity across threads.
1 open finding
What changed in this PR
Maps stalled webhook target reads to retryable HTTP 503 responses and documents related delivery behavior.
Changes:
- Adds 503/
Retry-Aftermapping with route-specific error text. - Adds integration coverage for stalled, successful, and ordinary-fault cases.
- Documents #6125 and the #6039 investigation.
| File | Description |
|---|---|
memex/Memex.Portal.Shared/Api/WebhookInboxEndpoints.cs |
Maps availability failures to 503 responses. |
memex/Memex.Portal.Shared/Api/InstanceAuthResponses.cs |
Supports route-specific 503 error text. |
test/Memex.Portal.Shared.Test/WebhookInboxStalledReadTest.cs |
Tests webhook target-read outcomes. |
src/MeshWeaver.Documentation/Data/Architecture/WebhookInbox.md |
Documents behavior and investigation findings. |
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
| await Observable.Using( | ||
| () => access.ImpersonateAsSystem(), | ||
| _ => Mesh.ServiceProvider.GetRequiredService<IMeshService>().CreateOrUpdateNode( | ||
| new MeshNode(Target) | ||
| { | ||
| Name = Target, | ||
| NodeType = "Markdown", | ||
| Content = new MarkdownContent { Content = "# Inbox target\n" }, | ||
| })) | ||
| .FirstAsync().Timeout(TestTimeouts.Convergence).Await(ct); |
There was a problem hiding this comment.
Correct — fixed in 2c1b4ed. The target seed now goes through access.RunAsSystem(() => …CreateOrUpdateNode(...)), the sealed boundary WebhookInbox.Store itself uses (#1790), so the impersonation enters and leaves inside one synchronous Subscribe and cannot latch the test thread as System. Rebuilt Release with -warnaserror (0 errors / 0 warnings); all 8 Webhook* tests in Memex.Portal.Shared.Test pass.
…Using (review) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Test Results 22 files + 1 22 suites +1 48m 17s ⏱️ + 2m 47s Results for commit 2c1b4ed. ± Comparison against base commit 3fc485c. This pull request removes 29 and adds 1341 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |

#6125: the root cause
memex/Memex.Portal.Shared/Api/WebhookInboxEndpoints.cs:108(before this change) awaitedWebhookInbox.Deliver(...)with no mapping for availability faults. Deliver's target-existence read (path:{target},WebhookEventNodeType.csStore) can end inQueryProviderStalledExceptionwhen a provider misses the fan-in's Initial budget. That exception escaped the handler, so ASP.NET answered a bare 500.The fix: the delivery verdict is mapped to an
IResultin aSelect, then passed throughInstanceAuthResponses.UnavailableOnAStalledRead, the same convention the plugin-bundle routes use (#5345). Only whatAreaErrorClassifier.IsStorageUnavailableclassifies becomes 503 +Retry-After. Every other fault still escapes.ReadUnavailableandUnavailableOnAStalledReadgain an optionalerrortext, so the 503 body names what this route could not read ("nothing was stored; retry shortly"). The bundle routes keep their wording because the default is unchanged.The underlying pg-read starvation (#5315 / QueryFanInStallTerminal) is untouched, and
QueryInitialBudgetis not changed.Test and negative control
test/Memex.Portal.Shared.Test/WebhookInboxStalledReadTest.csuses a real TestServer host over the real mesh. A testIMeshQueryProvider, the same harness asPluginBundleStalledReadTest, stalls or faults only the target read. The stall fires at the production rung. There are three cases:Retry-Afterset to 5 s and the route's own error text;accepted;Negative control, run locally: with the
UnavailableOnAStalledReadline commented out,AStalledTargetRead_Answers503WithRetryAfterFAILS with the production exception verbatim (QueryProviderStalledException … for query 'path:Hosting/StalledInbox' (user 'system-security')atWebhookInboxEndpoints.Deliver). With the line in, all 8Webhook*tests pass, andPluginBundleStalledReadTestpasses 4/4.#6039: investigated, no code change; findings committed to
Doc/Architecture/WebhookInboxINodePostCreationHandlerin core or MeshWeaver.Plugins matchesWebhookEvent. SoRunPostCreationHandlersObsisObservable.Empty, andCreateNodeResponse.Okis posted synchronously right after theNode created at …line (MeshExtensions.cs~1540–1589).portal/nodeops-{meshId}. I tried issuing the create fromportal/reads-{meshId}(hub.ReadIssuingHub(), the A freshly uploaded content file 503s for minutes (10s timeout per request) before it starts serving — /api/content settle window #2901 cure). A deterministic repro held the nodeops block after the commit and before the reply. The delivery was still not answered. The request-fate trail shows why: the responder enqueues its own outgoing reply on its own block (RESPONSE_POSTED target=portal/reads-… ↩ reply#1: RECEIVED @portal/nodeops-… → ENQUEUED → QUEUED queue=main). No endpoint-local change separates the answer from that block, so that change was dropped. No timeout was added or raised.Closes #6125 is deliberately not used, because sev:M issues close after deployed verification. Refs #6125. Refs #6039.
Pairs-with: none — no public type or member removed (one optional parameter added to two
internalmethods)Mirror-sync: none — no i18n catalog change
🤖 Generated with Claude Code