-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix(core): enforce maxElapsed against wall-clock time in ExponentialB… #3779
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@trigger.dev/core": patch | ||
| --- | ||
|
|
||
| Fix `ExponentialBackoff.execute` ignoring the `maxElapsed` boundary. The retry loop now stops once the real wall-clock time spent (callback duration plus sleeps) reaches `maxElapsed`, instead of only counting the summed sleep delays. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { ExponentialBackoff } from "./backoff.js"; | ||
|
|
||
| function sleep(ms: number) { | ||
| return new Promise<void>((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
|
|
||
| describe("ExponentialBackoff.execute", () => { | ||
| it("stops retrying once real wall-clock time exceeds maxElapsed", async () => { | ||
| const backoff = new ExponentialBackoff("NoJitter", { | ||
| factor: 0, | ||
| maxRetries: 1000, | ||
| maxElapsed: 0.05, | ||
| }); | ||
|
|
||
| let attempts = 0; | ||
|
|
||
| const result = await backoff.execute(async () => { | ||
| attempts++; | ||
| await sleep(15); | ||
| throw new Error("always fails"); | ||
| }); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(attempts).toBeGreaterThan(1); | ||
| expect(attempts).toBeLessThan(1000); | ||
| }); | ||
|
|
||
| it("returns the result when the callback succeeds", async () => { | ||
| const backoff = new ExponentialBackoff("NoJitter", { | ||
| factor: 0, | ||
| maxRetries: 5, | ||
| maxElapsed: 1, | ||
| }); | ||
|
|
||
| let attempts = 0; | ||
|
|
||
| const result = await backoff.execute(async () => { | ||
| attempts++; | ||
| if (attempts < 3) { | ||
| throw new Error("not yet"); | ||
| } | ||
| return "ok"; | ||
| }); | ||
|
|
||
| expect(result).toEqual({ success: true, result: "ok" }); | ||
| expect(attempts).toBe(3); | ||
| }); | ||
|
|
||
| it("stops at maxRetries when callbacks are fast and keep failing", async () => { | ||
| const backoff = new ExponentialBackoff("NoJitter", { | ||
| factor: 0, | ||
| maxRetries: 3, | ||
| maxElapsed: 60, | ||
| }); | ||
|
|
||
| let attempts = 0; | ||
|
|
||
| const result = await backoff.execute(async () => { | ||
| attempts++; | ||
| throw new Error("always fails"); | ||
| }); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| if (!result.success) { | ||
| expect(result.cause).toBe("MaxRetries"); | ||
| } | ||
| expect(attempts).toBe(4); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -340,6 +340,10 @@ export class ExponentialBackoff { | |
| elapsedMs += Date.now() - start; | ||
| clearTimeout(attemptTimeout); | ||
| } | ||
|
|
||
| if (elapsedMs >= this.#maxElapsed * 1000) { | ||
| break; | ||
| } | ||
|
Comment on lines
+344
to
+346
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 When maxElapsed is exceeded, cause is reported as "MaxRetries" which is semantically inaccurate When the new wall-clock Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| if (finalError instanceof AttemptTimeout) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡
maxElapsedwall-clock check bypassed whenAttemptTimeoutfires due tocontinueThe new
maxElapsedwall-clock check at line 344 is placed after thetry/catch/finallyblock. When anAttemptTimeoutis caught at line 336-337, thecontinuestatement jumps to the next loop iteration, skipping themaxElapsedcheck entirely. While thefinallyblock at line 339 correctly updateselapsedMs, the break condition at line 344 is never evaluated for timeout iterations.This means that if a caller passes a non-zero
attemptTimeoutMs, and callbacks keep timing out, the loop will ignore themaxElapsedboundary and continue retrying untilmaxRetriesis exhausted or the iterator's own delay-basedelapsedcheck (packages/core/src/v3/apps/backoff.ts:130) triggers — which only accounts for computed delay values, not real wall-clock time spent in timed-out callbacks.Fix: move the check before continue or into finally
The
maxElapsedcheck should be evaluated before thecontinuestatement, e.g.:However,
elapsedMsis only updated in thefinallyblock which runs after thecatchblock, so the check would need to be restructured — either by moving the elapsed calculation before the check, or by placing themaxElapsedcheck inside thefinallyblock.(Refers to lines 336-338)
Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.