Skip to content

Commit 8520617

Browse files
authored
fix(core): retry provider failures for about 84s with 10s gap cap (#49441)
1 parent ab3566a commit 8520617

2 files changed

Lines changed: 50 additions & 34 deletions

File tree

‎packages/core/src/session/runner/retry.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,13 @@ const retryAfter = (input: Input) => {
7474
return undefined
7575
}
7676

77-
const schedule = Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
77+
// Exponential from 2s capped at 10s per gap, for 10 retries: 2, 4, 8, then 10 × 7, about 84s of
78+
// waiting when every attempt fails (67–101s with jitter). `min` takes the faster schedule, so the
79+
// cap applies per gap; `max` with `recurs` bounds the count.
80+
const schedule = Schedule.max([
81+
Schedule.min([Schedule.exponential("2 seconds"), Schedule.spaced("10 seconds")]),
82+
Schedule.recurs(10),
83+
]).pipe(
7884
Schedule.jittered,
7985
Schedule.setInputType<Input>(),
8086
Schedule.modifyDelay(({ input, duration: delay }) => {

‎packages/core/test/session-runner.test.ts‎

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,12 @@ const scenario = (
580580
}),
581581
)
582582

583+
// Nominal retry gaps: exponential from 2s capped at 10s, for 10 retries.
584+
const RETRY_GAPS = [2_000, 4_000, 8_000, ...Array<number>(7).fill(10_000)]
585+
// Longest possible gap per retry (+20% jitter); advancing the clock by these always fires the retry.
586+
const RETRY_GAPS_MAX = RETRY_GAPS.map((gap) => gap * 1.2)
587+
const RETRY_ATTEMPTS = RETRY_GAPS.map((_, index) => index + 2)
588+
583589
// Subscribe before resuming; model requests can arrive before retry backoff is scheduled.
584590
const subscribeRetries = (s: Scenario) =>
585591
Effect.gen(function* () {
@@ -2676,8 +2682,8 @@ describe("SessionRunnerLLM", () => {
26762682
const compaction = yield* s.session.compact({ sessionID })
26772683
yield* s.resume
26782684

2679-
expect(attempts).toEqual([2, 3, 4, 5])
2680-
expect(s.requests).toHaveLength(6)
2685+
expect(attempts).toEqual(RETRY_ATTEMPTS)
2686+
expect(s.requests).toHaveLength(RETRY_ATTEMPTS.length + 2)
26812687
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
26822688
status: "failed",
26832689
error: { type: "provider.transport", message: "Provider unavailable" },
@@ -5476,28 +5482,34 @@ describe("SessionRunnerLLM", () => {
54765482
LLMEvent.textStart({ id: "mixed-partial" }),
54775483
LLMEvent.textDelta({ id: "mixed-partial", text: "Partial" }),
54785484
)
5479-
yield* s.llm.push(Stream.fail(failure), partial, Stream.fail(failure), partial, partial)
5485+
// Alternate transparent failures and partial continuations until the retry allowance is spent.
5486+
const outcomes = RETRY_GAPS.map((_, index) => (index % 2 === 0 ? Stream.fail(failure) : partial))
5487+
yield* s.llm.push(...outcomes, partial)
54805488
const run = yield* s.resume.pipe(Effect.forkChild)
54815489
const identities: SessionMessage.ID[] = []
5482-
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
5490+
for (const delay of RETRY_GAPS_MAX) {
54835491
identities.push(yield* Queue.take(scheduled))
54845492
yield* TestClock.adjust(delay)
54855493
}
54865494
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
5487-
expect(s.requests).toHaveLength(5)
5488-
expect(identities[0]).toBe(identities[1])
5489-
expect(identities[2]).toBe(identities[3])
5490-
expect(identities[0]).not.toBe(identities[2])
5495+
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
5496+
// A transparent retry keeps the assistant identity; a partial continuation starts a new one.
5497+
for (const [index, identity] of identities.entries()) {
5498+
if (index === 0) continue
5499+
if (index % 2 === 1) expect(identity).toBe(identities[index - 1])
5500+
else expect(identity).not.toBe(identities[index - 1])
5501+
}
5502+
const partials = outcomes.filter((outcome) => outcome === partial).length
54915503
const messages = yield* s.context
5492-
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(3)
5493-
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(2)
5504+
expect(messages.filter((message) => message.type === "assistant")).toHaveLength(partials + 1)
5505+
expect(messages.filter((message) => message.type === "synthetic")).toHaveLength(partials)
54945506
const events = yield* recordedEventTypes(sessionID)
5495-
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(4)
5496-
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(3)
5507+
expect(events.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(RETRY_GAPS.length)
5508+
expect(events.filter((type) => type === "session.step.failed.1")).toHaveLength(partials + 1)
54975509
},
54985510
)
54995511

5500-
scenario("stops incomplete stream continuations after five total attempts", function* (s) {
5512+
scenario("stops incomplete stream continuations once the retry allowance is spent", function* (s) {
55015513
yield* s.admit("Exhaust partial continuations")
55025514
const failure = incompleteStream()
55035515
yield* s.llm.always(
@@ -5511,30 +5523,30 @@ describe("SessionRunnerLLM", () => {
55115523

55125524
const scheduled = yield* subscribeRetries(s)
55135525
const run = yield* s.resume.pipe(Effect.forkChild)
5514-
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
5526+
for (const delay of RETRY_GAPS_MAX) {
55155527
yield* Queue.take(scheduled)
55165528
yield* TestClock.adjust(delay)
55175529
}
55185530
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
5519-
expect(s.requests).toHaveLength(5)
5531+
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
55205532
const context = yield* s.context
5521-
expect(context.filter((message) => message.type === "assistant")).toHaveLength(5)
5522-
expect(context.filter((message) => message.type === "synthetic")).toHaveLength(4)
5533+
expect(context.filter((message) => message.type === "assistant")).toHaveLength(RETRY_GAPS.length + 1)
5534+
expect(context.filter((message) => message.type === "synthetic")).toHaveLength(RETRY_GAPS.length)
55235535
})
55245536

5525-
scenario("stops after five total retry attempts", function* (s) {
5537+
scenario("stops once the retry allowance is spent", function* (s) {
55265538
yield* s.admit("Exhaust retries")
55275539
const failure = providerUnavailable()
55285540
yield* s.llm.always(Stream.fail(failure))
55295541

55305542
const scheduled = yield* subscribeRetries(s)
55315543
const run = yield* s.resume.pipe(Effect.forkChild)
5532-
for (const delay of [2_400, 4_800, 9_600, 19_200]) {
5544+
for (const delay of RETRY_GAPS_MAX) {
55335545
yield* Queue.take(scheduled)
55345546
yield* TestClock.adjust(delay)
55355547
}
55365548
expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
5537-
expect(s.requests).toHaveLength(5)
5549+
expect(s.requests).toHaveLength(RETRY_GAPS.length + 1)
55385550

55395551
const retries = yield* s.db
55405552
.select({ data: EventTable.data })
@@ -5543,22 +5555,20 @@ describe("SessionRunnerLLM", () => {
55435555
.orderBy(asc(EventTable.seq))
55445556
.all()
55455557
.pipe(Effect.orDie)
5546-
for (const [index, range] of [
5547-
[1_600, 2_400],
5548-
[4_800, 7_200],
5549-
[11_200, 16_800],
5550-
[24_000, 36_000],
5551-
].entries()) {
5552-
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(range[0]!)
5553-
expect(retries[index]?.data.at).toBeLessThanOrEqual(range[1]!)
5558+
// Each scheduled time falls within the jittered cumulative window for that retry.
5559+
expect(retries).toHaveLength(RETRY_GAPS.length)
5560+
let elapsed = 0
5561+
for (const [index, gap] of RETRY_GAPS.entries()) {
5562+
elapsed += gap
5563+
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(elapsed * 0.8)
5564+
expect(retries[index]?.data.at).toBeLessThanOrEqual(elapsed * 1.2)
55545565
}
5555-
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
5566+
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(
5567+
RETRY_GAPS.length + 1,
5568+
)
55565569
const assistant = requireAssistant(yield* s.context)
55575570
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
5558-
{ type: "session.step.started.1" },
5559-
{ type: "session.step.started.1" },
5560-
{ type: "session.step.started.1" },
5561-
{ type: "session.step.started.1" },
5571+
...RETRY_GAPS.map(() => ({ type: "session.step.started.1" })),
55625572
{ type: "session.step.started.1" },
55635573
{ type: "session.step.failed.1" },
55645574
])

0 commit comments

Comments
 (0)