Skip to content

Commit 7837402

Browse files
committed
Client errors caught in a run function now will skip retrying
1 parent 6864f5d commit 7837402

File tree

4 files changed

+169
-6
lines changed

4 files changed

+169
-6
lines changed

apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ const { action, loader } = createActionApiRoute(
116116
);
117117
} catch (error) {
118118
if (error instanceof ServiceValidationError) {
119-
return json({ error: error.message }, { status: 422 });
119+
return json({ error: error.message }, { status: error.status ?? 422 });
120120
} else if (error instanceof OutOfEntitlementError) {
121121
return json({ error: error.message }, { status: 422 });
122122
} else if (error instanceof Error) {

apps/webapp/app/runEngine/services/triggerTask.server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ export class RunEngineTriggerTaskService extends WithRunEngine {
286286

287287
if (!specifiedQueue) {
288288
throw new ServiceValidationError(
289-
`Specified queue '${specifiedQueueName}' not found or not associated with locked worker version '${
289+
`Specified queue '${specifiedQueueName}' not found or not associated with locked version '${
290290
body.options?.lockToVersion ?? "<unknown>"
291291
}'.`
292292
);
@@ -308,7 +308,7 @@ export class RunEngineTriggerTaskService extends WithRunEngine {
308308

309309
if (!lockedTask) {
310310
throw new ServiceValidationError(
311-
`Task '${taskId}' not found on locked worker version '${
311+
`Task '${taskId}' not found on locked version '${
312312
body.options?.lockToVersion ?? "<unknown>"
313313
}'.`
314314
);
@@ -317,13 +317,13 @@ export class RunEngineTriggerTaskService extends WithRunEngine {
317317
if (!lockedTask.queue) {
318318
// This case should ideally be prevented by earlier checks or schema constraints,
319319
// but handle it defensively.
320-
logger.error("Task found on locked worker, but has no associated queue record", {
320+
logger.error("Task found on locked version, but has no associated queue record", {
321321
taskId,
322322
workerId: lockedToBackgroundWorker.id,
323323
version: lockedToBackgroundWorker.version,
324324
});
325325
throw new ServiceValidationError(
326-
`Default queue configuration for task '${taskId}' missing on locked worker version '${
326+
`Default queue configuration for task '${taskId}' missing on locked version '${
327327
body.options?.lockToVersion ?? "<unknown>"
328328
}'.`
329329
);

packages/core/src/v3/workers/taskExecutor.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,19 @@ export class TaskExecutor {
10221022
return { status: "skipped" };
10231023
}
10241024

1025+
// Check for unretryable API errors (client errors except 408 and 429)
1026+
if (
1027+
error instanceof Error &&
1028+
error.name === "TriggerApiError" &&
1029+
"status" in error &&
1030+
typeof error.status === "number"
1031+
) {
1032+
const status = error.status;
1033+
if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
1034+
return { status: "skipped", error };
1035+
}
1036+
}
1037+
10251038
// Calculate default retry delay if retry config exists
10261039
let defaultDelay: number | undefined;
10271040
if (retry) {
@@ -1039,7 +1052,10 @@ export class TaskExecutor {
10391052
(error as ApiError).status === 429
10401053
) {
10411054
const rateLimitError = error as RateLimitError;
1042-
defaultDelay = rateLimitError.millisecondsUntilReset;
1055+
const rateLimitDelay = rateLimitError.millisecondsUntilReset;
1056+
if (rateLimitDelay) {
1057+
defaultDelay = rateLimitDelay;
1058+
}
10431059
}
10441060
}
10451061

packages/core/test/taskExecutor.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ import {
77
TaskMetadataWithFunctions,
88
TaskRunErrorCodes,
99
TaskRunExecution,
10+
TaskRunExecutionResult,
11+
TaskRunExecutionRetry,
1012
} from "../src/v3/index.js";
1113
import { TracingSDK } from "../src/v3/otel/tracingSDK.js";
1214
import { TriggerTracer } from "../src/v3/tracer.js";
1315
import { TaskExecutor } from "../src/v3/workers/taskExecutor.js";
1416
import { StandardLifecycleHooksManager } from "../src/v3/lifecycleHooks/manager.js";
1517
import { lifecycleHooks } from "../src/v3/index.js";
18+
import { ApiError } from "../src/v3/apiClient/errors.js";
1619

1720
describe("TaskExecutor", () => {
1821
beforeEach(() => {
@@ -1664,6 +1667,150 @@ describe("TaskExecutor", () => {
16641667
},
16651668
});
16661669
});
1670+
1671+
test("should skip retrying for unretryable API errors", async () => {
1672+
const unretryableStatusCodes = [400, 401, 403, 404, 422];
1673+
const retryableStatusCodes = [408, 429, 500, 502, 503, 504];
1674+
1675+
// Register global init hook
1676+
lifecycleHooks.registerGlobalInitHook({
1677+
id: "test-init",
1678+
fn: async () => {
1679+
return {
1680+
foo: "bar",
1681+
};
1682+
},
1683+
});
1684+
1685+
// Test each unretryable status code
1686+
for (const status of unretryableStatusCodes) {
1687+
const apiError = ApiError.generate(
1688+
status,
1689+
{ error: { message: "API Error" } },
1690+
"API Error",
1691+
{}
1692+
);
1693+
1694+
const task = {
1695+
id: "test-task",
1696+
fns: {
1697+
run: async () => {
1698+
throw apiError;
1699+
},
1700+
},
1701+
retry: {
1702+
maxAttempts: 3,
1703+
minDelay: 1000,
1704+
maxDelay: 5000,
1705+
factor: 2,
1706+
},
1707+
};
1708+
1709+
const result = await executeTask(task, { test: "data" }, undefined);
1710+
1711+
// Verify that retrying is skipped for these status codes
1712+
expect(result.result).toMatchObject({
1713+
ok: false,
1714+
id: "test-run-id",
1715+
error: {
1716+
type: "BUILT_IN_ERROR",
1717+
message: `${status} API Error`,
1718+
name: "TriggerApiError",
1719+
stackTrace: expect.any(String),
1720+
},
1721+
skippedRetrying: true,
1722+
});
1723+
}
1724+
1725+
// Test each retryable status code
1726+
for (const status of retryableStatusCodes) {
1727+
const apiError = ApiError.generate(
1728+
status,
1729+
{ error: { message: "API Error" } },
1730+
"API Error",
1731+
{}
1732+
);
1733+
1734+
const task = {
1735+
id: "test-task",
1736+
fns: {
1737+
run: async () => {
1738+
throw apiError;
1739+
},
1740+
},
1741+
retry: {
1742+
maxAttempts: 3,
1743+
minDelay: 1000,
1744+
maxDelay: 5000,
1745+
factor: 2,
1746+
},
1747+
};
1748+
1749+
const result = await executeTask(task, { test: "data" }, undefined);
1750+
1751+
// Verify that retrying is NOT skipped for these status codes
1752+
expect(result.result.ok).toBe(false);
1753+
expect(result.result).toMatchObject({
1754+
ok: false,
1755+
skippedRetrying: false,
1756+
retry: expect.objectContaining({
1757+
delay: expect.any(Number),
1758+
timestamp: expect.any(Number),
1759+
}),
1760+
});
1761+
1762+
if (status === 429) {
1763+
// Rate limit errors should use the rate limit retry delay
1764+
expect((result.result as any).retry.delay).toBeGreaterThan(0);
1765+
} else {
1766+
// Other retryable errors should use the exponential backoff
1767+
expect((result.result as any).retry.delay).toBeGreaterThan(1000);
1768+
expect((result.result as any).retry.delay).toBeLessThan(5000);
1769+
}
1770+
}
1771+
});
1772+
1773+
test("should respect rate limit headers for 429 errors", async () => {
1774+
const resetTime = Date.now() + 30000; // 30 seconds from now
1775+
const apiError = ApiError.generate(
1776+
429,
1777+
{ error: { message: "Rate limit exceeded" } },
1778+
"Rate limit exceeded",
1779+
{ "x-ratelimit-reset": resetTime.toString() }
1780+
);
1781+
1782+
const task = {
1783+
id: "test-task",
1784+
fns: {
1785+
run: async () => {
1786+
throw apiError;
1787+
},
1788+
},
1789+
retry: {
1790+
maxAttempts: 3,
1791+
minDelay: 1000,
1792+
maxDelay: 5000,
1793+
factor: 2,
1794+
},
1795+
};
1796+
1797+
const result = await executeTask(task, { test: "data" }, undefined);
1798+
1799+
// Verify that the retry delay matches the rate limit reset time (with some jitter)
1800+
expect(result.result.ok).toBe(false);
1801+
expect(result.result).toMatchObject({
1802+
ok: false,
1803+
skippedRetrying: false,
1804+
retry: expect.objectContaining({
1805+
delay: expect.any(Number),
1806+
timestamp: expect.any(Number),
1807+
}),
1808+
});
1809+
1810+
const delay = (result.result as any).retry.delay;
1811+
expect(delay).toBeGreaterThan(29900); // Allow for some time passing during test
1812+
expect(delay).toBeLessThan(32000); // Account for max 2000ms jitter
1813+
});
16671814
});
16681815

16691816
function executeTask(

0 commit comments

Comments
 (0)