From 09b5c46ea0e324c6425daa17acb2c289e9769ec9 Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Sat, 30 May 2026 02:05:07 +0530 Subject: [PATCH] fix(core): enforce maxElapsed against wall-clock time in ExponentialBackoff.execute fixes #3726 --- .changeset/backoff-maxelapsed-wallclock.md | 5 ++ packages/core/src/v3/apps/backoff.test.ts | 71 ++++++++++++++++++++++ packages/core/src/v3/apps/backoff.ts | 4 ++ 3 files changed, 80 insertions(+) create mode 100644 .changeset/backoff-maxelapsed-wallclock.md create mode 100644 packages/core/src/v3/apps/backoff.test.ts diff --git a/.changeset/backoff-maxelapsed-wallclock.md b/.changeset/backoff-maxelapsed-wallclock.md new file mode 100644 index 00000000000..6328af07f61 --- /dev/null +++ b/.changeset/backoff-maxelapsed-wallclock.md @@ -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. diff --git a/packages/core/src/v3/apps/backoff.test.ts b/packages/core/src/v3/apps/backoff.test.ts new file mode 100644 index 00000000000..69d28834a34 --- /dev/null +++ b/packages/core/src/v3/apps/backoff.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { ExponentialBackoff } from "./backoff.js"; + +function sleep(ms: number) { + return new Promise((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); + }); +}); diff --git a/packages/core/src/v3/apps/backoff.ts b/packages/core/src/v3/apps/backoff.ts index d09e6d75fd5..438102ca551 100644 --- a/packages/core/src/v3/apps/backoff.ts +++ b/packages/core/src/v3/apps/backoff.ts @@ -340,6 +340,10 @@ export class ExponentialBackoff { elapsedMs += Date.now() - start; clearTimeout(attemptTimeout); } + + if (elapsedMs >= this.#maxElapsed * 1000) { + break; + } } if (finalError instanceof AttemptTimeout) {