Skip to content

Commit dd20e04

Browse files
KyleAMathewsclaude
andcommitted
fix(client): always create cache buster on 409 and fix parked frame leak in #start
Two fixes: 1. Both 409 handlers (#requestShape and #fetchSnapshotWithRetry) now unconditionally create a cache buster instead of only doing so conditionally when the handle is missing or recycled. This eliminates the same-handle 409 infinite loop (where identical retry URLs would hit CDN cache forever) and removes two conditional branches, making the behavior safer and easier to verify exhaustively. 2. Changed `await this.#start(); return` to `return this.#start()` in the onError retry path. The old pattern parked the outer #start frame on the call stack for the entire lifetime of the replacement stream, accumulating one frame per error recovery. The new pattern resolves the outer frame immediately. Also adds model-based test commands for 409-no-handle and 409-same-handle scenarios, plus a targeted regression test verifying consecutive same-handle 409s produce unique retry URLs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a6c237f commit dd20e04

File tree

2 files changed

+97
-11
lines changed

2 files changed

+97
-11
lines changed

packages/typescript-client/src/client.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -772,8 +772,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
772772

773773
// Restart from current offset
774774
this.#started = false
775-
await this.#start()
776-
return
775+
return this.#start()
777776
}
778777
// onError returned void, meaning it doesn't want to retry
779778
// This is an unrecoverable error, notify subscribers
@@ -911,15 +910,14 @@ export class ShapeStream<T extends Row<unknown> = Row>
911910
}
912911

913912
const newShapeHandle = e.headers[SHAPE_HANDLE_HEADER]
914-
let nextRequestShapeCacheBuster: string | undefined
915913
if (!newShapeHandle) {
916914
console.warn(
917915
`[Electric] Received 409 response without a shape handle header. ` +
918916
`This likely indicates a proxy or CDN stripping required headers.`,
919917
new Error(`stack trace`)
920918
)
921-
nextRequestShapeCacheBuster = createCacheBuster()
922919
}
920+
const nextRequestShapeCacheBuster = createCacheBuster()
923921
this.#reset(newShapeHandle)
924922

925923
// must refetch control message might be in a list or not depending
@@ -1935,22 +1933,16 @@ export class ShapeStream<T extends Row<unknown> = Row>
19351933
// For snapshot 409s, only update the handle — don't reset offset/schema/etc.
19361934
// The main stream is paused and should not be disturbed.
19371935
const nextHandle = e.headers[SHAPE_HANDLE_HEADER]
1938-
let nextCacheBuster: string | undefined
19391936
if (nextHandle) {
19401937
this.#syncState = this.#syncState.withHandle(nextHandle)
1941-
// If 409 returned the same handle, the URL won't change —
1942-
// pass a cache buster to the next retry to force a unique URL.
1943-
if (nextHandle === usedHandle) {
1944-
nextCacheBuster = createCacheBuster()
1945-
}
19461938
} else {
19471939
console.warn(
19481940
`[Electric] Received 409 response without a shape handle header. ` +
19491941
`This likely indicates a proxy or CDN stripping required headers.`,
19501942
new Error(`stack trace`)
19511943
)
1952-
nextCacheBuster = createCacheBuster()
19531944
}
1945+
const nextCacheBuster = createCacheBuster()
19541946

19551947
return this.#fetchSnapshotWithRetry(
19561948
opts,

packages/typescript-client/test/model-based.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,22 @@ function make409(newHandle: string): Response {
102102
)
103103
}
104104

105+
/**
106+
* 409 Conflict without a handle header. Simulates a proxy stripping
107+
* the header. Must always produce a unique retry URL via cache buster.
108+
*/
109+
function make409NoHandle(): Response {
110+
return new Response(
111+
JSON.stringify([{ headers: { control: `must-refetch` } }]),
112+
{
113+
status: 409,
114+
headers: {
115+
'content-type': `application/json`,
116+
},
117+
}
118+
)
119+
}
120+
105121
/** Valid headers but non-array body. Throws FetchError → onError. */
106122
function makeMalformed200(handle: string): Response {
107123
nextSeq()
@@ -484,6 +500,82 @@ class RespondMissingHeadersCmd
484500
}
485501
}
486502

