@notionhq/apps 0.0.40 → 0.0.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/error.d.ts +11 -0
- package/dist/error.d.ts.map +1 -1
- package/dist/error.js +20 -1
- package/dist/workflow.d.ts +41 -1
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +232 -12
- package/package.json +1 -1
- package/skills/workflow/SKILL.md +46 -0
- package/src/error.ts +25 -0
- package/src/workflow.test.ts +425 -3
- package/src/workflow.ts +398 -15
package/src/workflow.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { join } from "node:path";
|
|
|
19
19
|
|
|
20
20
|
import type { CapabilityContext } from "./context.js";
|
|
21
21
|
import { createCapabilityContext } from "./context.js";
|
|
22
|
-
import { ExecutionError } from "./error.js";
|
|
22
|
+
import { ExecutionError, FatalError, RateLimitError, RetryableError } from "./error.js";
|
|
23
23
|
import { writeOutput } from "./output.js";
|
|
24
24
|
import { resolveRuntimeInput } from "./runtime-input.js";
|
|
25
25
|
import { readRunMetadata, type RunMetadata } from "./runtime-metadata.js";
|
|
@@ -186,6 +186,7 @@ type WorkflowWaitResult = WorkflowWaitUntilResult | WorkflowWaitForInputResult;
|
|
|
186
186
|
type WorkflowHandlerResult = { status: "success" } | WorkflowWaitResult;
|
|
187
187
|
|
|
188
188
|
const MAX_WORKFLOW_WAIT_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
189
|
+
export { FatalError, RetryableError } from "./error.js";
|
|
189
190
|
|
|
190
191
|
export type {
|
|
191
192
|
WorkflowAccess,
|
|
@@ -228,6 +229,58 @@ export {
|
|
|
228
229
|
type OAuthConnection,
|
|
229
230
|
} from "./connections.js";
|
|
230
231
|
|
|
232
|
+
export type WorkflowDeadline = {
|
|
233
|
+
/** Maximum wall-clock lifetime of one logical workflow run. */
|
|
234
|
+
afterMs: number;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* An error class, or a predicate, that marks an error as retryable.
|
|
239
|
+
* Classes match with `instanceof`; any other function is called with the error.
|
|
240
|
+
*/
|
|
241
|
+
export type WorkflowRetryableErrorMatcher =
|
|
242
|
+
| (abstract new (...args: never[]) => Error)
|
|
243
|
+
| ((error: unknown) => boolean);
|
|
244
|
+
|
|
245
|
+
export type WorkflowRetryPolicy = {
|
|
246
|
+
/** Total attempts, including the initial attempt. At most 4. */
|
|
247
|
+
maxAttempts: number;
|
|
248
|
+
/** Delay before the first retry. Defaults to 1 second. */
|
|
249
|
+
initialDelayMs?: number;
|
|
250
|
+
/** Maximum delay between attempts. Defaults to 5 minutes. */
|
|
251
|
+
maxDelayMs?: number;
|
|
252
|
+
/** Exponential backoff multiplier. Defaults to 2. */
|
|
253
|
+
backoffMultiplier?: number;
|
|
254
|
+
/**
|
|
255
|
+
* Errors that may be retried, as one matcher or an array of matchers. When
|
|
256
|
+
* set, only matching errors and `RetryableError` are retried; every other
|
|
257
|
+
* error fails immediately. When omitted, every error except `FatalError` is
|
|
258
|
+
* retried.
|
|
259
|
+
*/
|
|
260
|
+
retryOn?: WorkflowRetryableErrorMatcher | readonly WorkflowRetryableErrorMatcher[];
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** Platform limit on `maxAttempts`. */
|
|
264
|
+
const MAX_WORKFLOW_RETRY_ATTEMPTS = 3;
|
|
265
|
+
|
|
266
|
+
/** Platform limit on deadlines and retry delays: seven days. */
|
|
267
|
+
const MAX_WORKFLOW_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* One workflow invocation runs in a sandbox that stops after five minutes, and
|
|
271
|
+
* step retries wait inside that invocation. A step's worst-case retry delays
|
|
272
|
+
* and timeouts must fit in this budget.
|
|
273
|
+
*/
|
|
274
|
+
const WORKFLOW_STEP_RETRY_BUDGET_MS = 5 * 60 * 1000;
|
|
275
|
+
|
|
276
|
+
/** Retry policy used when neither the workflow nor the step sets one. */
|
|
277
|
+
const DEFAULT_WORKFLOW_RETRY_POLICY = {
|
|
278
|
+
maxAttempts: 3,
|
|
279
|
+
initialDelayMs: 1_000,
|
|
280
|
+
maxDelayMs: 5 * 60 * 1000,
|
|
281
|
+
backoffMultiplier: 2,
|
|
282
|
+
} as const satisfies WorkflowRetryPolicy;
|
|
283
|
+
|
|
231
284
|
/**
|
|
232
285
|
* The inbound HTTP request passed to a webhook verify handler.
|
|
233
286
|
*/
|
|
@@ -332,6 +385,15 @@ export type WorkflowConfiguration<
|
|
|
332
385
|
*/
|
|
333
386
|
connections?: TConnections;
|
|
334
387
|
|
|
388
|
+
/** Maximum wall-clock lifetime of one logical workflow run. */
|
|
389
|
+
deadline?: WorkflowDeadline;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Default retry policy for every step, and the retry policy for failures
|
|
393
|
+
* that happen outside a step. A step's own `retry` replaces it.
|
|
394
|
+
*/
|
|
395
|
+
retry?: WorkflowRetryPolicy;
|
|
396
|
+
|
|
335
397
|
/**
|
|
336
398
|
* Optional synchronous verification handler, run at Notion's webhook
|
|
337
399
|
* ingress before the HTTP response is sent. Use it for providers that
|
|
@@ -400,6 +462,8 @@ export type Workflow<
|
|
|
400
462
|
triggers: WorkflowManifestTrigger[];
|
|
401
463
|
connections?: readonly WorkflowConnectionRequirement[];
|
|
402
464
|
access?: WorkflowAccessRequirements;
|
|
465
|
+
deadline?: WorkflowDeadline;
|
|
466
|
+
retry?: WorkflowRetryPolicy;
|
|
403
467
|
};
|
|
404
468
|
handler: (
|
|
405
469
|
input: WorkflowEventForTriggers<TTriggers> | WebhookVerifyInvocation,
|
|
@@ -468,6 +532,16 @@ export function workflow<
|
|
|
468
532
|
? configuration.triggers({ events: createWorkflowEvents<TConnections>() })
|
|
469
533
|
: configuration.triggers;
|
|
470
534
|
validateTriggerConnections(triggers, requirements);
|
|
535
|
+
validateWorkflowDeadline(configuration.deadline);
|
|
536
|
+
validateWorkflowRetryPolicy(configuration.retry, "Workflow retry policy");
|
|
537
|
+
if (configuration.retry !== undefined) {
|
|
538
|
+
validateStepRetryBudget(configuration.retry, undefined, "Workflow retry policy");
|
|
539
|
+
}
|
|
540
|
+
const deadline =
|
|
541
|
+
configuration.deadline === undefined ? undefined : structuredClone(configuration.deadline);
|
|
542
|
+
const retry =
|
|
543
|
+
configuration.retry === undefined ? undefined : manifestRetryPolicy(configuration.retry);
|
|
544
|
+
|
|
471
545
|
const manualTriggers = triggers.filter(
|
|
472
546
|
(trigger): trigger is ManualWorkflowTrigger => trigger.type === "workflow.manual",
|
|
473
547
|
);
|
|
@@ -493,11 +567,17 @@ export function workflow<
|
|
|
493
567
|
triggers: manifestTriggers(triggers, configuration.verify !== undefined),
|
|
494
568
|
...(configuration.connections === undefined ? {} : { connections: requirements }),
|
|
495
569
|
...(configuration.access === undefined ? {} : { access: accessRequirements }),
|
|
570
|
+
...(deadline === undefined ? {} : { deadline }),
|
|
571
|
+
...(retry === undefined ? {} : { retry }),
|
|
496
572
|
},
|
|
497
573
|
async handler(
|
|
498
574
|
input: WorkflowEventForTriggers<TTriggers> | WebhookVerifyInvocation,
|
|
499
575
|
options?: HandlerOptions,
|
|
500
576
|
): Promise<WorkflowHandlerResult | WebhookVerifyResponse | undefined> {
|
|
577
|
+
const invocationStartedAt = Date.now();
|
|
578
|
+
// Errors from steps that already used their retry policy. The run is not
|
|
579
|
+
// retried for these, so they are reported as terminal.
|
|
580
|
+
const exhaustedStepErrors = new WeakSet<object>();
|
|
501
581
|
try {
|
|
502
582
|
input = await resolveRuntimeInput(input);
|
|
503
583
|
|
|
@@ -525,7 +605,11 @@ export function workflow<
|
|
|
525
605
|
const event = input;
|
|
526
606
|
const runMetadata = readRunMetadata();
|
|
527
607
|
const baseContext = createCapabilityContext();
|
|
528
|
-
const step = createStep(runMetadata
|
|
608
|
+
const step = createStep(runMetadata, {
|
|
609
|
+
defaultRetry: configuration.retry,
|
|
610
|
+
exhaustedErrors: exhaustedStepErrors,
|
|
611
|
+
invocationStartedAt,
|
|
612
|
+
});
|
|
529
613
|
const capabilityContext: WorkflowContext<TConnections, TAccess> = {
|
|
530
614
|
...baseContext,
|
|
531
615
|
access: hydrateWorkflowAccess<TAccess>(accessRequirements),
|
|
@@ -567,15 +651,51 @@ export function workflow<
|
|
|
567
651
|
}
|
|
568
652
|
|
|
569
653
|
const error = new ExecutionError(err);
|
|
654
|
+
const terminal =
|
|
655
|
+
err instanceof FatalError ||
|
|
656
|
+
(isObject(err) && exhaustedStepErrors.has(err)) ||
|
|
657
|
+
(!(err instanceof RetryableError) &&
|
|
658
|
+
!isRetryableError(err, configuration.retry));
|
|
570
659
|
|
|
571
660
|
if (!options?.concreteOutput) {
|
|
572
661
|
writeOutput({
|
|
573
662
|
_tag: "error",
|
|
574
|
-
error:
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
663
|
+
error:
|
|
664
|
+
!terminal && err instanceof RateLimitError
|
|
665
|
+
? {
|
|
666
|
+
_tag: "rate_limit",
|
|
667
|
+
name: err.name,
|
|
668
|
+
message: err.message,
|
|
669
|
+
...(err.retryAfter === undefined
|
|
670
|
+
? {}
|
|
671
|
+
: { retryAfter: err.retryAfter }),
|
|
672
|
+
}
|
|
673
|
+
: !terminal && err instanceof RetryableError
|
|
674
|
+
? {
|
|
675
|
+
_tag: "retryable",
|
|
676
|
+
name: err.name,
|
|
677
|
+
message: err.message,
|
|
678
|
+
trace: err.stack,
|
|
679
|
+
...(err.retryAfterMs === undefined
|
|
680
|
+
? {}
|
|
681
|
+
: { retryAfterMs: err.retryAfterMs }),
|
|
682
|
+
}
|
|
683
|
+
: terminal
|
|
684
|
+
? {
|
|
685
|
+
_tag: "terminal",
|
|
686
|
+
name: err instanceof Error ? err.name : error.name,
|
|
687
|
+
message:
|
|
688
|
+
err instanceof Error
|
|
689
|
+
? err.message
|
|
690
|
+
: error.message,
|
|
691
|
+
trace:
|
|
692
|
+
err instanceof Error ? err.stack : error.stack,
|
|
693
|
+
}
|
|
694
|
+
: {
|
|
695
|
+
name: error.name,
|
|
696
|
+
message: error.message,
|
|
697
|
+
trace: error.stack,
|
|
698
|
+
},
|
|
579
699
|
});
|
|
580
700
|
}
|
|
581
701
|
|
|
@@ -635,7 +755,11 @@ export type WorkflowStepOptions = {
|
|
|
635
755
|
* Pass a string for a single key segment or an array of strings for a composite
|
|
636
756
|
* key. Defaults to the step name. Changing the key creates a new logical step.
|
|
637
757
|
*/
|
|
638
|
-
key
|
|
758
|
+
key?: string | string[];
|
|
759
|
+
/** Maximum duration of one invocation of the step callback. */
|
|
760
|
+
timeoutMs?: number;
|
|
761
|
+
/** Retry policy for failures from this step callback. */
|
|
762
|
+
retry?: WorkflowRetryPolicy;
|
|
639
763
|
};
|
|
640
764
|
|
|
641
765
|
type WorkflowStep = {
|
|
@@ -733,8 +857,15 @@ function writeStepEvent(
|
|
|
733
857
|
|
|
734
858
|
function createStep(
|
|
735
859
|
metadata: Pick<RunMetadata, "runGroupId" | "workerId">,
|
|
860
|
+
options: {
|
|
861
|
+
defaultRetry?: WorkflowRetryPolicy | undefined;
|
|
862
|
+
exhaustedErrors?: WeakSet<object>;
|
|
863
|
+
/** When the handler started. Step retries share one invocation's budget. */
|
|
864
|
+
invocationStartedAt: number;
|
|
865
|
+
},
|
|
736
866
|
): WorkflowContext["step"] {
|
|
737
867
|
const { runGroupId } = metadata;
|
|
868
|
+
const { defaultRetry, exhaustedErrors, invocationStartedAt } = options;
|
|
738
869
|
const stepNameByKey = new Map<string, string>();
|
|
739
870
|
|
|
740
871
|
async function step<T>(
|
|
@@ -742,13 +873,17 @@ function createStep(
|
|
|
742
873
|
optionsOrFn: WorkflowStepOptions | ((context: StepContext) => T | Promise<T>),
|
|
743
874
|
maybeFn?: (context: StepContext) => T | Promise<T>,
|
|
744
875
|
): Promise<WorkflowStepResult<T>> {
|
|
745
|
-
const
|
|
876
|
+
const stepOptions = typeof optionsOrFn === "function" ? undefined : optionsOrFn;
|
|
746
877
|
const fn = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn;
|
|
747
878
|
if (!fn) {
|
|
748
879
|
throw new Error(`Workflow step "${name}" is missing its callback.`);
|
|
749
880
|
}
|
|
750
881
|
|
|
751
|
-
|
|
882
|
+
validateWorkflowStepOptions(stepOptions);
|
|
883
|
+
const retry = stepOptions?.retry ?? defaultRetry ?? DEFAULT_WORKFLOW_RETRY_POLICY;
|
|
884
|
+
validateStepRetryBudget(retry, stepOptions?.timeoutMs, `Workflow step "${name}"`);
|
|
885
|
+
|
|
886
|
+
const keyInput = stepOptions?.key ?? name;
|
|
752
887
|
const key = createWorkflowStepKey(typeof keyInput === "string" ? [keyInput] : keyInput);
|
|
753
888
|
const existingStepName = stepNameByKey.get(key);
|
|
754
889
|
if (existingStepName !== undefined) {
|
|
@@ -759,9 +894,7 @@ function createStep(
|
|
|
759
894
|
stepNameByKey.set(key, name);
|
|
760
895
|
|
|
761
896
|
const id = `${runGroupId}:${key}`;
|
|
762
|
-
const
|
|
763
|
-
const context: StepContext = { id, state: stepState.state };
|
|
764
|
-
const event = { id: context.id, name, key };
|
|
897
|
+
const event = { id, name, key };
|
|
765
898
|
const startedAt = performance.now();
|
|
766
899
|
|
|
767
900
|
writeStepEvent("started", event, startedAt, startedAt);
|
|
@@ -789,7 +922,16 @@ function createStep(
|
|
|
789
922
|
return checkpoint.value as WorkflowStepResult<T>;
|
|
790
923
|
}
|
|
791
924
|
|
|
792
|
-
const value = normalizeWorkflowStepResult(
|
|
925
|
+
const value = normalizeWorkflowStepResult(
|
|
926
|
+
await runWorkflowStepCallback({
|
|
927
|
+
metadata,
|
|
928
|
+
id,
|
|
929
|
+
fn,
|
|
930
|
+
timeoutMs: stepOptions?.timeoutMs,
|
|
931
|
+
retry,
|
|
932
|
+
invocationStartedAt,
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
793
935
|
writeStepEvent("success", { ...event, value }, startedAt);
|
|
794
936
|
writeStepEvent(
|
|
795
937
|
"completed",
|
|
@@ -800,7 +942,11 @@ function createStep(
|
|
|
800
942
|
startedAt,
|
|
801
943
|
);
|
|
802
944
|
return value;
|
|
803
|
-
} catch (
|
|
945
|
+
} catch (caught) {
|
|
946
|
+
// A retry that no longer fits in this invocation is left to the run's
|
|
947
|
+
// retry policy, so the step is not reported as having used its retries.
|
|
948
|
+
const deferred = caught instanceof DeferredStepRetry;
|
|
949
|
+
const err = deferred ? caught.error : caught;
|
|
804
950
|
const error = new ExecutionError(err);
|
|
805
951
|
writeStepEvent(
|
|
806
952
|
"failure",
|
|
@@ -822,6 +968,9 @@ function createStep(
|
|
|
822
968
|
},
|
|
823
969
|
startedAt,
|
|
824
970
|
);
|
|
971
|
+
if (!deferred && isObject(err)) {
|
|
972
|
+
exhaustedErrors?.add(err);
|
|
973
|
+
}
|
|
825
974
|
throw err;
|
|
826
975
|
}
|
|
827
976
|
}
|
|
@@ -829,6 +978,11 @@ function createStep(
|
|
|
829
978
|
return step;
|
|
830
979
|
}
|
|
831
980
|
|
|
981
|
+
/** Signals that a step retry must be deferred to a later workflow invocation. */
|
|
982
|
+
class DeferredStepRetry {
|
|
983
|
+
constructor(readonly error: unknown) {}
|
|
984
|
+
}
|
|
985
|
+
|
|
832
986
|
class WorkflowWaitInterrupt extends Error {
|
|
833
987
|
constructor(readonly result: WorkflowWaitResult) {
|
|
834
988
|
super(workflowWaitMessage(result));
|
|
@@ -1035,6 +1189,235 @@ function waitDurationUnitMultiplier(unit: string): number {
|
|
|
1035
1189
|
}
|
|
1036
1190
|
}
|
|
1037
1191
|
|
|
1192
|
+
async function runWorkflowStepCallback<T>(args: {
|
|
1193
|
+
metadata: Pick<RunMetadata, "workerId">;
|
|
1194
|
+
id: string;
|
|
1195
|
+
fn: (context: StepContext) => T | Promise<T>;
|
|
1196
|
+
timeoutMs: number | undefined;
|
|
1197
|
+
retry: WorkflowRetryPolicy;
|
|
1198
|
+
invocationStartedAt: number;
|
|
1199
|
+
}): Promise<T> {
|
|
1200
|
+
let attempt = 1;
|
|
1201
|
+
|
|
1202
|
+
while (true) {
|
|
1203
|
+
// Each attempt gets its own state scope so a failed or timed-out attempt
|
|
1204
|
+
// never commits, and its pending mutations are not visible to the retry.
|
|
1205
|
+
const stepState = createWorkflowStepState(args.metadata, args.id);
|
|
1206
|
+
const context: StepContext = { id: args.id, state: stepState.state };
|
|
1207
|
+
try {
|
|
1208
|
+
return await stepState.run(() => runWithTimeout(args.fn(context), args.timeoutMs));
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
if (attempt >= args.retry.maxAttempts || !isRetryableError(error, args.retry)) {
|
|
1211
|
+
throw error;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const delayMs =
|
|
1215
|
+
explicitRetryDelay(error) ?? calculateRetryDelay(args.retry, attempt - 1);
|
|
1216
|
+
// Earlier work in this invocation shares the sandbox, so measure from
|
|
1217
|
+
// the handler's start rather than the step's.
|
|
1218
|
+
const nextAttemptBudgetMs = delayMs + (args.timeoutMs ?? 0);
|
|
1219
|
+
if (
|
|
1220
|
+
Date.now() - args.invocationStartedAt + nextAttemptBudgetMs >
|
|
1221
|
+
WORKFLOW_STEP_RETRY_BUDGET_MS
|
|
1222
|
+
) {
|
|
1223
|
+
throw new DeferredStepRetry(error);
|
|
1224
|
+
}
|
|
1225
|
+
if (delayMs > 0) {
|
|
1226
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1227
|
+
}
|
|
1228
|
+
attempt += 1;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Whether a policy permits retrying an error. `FatalError` never retries and
|
|
1235
|
+
* `RetryableError` always does; `retryOn`, when set, limits everything else.
|
|
1236
|
+
*/
|
|
1237
|
+
function isRetryableError(error: unknown, policy: WorkflowRetryPolicy | undefined): boolean {
|
|
1238
|
+
if (error instanceof FatalError) {
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
if (error instanceof RetryableError || policy?.retryOn === undefined) {
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1244
|
+
const matchers: readonly WorkflowRetryableErrorMatcher[] =
|
|
1245
|
+
typeof policy.retryOn === "function" ? [policy.retryOn] : policy.retryOn;
|
|
1246
|
+
return matchers.some((matcher) =>
|
|
1247
|
+
isErrorClass(matcher)
|
|
1248
|
+
? error instanceof matcher
|
|
1249
|
+
: (matcher as (error: unknown) => boolean)(error),
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function isErrorClass(
|
|
1254
|
+
matcher: WorkflowRetryableErrorMatcher,
|
|
1255
|
+
): matcher is abstract new (...args: never[]) => Error {
|
|
1256
|
+
return matcher === Error || matcher.prototype instanceof Error;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** A delay requested by the error itself, which takes precedence over backoff. */
|
|
1260
|
+
function explicitRetryDelay(error: unknown): number | undefined {
|
|
1261
|
+
if (error instanceof RetryableError) {
|
|
1262
|
+
return error.retryAfterMs;
|
|
1263
|
+
}
|
|
1264
|
+
if (error instanceof RateLimitError && error.retryAfter !== undefined) {
|
|
1265
|
+
return error.retryAfter * 1_000;
|
|
1266
|
+
}
|
|
1267
|
+
return undefined;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
async function runWithTimeout<T>(value: T | Promise<T>, timeoutMs: number | undefined): Promise<T> {
|
|
1271
|
+
if (timeoutMs === undefined) {
|
|
1272
|
+
return await value;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1276
|
+
try {
|
|
1277
|
+
return await Promise.race([
|
|
1278
|
+
value,
|
|
1279
|
+
new Promise<never>((_resolve, reject) => {
|
|
1280
|
+
timer = setTimeout(() => {
|
|
1281
|
+
const error = new Error(`Workflow step timed out after ${timeoutMs}ms.`);
|
|
1282
|
+
error.name = "StepTimeoutError";
|
|
1283
|
+
reject(error);
|
|
1284
|
+
}, timeoutMs);
|
|
1285
|
+
}),
|
|
1286
|
+
]);
|
|
1287
|
+
} finally {
|
|
1288
|
+
if (timer !== undefined) {
|
|
1289
|
+
clearTimeout(timer);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Exponential backoff with "equal jitter": half the delay is fixed and half is
|
|
1296
|
+
* random, so retries from many runs spread out without retrying too early.
|
|
1297
|
+
*/
|
|
1298
|
+
function calculateRetryDelay(policy: WorkflowRetryPolicy, retryIndex: number): number {
|
|
1299
|
+
const delayMs = maxRetryDelay(policy, retryIndex);
|
|
1300
|
+
return delayMs / 2 + Math.random() * (delayMs / 2);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** The backoff delay before jitter, which is also its upper bound. */
|
|
1304
|
+
function maxRetryDelay(policy: WorkflowRetryPolicy, retryIndex: number): number {
|
|
1305
|
+
const initialDelayMs = policy.initialDelayMs ?? DEFAULT_WORKFLOW_RETRY_POLICY.initialDelayMs;
|
|
1306
|
+
const maxDelayMs = policy.maxDelayMs ?? DEFAULT_WORKFLOW_RETRY_POLICY.maxDelayMs;
|
|
1307
|
+
const backoffMultiplier =
|
|
1308
|
+
policy.backoffMultiplier ?? DEFAULT_WORKFLOW_RETRY_POLICY.backoffMultiplier;
|
|
1309
|
+
return Math.min(initialDelayMs * backoffMultiplier ** retryIndex, maxDelayMs);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Rejects retry policies whose longest possible retry delays plus step timeouts
|
|
1314
|
+
* cannot fit in one workflow invocation.
|
|
1315
|
+
*/
|
|
1316
|
+
function validateStepRetryBudget(
|
|
1317
|
+
policy: WorkflowRetryPolicy,
|
|
1318
|
+
timeoutMs: number | undefined,
|
|
1319
|
+
label: string,
|
|
1320
|
+
): void {
|
|
1321
|
+
let worstCaseMs = (timeoutMs ?? 0) * policy.maxAttempts;
|
|
1322
|
+
for (let retryIndex = 0; retryIndex < policy.maxAttempts - 1; retryIndex++) {
|
|
1323
|
+
worstCaseMs += maxRetryDelay(policy, retryIndex);
|
|
1324
|
+
}
|
|
1325
|
+
if (worstCaseMs > WORKFLOW_STEP_RETRY_BUDGET_MS) {
|
|
1326
|
+
throw new Error(
|
|
1327
|
+
`${label} can spend up to ${worstCaseMs}ms on retry delays and timeouts, which exceeds the ${WORKFLOW_STEP_RETRY_BUDGET_MS}ms limit for one workflow invocation. Lower maxAttempts, the retry delays, or timeoutMs, or use context.wait.until() for longer waits.`,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** The manifest carries only serializable policy fields; `retryOn` runs in the SDK. */
|
|
1333
|
+
function manifestRetryPolicy(policy: WorkflowRetryPolicy): Omit<WorkflowRetryPolicy, "retryOn"> {
|
|
1334
|
+
const { retryOn: _retryOn, ...rest } = policy;
|
|
1335
|
+
return structuredClone(rest);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function validateWorkflowDeadline(deadline: WorkflowDeadline | undefined): void {
|
|
1339
|
+
if (deadline === undefined) {
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
assertPositiveFiniteNumber(deadline.afterMs, "Workflow deadline afterMs");
|
|
1343
|
+
assertAtMost(deadline.afterMs, MAX_WORKFLOW_DURATION_MS, "Workflow deadline afterMs");
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function validateWorkflowRetryPolicy(policy: WorkflowRetryPolicy | undefined, label: string): void {
|
|
1347
|
+
if (policy === undefined) {
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (
|
|
1351
|
+
!Number.isInteger(policy.maxAttempts) ||
|
|
1352
|
+
policy.maxAttempts < 1 ||
|
|
1353
|
+
policy.maxAttempts > MAX_WORKFLOW_RETRY_ATTEMPTS
|
|
1354
|
+
) {
|
|
1355
|
+
throw new Error(
|
|
1356
|
+
`${label} maxAttempts must be an integer between 1 and ${MAX_WORKFLOW_RETRY_ATTEMPTS}.`,
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
if (policy.initialDelayMs !== undefined) {
|
|
1360
|
+
assertNonNegativeFiniteNumber(policy.initialDelayMs, `${label} initialDelayMs`);
|
|
1361
|
+
assertAtMost(policy.initialDelayMs, MAX_WORKFLOW_DURATION_MS, `${label} initialDelayMs`);
|
|
1362
|
+
}
|
|
1363
|
+
if (policy.maxDelayMs !== undefined) {
|
|
1364
|
+
assertNonNegativeFiniteNumber(policy.maxDelayMs, `${label} maxDelayMs`);
|
|
1365
|
+
assertAtMost(policy.maxDelayMs, MAX_WORKFLOW_DURATION_MS, `${label} maxDelayMs`);
|
|
1366
|
+
}
|
|
1367
|
+
if (
|
|
1368
|
+
policy.retryOn !== undefined &&
|
|
1369
|
+
typeof policy.retryOn !== "function" &&
|
|
1370
|
+
(!Array.isArray(policy.retryOn) ||
|
|
1371
|
+
policy.retryOn.some((matcher) => typeof matcher !== "function"))
|
|
1372
|
+
) {
|
|
1373
|
+
throw new Error(
|
|
1374
|
+
`${label} retryOn must be an error class, a predicate, or an array of them.`,
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
if (
|
|
1378
|
+
policy.initialDelayMs !== undefined &&
|
|
1379
|
+
policy.maxDelayMs !== undefined &&
|
|
1380
|
+
policy.maxDelayMs < policy.initialDelayMs
|
|
1381
|
+
) {
|
|
1382
|
+
throw new Error(`${label} maxDelayMs must be at least initialDelayMs.`);
|
|
1383
|
+
}
|
|
1384
|
+
if (
|
|
1385
|
+
policy.backoffMultiplier !== undefined &&
|
|
1386
|
+
(!Number.isFinite(policy.backoffMultiplier) || policy.backoffMultiplier < 1)
|
|
1387
|
+
) {
|
|
1388
|
+
throw new Error(`${label} backoffMultiplier must be at least one.`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function validateWorkflowStepOptions(options: WorkflowStepOptions | undefined): void {
|
|
1393
|
+
if (options?.timeoutMs !== undefined) {
|
|
1394
|
+
assertPositiveFiniteNumber(options.timeoutMs, "Workflow step timeoutMs");
|
|
1395
|
+
}
|
|
1396
|
+
validateWorkflowRetryPolicy(options?.retry, "Workflow step retry policy");
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function assertAtMost(value: number, max: number, label: string): void {
|
|
1400
|
+
if (value > max) {
|
|
1401
|
+
throw new Error(`${label} must be at most ${max}.`);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
function isObject(value: unknown): value is object {
|
|
1406
|
+
return (typeof value === "object" && value !== null) || typeof value === "function";
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function assertPositiveFiniteNumber(value: number, label: string): void {
|
|
1410
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1411
|
+
throw new Error(`${label} must be greater than zero.`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function assertNonNegativeFiniteNumber(value: number, label: string): void {
|
|
1416
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1417
|
+
throw new Error(`${label} must be zero or greater.`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1038
1421
|
function createWorkflowStepKey(segments: readonly unknown[]): string {
|
|
1039
1422
|
if (segments.length === 0) {
|
|
1040
1423
|
throw new Error("Workflow step keys must contain at least one segment.");
|