v2: Better handle recovering from platform communication errors by auto-yielding back to the platform in case of temporary API failures

This commit is contained in:
Eric Allam
2024-05-21 14:07:16 +01:00
parent f243eab9c9
commit 3f8b6d8fce
7 changed files with 271 additions and 132 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---
v2: Better handle recovering from platform communication errors by auto-yielding back to the platform in case of temporary API failures
+2 -1
View File
@@ -9,6 +9,7 @@
"build:remix": "remix build", "build:remix": "remix build",
"build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build", "build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build",
"dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"", "dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"",
"dev:worker": "cross-env NODE_PATH=../../node_modules/.pnpm/node_modules node ./build/server.js",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"start": "cross-env NODE_ENV=production node --max-old-space-size=8192 ./build/server.js", "start": "cross-env NODE_ENV=production node --max-old-space-size=8192 ./build/server.js",
@@ -237,4 +238,4 @@
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
} }
} }
+3 -5
View File
@@ -1,5 +1,6 @@
import { context, propagation } from "@opentelemetry/api"; import { context, propagation } from "@opentelemetry/api";
import { ZodFetchOptions, zodfetch } from "../zodfetch"; import { version } from "../../../package.json";
import { APIError } from "../apiErrors";
import { import {
BatchTaskRunExecutionResult, BatchTaskRunExecutionResult,
BatchTriggerTaskRequestBody, BatchTriggerTaskRequestBody,
@@ -19,10 +20,7 @@ import {
UpdateScheduleOptions, UpdateScheduleOptions,
} from "../schemas"; } from "../schemas";
import { taskContext } from "../task-context-api"; import { taskContext } from "../task-context-api";
import { getEnvVar } from "../utils/getEnv"; import { ZodFetchOptions, zodfetch } from "../zodfetch";
import { SafeAsyncLocalStorage } from "../utils/safeAsyncLocalStorage";
import { APIError } from "../apiErrors";
import { version } from "../../../package.json";
export type TriggerOptions = { export type TriggerOptions = {
spanParentAsLink?: boolean; spanParentAsLink?: boolean;
+199 -97
View File
@@ -74,6 +74,18 @@ export type RunRecord = {
event: ApiEventLog; event: ApiEventLog;
}; };
export class UnknownVersionError extends Error {
constructor(version: string) {
super(`Unknown version ${version}`);
}
}
const MAX_RETRIES = 8;
const EXPONENT_FACTOR = 2;
const MIN_DELAY_IN_MS = 80;
const MAX_DELAY_IN_MS = 2000;
const JITTER_IN_MS = 50;
export class ApiClient { export class ApiClient {
#apiUrl: string; #apiUrl: string;
#options: ApiClientOptions; #options: ApiClientOptions;
@@ -129,11 +141,10 @@ export class ApiClient {
) { ) {
const apiKey = await this.#apiKey(); const apiKey = await this.#apiKey();
this.#logger.debug("Running Task", { this.#logger.debug(`[ApiClient] runTask ${task.displayKey}`);
task,
});
return await zodfetchWithVersions( return await zodfetchWithVersions(
this.#logger,
{ {
[API_VERSIONS.LAZY_LOADED_CACHED_TASKS]: RunTaskResponseWithCachedTasksBodySchema, [API_VERSIONS.LAZY_LOADED_CACHED_TASKS]: RunTaskResponseWithCachedTasksBodySchema,
}, },
@@ -771,6 +782,7 @@ async function zodfetchWithVersions<
TUnversionedResponseBodySchema extends z.ZodTypeAny, TUnversionedResponseBodySchema extends z.ZodTypeAny,
TOptional extends boolean = false, TOptional extends boolean = false,
>( >(
logger: Logger,
versionedSchemaMap: TVersionedResponseBodyMap, versionedSchemaMap: TVersionedResponseBodyMap,
unversionedSchema: TUnversionedResponseBodySchema, unversionedSchema: TUnversionedResponseBodySchema,
url: string, url: string,
@@ -785,66 +797,132 @@ async function zodfetchWithVersions<
? VersionedResponseBody<TVersionedResponseBodyMap, TUnversionedResponseBodySchema> | undefined ? VersionedResponseBody<TVersionedResponseBodyMap, TUnversionedResponseBodySchema> | undefined
: VersionedResponseBody<TVersionedResponseBodyMap, TUnversionedResponseBodySchema> : VersionedResponseBody<TVersionedResponseBodyMap, TUnversionedResponseBodySchema>
> { > {
const response = await fetch(url, requestInitWithCache(requestInit)); try {
const fullRequestInit = requestInitWithCache(requestInit);
if ( const response = await fetch(url, fullRequestInit);
(!requestInit || requestInit.method === "GET") &&
response.status === 404 &&
options?.optional
) {
// @ts-ignore
return;
}
if (response.status >= 400 && response.status < 500) { logger.debug(`[ApiClient] zodfetchWithVersions ${url} (attempt ${retryCount + 1})`, {
const body = await response.json();
throw new Error(body.error);
}
if (response.status >= 500 && retryCount < 6) {
// retry with exponential backoff and jitter
const delay = exponentialBackoff(retryCount + 1, 2, 50, 1150, 50);
await new Promise((resolve) => setTimeout(resolve, delay));
return zodfetchWithVersions(
versionedSchemaMap,
unversionedSchema,
url, url,
requestInit, retryCount,
options, requestHeaders: fullRequestInit?.headers,
retryCount + 1 responseHeaders: Object.fromEntries(response.headers.entries()),
); });
}
if (response.status !== 200) { if (
throw new Error( (!requestInit || requestInit.method === "GET") &&
options?.errorMessage ?? `Failed to fetch ${url}, got status code ${response.status}` response.status === 404 &&
); options?.optional
} ) {
// @ts-ignore
return;
}
const jsonBody = await response.json(); if (response.status >= 400 && response.status < 500) {
const rawBody = await safeResponseText(response);
const body = safeJsonParse(rawBody);
const version = response.headers.get("trigger-version"); logger.error(`[ApiClient] zodfetchWithVersions failed with ${response.status}`, {
url,
retryCount,
requestHeaders: fullRequestInit?.headers,
responseHeaders: Object.fromEntries(response.headers.entries()),
status: response.status,
rawBody,
});
if (body && body.error) {
throw new Error(body.error);
} else {
throw new Error(rawBody);
}
}
if (response.status >= 500 && retryCount < MAX_RETRIES) {
// retry with exponential backoff and jitter
const delay = exponentialBackoff(retryCount + 1);
await new Promise((resolve) => setTimeout(resolve, delay));
return zodfetchWithVersions(
logger,
versionedSchemaMap,
unversionedSchema,
url,
requestInit,
options,
retryCount + 1
);
}
if (response.status !== 200) {
const rawBody = await safeResponseText(response);
logger.error(`[ApiClient] zodfetchWithVersions failed with ${response.status}`, {
url,
retryCount,
requestHeaders: fullRequestInit?.headers,
responseHeaders: Object.fromEntries(response.headers.entries()),
status: response.status,
rawBody,
});
throw new Error(
options?.errorMessage ?? `Failed to fetch ${url}, got status code ${response.status}`
);
}
const jsonBody = await response.json();
const version = response.headers.get("trigger-version");
if (!version) {
return {
version: "unversioned",
body: unversionedSchema.parse(jsonBody),
};
}
const versionedSchema = versionedSchemaMap[version];
if (!versionedSchema) {
throw new UnknownVersionError(version);
}
if (!version) {
return { return {
version: "unversioned", version,
body: unversionedSchema.parse(jsonBody), body: versionedSchema.parse(jsonBody),
}; };
} catch (error) {
if (error instanceof UnknownVersionError) {
throw error;
}
logger.error(`[ApiClient] zodfetchWithVersions failed with a connection error`, {
url,
retryCount,
error,
});
if (retryCount < MAX_RETRIES) {
// retry with exponential backoff and jitter
const delay = exponentialBackoff(retryCount + 1);
await new Promise((resolve) => setTimeout(resolve, delay));
return zodfetchWithVersions(
logger,
versionedSchemaMap,
unversionedSchema,
url,
requestInit,
options,
retryCount + 1
);
}
throw error;
} }
const versionedSchema = versionedSchemaMap[version];
if (!versionedSchema) {
throw new Error(`Unknown version ${version}`);
}
return {
version,
body: versionedSchema.parse(jsonBody),
};
} }
function requestInitWithCache(requestInit?: RequestInit): RequestInit { function requestInitWithCache(requestInit?: RequestInit): RequestInit {
@@ -873,9 +951,9 @@ async function fetchHead(
}; };
const response = await fetch(url, requestInitWithCache(requestInit)); const response = await fetch(url, requestInitWithCache(requestInit));
if (response.status >= 500 && retryCount < 6) { if (response.status >= 500 && retryCount < MAX_RETRIES) {
// retry with exponential backoff and jitter // retry with exponential backoff and jitter
const delay = exponentialBackoff(retryCount + 1, 2, 50, 1150, 50); const delay = exponentialBackoff(retryCount + 1);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
@@ -897,56 +975,80 @@ async function zodfetch<TResponseSchema extends z.ZodTypeAny, TOptional extends
): Promise< ): Promise<
TOptional extends true ? z.infer<TResponseSchema> | undefined : z.infer<TResponseSchema> TOptional extends true ? z.infer<TResponseSchema> | undefined : z.infer<TResponseSchema>
> { > {
const response = await fetch(url, requestInitWithCache(requestInit)); try {
const response = await fetch(url, requestInitWithCache(requestInit));
if ( if (
(!requestInit || requestInit.method === "GET") && (!requestInit || requestInit.method === "GET") &&
response.status === 404 && response.status === 404 &&
options?.optional options?.optional
) { ) {
// @ts-ignore // @ts-ignore
return; return;
}
if (response.status >= 400 && response.status < 500) {
const body = await response.json();
throw new Error(body.error);
}
if (response.status >= 500 && retryCount < MAX_RETRIES) {
// retry with exponential backoff and jitter
const delay = exponentialBackoff(retryCount + 1);
await new Promise((resolve) => setTimeout(resolve, delay));
return zodfetch(schema, url, requestInit, options, retryCount + 1);
}
if (response.status !== 200) {
throw new Error(
options?.errorMessage ?? `Failed to fetch ${url}, got status code ${response.status}`
);
}
const jsonBody = await response.json();
return schema.parse(jsonBody);
} catch (error) {
if (retryCount < MAX_RETRIES) {
// retry with exponential backoff and jitter
const delay = exponentialBackoff(retryCount + 1);
await new Promise((resolve) => setTimeout(resolve, delay));
return zodfetch(schema, url, requestInit, options, retryCount + 1);
}
throw error;
} }
if (response.status >= 400 && response.status < 500) {
const body = await response.json();
throw new Error(body.error);
}
if (response.status >= 500 && retryCount < 6) {
// retry with exponential backoff and jitter
const delay = exponentialBackoff(retryCount + 1, 2, 50, 1150, 50);
await new Promise((resolve) => setTimeout(resolve, delay));
return zodfetch(schema, url, requestInit, options, retryCount + 1);
}
if (response.status !== 200) {
throw new Error(
options?.errorMessage ?? `Failed to fetch ${url}, got status code ${response.status}`
);
}
const jsonBody = await response.json();
return schema.parse(jsonBody);
} }
function exponentialBackoff( // First retry will have a delay of 80ms, second 160ms, third 320ms, etc.
retryCount: number, function exponentialBackoff(retryCount: number): number {
exponential: number,
minDelay: number,
maxDelay: number,
jitter: number
): number {
// Calculate the delay using the exponential backoff formula // Calculate the delay using the exponential backoff formula
const delay = Math.min(Math.pow(exponential, retryCount) * minDelay, maxDelay); const delay = Math.min(Math.pow(EXPONENT_FACTOR, retryCount) * MIN_DELAY_IN_MS, MAX_DELAY_IN_MS);
// Calculate the jitter // Calculate the jitter
const jitterValue = Math.random() * jitter; const jitterValue = Math.random() * JITTER_IN_MS;
// Return the calculated delay with jitter // Return the calculated delay with jitter
return delay + jitterValue; return delay + jitterValue;
} }
function safeJsonParse(rawBody: string) {
try {
return JSON.parse(rawBody);
} catch (error) {
return;
}
}
async function safeResponseText(response: Response) {
try {
return await response.text();
} catch (error) {
return "";
}
}
+53 -16
View File
@@ -1,6 +1,7 @@
import { import {
API_VERSIONS, API_VERSIONS,
CachedTask, CachedTask,
CompleteTaskBodyV2Input,
ConnectionAuth, ConnectionAuth,
CronOptions, CronOptions,
ErrorWithStackSchema, ErrorWithStackSchema,
@@ -11,6 +12,7 @@ import {
FetchTimeoutOptions, FetchTimeoutOptions,
InitialStatusUpdate, InitialStatusUpdate,
IntervalOptions, IntervalOptions,
RunTaskBodyInput,
RunTaskOptions, RunTaskOptions,
SendEvent, SendEvent,
SendEventOptions, SendEventOptions,
@@ -1161,19 +1163,18 @@ export class IO {
const runOptions = { ...(options ?? {}), parseOutput: undefined }; const runOptions = { ...(options ?? {}), parseOutput: undefined };
const response = await this._apiClient.runTask( const response = await this.#doRunTask({
this._id, idempotencyKey,
{ displayKey: typeof cacheKey === "string" ? cacheKey : undefined,
idempotencyKey, noop: false,
displayKey: typeof cacheKey === "string" ? cacheKey : undefined, ...(runOptions ?? {}),
noop: false, parentId,
...(runOptions ?? {}), });
parentId,
}, if (!response) {
{ this.#forceYield("failed_task_run");
cachedTasksCursor: this._cachedTasksCursor, throw new Error("Failed to run task"); // this shouldn't actually happen, because forceYield will throw
} }
);
const task = const task =
response.version === API_VERSIONS.LAZY_LOADED_CACHED_TASKS response.version === API_VERSIONS.LAZY_LOADED_CACHED_TASKS
@@ -1267,11 +1268,16 @@ export class IO {
this.#detectAutoYield("before_complete_task", 500, task, output); this.#detectAutoYield("before_complete_task", 500, task, output);
const completedTask = await this._apiClient.completeTask(this._id, task.id, { const completedTask = await this.#doCompleteTask(task.id, {
output, output,
properties: task.outputProperties ?? undefined, properties: task.outputProperties ?? undefined,
}); });
if (!completedTask) {
this.#forceYield("before_complete_task", task, output);
throw new Error("Failed to complete task"); // this shouldn't actually happen, because forceYield will throw
}
if (completedTask.forceYield) { if (completedTask.forceYield) {
this._logger.debug("Forcing yield after task completed", { this._logger.debug("Forcing yield after task completed", {
idempotencyKey, idempotencyKey,
@@ -1448,6 +1454,24 @@ export class IO {
this._cachedTasks.set(task.idempotencyKey, task); this._cachedTasks.set(task.idempotencyKey, task);
} }
async #doRunTask(task: RunTaskBodyInput) {
try {
return await this._apiClient.runTask(this._id, task, {
cachedTasksCursor: this._cachedTasksCursor,
});
} catch (error) {
return;
}
}
async #doCompleteTask(id: string, task: CompleteTaskBodyV2Input) {
try {
return await this._apiClient.completeTask(this._id, id, task);
} catch (error) {
return;
}
}
#detectAutoYield(location: string, threshold: number = 1500, task?: ServerTask, output?: string) { #detectAutoYield(location: string, threshold: number = 1500, task?: ServerTask, output?: string) {
const timeRemaining = this.#getRemainingTimeInMillis(); const timeRemaining = this.#getRemainingTimeInMillis();
@@ -1469,11 +1493,24 @@ export class IO {
} }
} }
#forceYield(location: string) { #forceYield(location: string, task?: ServerTask, output?: string) {
const timeRemaining = this.#getRemainingTimeInMillis(); const timeRemaining = this.#getRemainingTimeInMillis();
if (timeRemaining) { if (timeRemaining) {
throw new AutoYieldExecutionError(location, timeRemaining, this.#getTimeElapsed()); if (task) {
throw new AutoYieldWithCompletedTaskExecutionError(
task.id,
task.outputProperties ?? [],
{
location,
timeRemaining,
timeElapsed: this.#getTimeElapsed(),
},
output
);
} else {
throw new AutoYieldExecutionError(location, timeRemaining, this.#getTimeElapsed());
}
} }
} }
+5 -1
View File
@@ -198,11 +198,15 @@ export class TriggerClient {
constructor(options: Prettify<TriggerClientOptions>) { constructor(options: Prettify<TriggerClientOptions>) {
this.id = options.id; this.id = options.id;
this.#options = options; this.#options = options;
this.#client = new ApiClient(this.#options);
this.#internalLogger = new Logger("trigger.dev", this.#options.verbose ? "debug" : "log", [ this.#internalLogger = new Logger("trigger.dev", this.#options.verbose ? "debug" : "log", [
"output", "output",
"noopTasksSet", "noopTasksSet",
]); ]);
this.#client = new ApiClient({
logLevel: this.#options.verbose ? "debug" : "log",
...this.#options,
});
this.#envStore = new KeyValueStore(this.#client); this.#envStore = new KeyValueStore(this.#client);
} }
+3 -12
View File
@@ -23,23 +23,14 @@ client.defineJob({
`task-${i}`, `task-${i}`,
async (task) => { async (task) => {
return { return {
output: "a".repeat(300 * 1024), output: "a".repeat(30),
}; };
}, },
{ name: `Task ${i}` } { name: `Task ${i}` }
); );
}
// Now run a single task with 5MB output await new Promise((resolve) => setTimeout(resolve, 2000));
await io.runTask( }
`task-5mb`,
async (task) => {
return {
output: "a".repeat(5 * 1024 * 1024),
};
},
{ name: `Task 5MB` }
);
// Now do a wait for 5 seconds // Now do a wait for 5 seconds
await io.wait("wait", 5); await io.wait("wait", 5);