503+
/**
504+
* 409 Conflict without a handle header (proxy stripped it).
505+
* Handled by creating a random cache buster. Does NOT affect
506+
* the retry counter — same as normal 409.
507+
*/
508+
class Respond409NoHandleCmd
509+
implements fc.AsyncCommand<StreamModel, StreamReal>
510+
{
511+
check(m: Readonly<StreamModel>): boolean {
512+
return !m.terminated
513+
}
514+
async run(_m: StreamModel, r: StreamReal): Promise<void> {
515+
const prevUrl = r.gate.lastUrl
516+
await r.respond(make409NoHandle())
517+
r.currentHandle = `` // handle cleared after 409 with no handle
518+
expect(r.subscriberError).toBeNull()
519+
assertGlobalInvariants(r)
520+
if (prevUrl) {
521+
expect(r.gate.lastUrl).not.toBe(prevUrl)
522+
}
523+
}
524+
toString(): string {
525+
return `Respond409NoHandle`
526+
}
527+
}
528+
529+
/**
530+
* 409 with the same handle as the current one.
531+
*/
532+
class Respond409SameHandleCmd
533+
implements fc.AsyncCommand<StreamModel, StreamReal>
534+
{
535+
check(m: Readonly<StreamModel>): boolean {
536+
return !m.terminated
537+
}
538+
async run(_m: StreamModel, r: StreamReal): Promise<void> {
539+
const prevUrl = r.gate.lastUrl
540+
// 409 with the SAME handle — tests cache buster for handle recycling
541+
await r.respond(make409(r.currentHandle))
542+
// Don't update r.currentHandle — it's the same
543+
expect(r.subscriberError).toBeNull()
544+
assertGlobalInvariants(r)
545+
if (prevUrl) {
546+
expect(r.gate.lastUrl).not.toBe(prevUrl)
547+
}
548+
}
549+
toString(): string {
550+
return `Respond409SameHandle`
551+
}
552+
}
553+
554+
// ─── Scenario Tests ────────────────────────────────────────────────
555+
556+
describe(`ShapeStream targeted scenario tests`, () => {
557+
it(`consecutive 409s with the same handle produce unique retry URLs`, async () => {
558+
const real = await createStreamReal()
559+
try {
560+
// Advance to a non-initial offset so the first 409 reset is visible
561+
await real.respond(make200WithData(real.currentHandle))
562+
563+
// First 409 with same handle — URL changes because offset resets to -1
564+
const urlBefore = real.gate.lastUrl
565+
await real.respond(make409(real.currentHandle))
566+
expect(real.gate.lastUrl).not.toBe(urlBefore)
567+
568+
// Second 409 with same handle — offset is already -1, handle unchanged.
569+
// Without a cache buster, the retry URL would be identical.
570+
const urlAfterFirstRetry = real.gate.lastUrl
571+
await real.respond(make409(real.currentHandle))
572+
expect(real.gate.lastUrl).not.toBe(urlAfterFirstRetry)
573+
} finally {
574+
real.cleanup()
575+
}
576+
})
577+
})
578+
487579
// ─── Property Tests ─────────────────────────────────────────────────
488580

489581
describe(`ShapeStream model-based property tests`, () => {
@@ -498,6 +590,8 @@ describe(`ShapeStream model-based property tests`, () => {
498590
fc.constant(new Respond204Cmd()),
499591
fc.constant(new Respond400Cmd()),
500592
fc.constant(new Respond409Cmd()),
593+
fc.constant(new Respond409SameHandleCmd()),
594+
fc.constant(new Respond409NoHandleCmd()),
501595
fc.constant(new RespondMalformed200Cmd()),
502596
fc.constant(new RespondMissingHeadersCmd()),
503597
],

0 commit comments

Comments
 (0)