fix(aggregator): wrap syncGetRecord errors in the CORS + no-store envelope#2167
Conversation
…elope An unexpected error (e.g. a D1 failure in the record-blob fetch) escaped handleXrpc unwrapped, hitting workerd's bare 500 with no CORS or cache headers — the parallel gap #2117 left out of scope on the cacheable record-blob path. Extract the shared no-store+CORS wrapper (wrapDispatchError) so the sync and policy error paths stay in lockstep, add a context string for correct log attribution, and route syncGetRecord throws through it. The success 200 keeps its cacheable header; only errors get private, no-store.
|
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | e3ee15d | Jul 21 2026, 07:20 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | e3ee15d | Jul 21 2026, 07:18 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | e3ee15d | Jul 21 2026, 07:19 AM |
There was a problem hiding this comment.
Approach judgment: This is the right fix for the right gap. syncGetRecord previously had no try/catch, so a D1 or other unexpected throw would bypass handleXrpc's envelope and surface as a bare workerd 500 without CORS or Cache-Control. Since the success path returns public, max-age=300, caching a 500 would be especially bad. Extracting the CORS + no-store wrapper into wrapDispatchError and threading a context string is a clean, in-scope refactor: it keeps the policy and sync.getRecord error paths consistent and makes operator logs correctly attribute failures instead of blaming every blob error on "policy resolution".
What I checked:
- Read the diff, the full
apps/aggregator/src/routes/xrpc/router.ts, andapps/aggregator/src/routes/xrpc/sync-get-record.ts. - Verified
internalErrorResponsenow has one caller andwrapDispatchErroris only used insidehandleXrpc. - Confirmed only the
syncGetRecordcatch block and the existingresolveRequestLabelerPolicycatch block use the new wrapper. - Traced the response clone/header flow: error responses carry
private, no-storeand CORS, while the success 200 keepspublic, max-age=300. - Checked the new test against the schema fixtures —
publishersandlabelerscurrently have no indexes/triggers, so theDROP TABLE+sqlrestore pattern does not drop secondary objects. The test pattern matches the pre-existing policy-failure test.
Headline conclusion: The implementation is correct and the new regression test exercises the previously exposed failure path. No blockers.
| // The success path serves a cacheable 200; an unexpected throw (e.g. a | ||
| // D1 error fetching the CAR blob) must fail closed with `no-store` so | ||
| // the error never inherits that public cache header. | ||
| return wrapDispatchError(err, "sync.getRecord"); |
There was a problem hiding this comment.
[suggestion] The catch block contains a rationale comment that duplicates the wrapDispatchError docstring (lines 93–99). Per the repo’s comment-discipline conventions, comments that justify the decision (“must fail closed … so … never inherits”) should usually be deleted once the helper name and its own docstring make the intent clear.
| // The success path serves a cacheable 200; an unexpected throw (e.g. a | |
| // D1 error fetching the CAR blob) must fail closed with `no-store` so | |
| // the error never inherits that public cache header. | |
| return wrapDispatchError(err, "sync.getRecord"); | |
| } catch (err) { | |
| return wrapDispatchError(err, "sync.getRecord"); | |
| } |
There was a problem hiding this comment.
Done — removed the redundant catch comment; the no-store/cache rationale lives in the wrapDispatchError docstring.
~ 🤖 Claude Fable 5
…string covers it)
9596a04
into
feat/plugin-registry-labelling-service
What does this PR do?
Closes a pre-existing error-handling gap in the aggregator that the review of #2117 surfaced but scoped out. In
apps/aggregator/src/routes/xrpc/router.ts, thesyncGetRecordbranch had no try/catch, so an unexpected throw (e.g. a D1 failure infetchRecordBlob→selectBlob) escapedhandleXrpcand the Workerfetchhandler unwrapped — hitting workerd's bare 500 with no CORS and no cache headers, violating the endpoint's response invariant the way #2117 fixed for the policy-resolution path.The subtlety vs #2117:
syncGetRecordserves immutable record bytes with a cacheablepublic, max-age=300200. So an error must not be cacheable — an unexpected throw now returns an opaque 500 carrying CORS +private, no-store(never the cacheable header), with the internal detail logged and noerror.message/SQL/stack leaked. The success 200 keeps its cacheable header; 404/not-found semantics (returned as JSON insidesyncGetRecord, never thrown) are unaffected.To keep the two error paths from drifting, this extracts #2117's inlined no-store+CORS wrapper into a shared
wrapDispatchError(err, context)(reusing the existinginternalErrorResponserather than duplicating it) and threads acontextstring so operator logs readxrpc sync.getRecord failedvsxrpc policy resolution failedinstead of misattributing a blob error to policy resolution. The policy path's behavior is identical; only its log string is parameterized.Targets the
feat/plugin-registry-labelling-serviceintegration branch. Part of #1909; a follow-up to #2117.Type of change
Checklist
pnpm typecheckpasses (aggregator: clean)pnpm lintpasses (0 diagnostics)pnpm testpasses (aggregator: 342 passed / 17 files) — TDD: the new test drops a table to force a D1 error and asserts a 500 withprivate, no-store+ CORS + no leaked internals; it fails pre-fix with the bareD1_ERRORescapingSELF.fetch. The existing success test covers the 200 +public, max-age=300path.pnpm formathas been run@emdash-cms/aggregatoris private.AI-generated code disclosure
Screenshots / test output
Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
fix/aggregator-syncgetrecord. Updated automatically when the playground redeploys.