@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/error.ts
CHANGED
|
@@ -11,6 +11,31 @@ export class ExecutionError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** Explicitly permit another workflow or step attempt. */
|
|
15
|
+
export class RetryableError extends Error {
|
|
16
|
+
readonly retryAfterMs: number | undefined;
|
|
17
|
+
|
|
18
|
+
constructor(message: string, options?: { retryAfterMs?: number }) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "RetryableError";
|
|
21
|
+
if (
|
|
22
|
+
options?.retryAfterMs !== undefined &&
|
|
23
|
+
(!Number.isFinite(options.retryAfterMs) || options.retryAfterMs < 0)
|
|
24
|
+
) {
|
|
25
|
+
throw new Error("RetryableError retryAfterMs must be zero or greater.");
|
|
26
|
+
}
|
|
27
|
+
this.retryAfterMs = options?.retryAfterMs;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Skip remaining workflow or step attempts. */
|
|
32
|
+
export class FatalError extends Error {
|
|
33
|
+
constructor(message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "FatalError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
14
39
|
/** Tell the platform that an external service asked the sync to slow down. */
|
|
15
40
|
export class RateLimitError extends ExecutionError {
|
|
16
41
|
readonly retryAfter: number | undefined;
|
package/src/workflow.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi, type Mock } from "vitest";
|
|
7
7
|
|
|
8
8
|
import { j } from "./schema-builder.js";
|
|
9
|
-
import { ExecutionError } from "./error.js";
|
|
9
|
+
import { ExecutionError, FatalError, RateLimitError, RetryableError } from "./error.js";
|
|
10
10
|
import type { RunMetadata } from "./runtime-metadata.js";
|
|
11
11
|
import { events, type NotionPageCreatedEvent, type WebhookEvent } from "./events.generated.js";
|
|
12
12
|
import { customAgent } from "./notion-as-code/custom-agent.js";
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type WebhookVerifyHandler,
|
|
22
22
|
type WorkflowContext,
|
|
23
23
|
type WorkflowInputDate,
|
|
24
|
+
type WorkflowRetryPolicy,
|
|
24
25
|
type WorkflowWaitDuration,
|
|
25
26
|
} from "./workflow.js";
|
|
26
27
|
|
|
@@ -124,12 +125,15 @@ describe("manual workflow triggers", () => {
|
|
|
124
125
|
});
|
|
125
126
|
});
|
|
126
127
|
|
|
127
|
-
async function createWorkflowContext(
|
|
128
|
+
async function createWorkflowContext(
|
|
129
|
+
retry?: WorkflowRetryPolicy,
|
|
130
|
+
): Promise<WorkflowContext<Record<never, never>>> {
|
|
128
131
|
let context: WorkflowContext<Record<never, never>> | undefined;
|
|
129
132
|
const definition = workflow({
|
|
130
133
|
name: "Test workflow",
|
|
131
134
|
description: "Captures its workflow context",
|
|
132
135
|
triggers: [events.notionPageCreated()],
|
|
136
|
+
...(retry === undefined ? {} : { retry }),
|
|
133
137
|
handler: (_event, workflowContext) => {
|
|
134
138
|
context = workflowContext;
|
|
135
139
|
},
|
|
@@ -249,6 +253,41 @@ describe("step", () => {
|
|
|
249
253
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
250
254
|
});
|
|
251
255
|
|
|
256
|
+
it("isolates buffered state between step retry attempts", async () => {
|
|
257
|
+
process.env.NOTION_API_BASE_URL = "https://api.example.com";
|
|
258
|
+
process.env.NOTION_API_TOKEN = "workflow-token";
|
|
259
|
+
const fetchMock = vi.fn<typeof fetch>().mockImplementation(async (_input, init) => {
|
|
260
|
+
const body = JSON.parse(String(init?.body)) as { operation: string };
|
|
261
|
+
return new Response(
|
|
262
|
+
JSON.stringify(body.operation === "get" ? { exists: false } : { success: true }),
|
|
263
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
267
|
+
const context = await createWorkflowContext();
|
|
268
|
+
const seen: unknown[] = [];
|
|
269
|
+
let attempts = 0;
|
|
270
|
+
|
|
271
|
+
await context.step("Retry with state", { retry: { maxAttempts: 2 } }, async ({ state }) => {
|
|
272
|
+
attempts += 1;
|
|
273
|
+
seen.push(await state.get("failed-attempt"));
|
|
274
|
+
if (attempts === 1) {
|
|
275
|
+
state.set("failed-attempt", true);
|
|
276
|
+
throw new RetryableError("try again");
|
|
277
|
+
}
|
|
278
|
+
state.set("succeeded", true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(seen).toEqual([undefined, undefined]);
|
|
282
|
+
const commits = fetchMock.mock.calls
|
|
283
|
+
.map(([, init]) => String(init?.body))
|
|
284
|
+
.filter((body) => body.includes('"operation":"commit"'));
|
|
285
|
+
expect(commits).toHaveLength(1);
|
|
286
|
+
expect(commits[0]).toContain(
|
|
287
|
+
'"mutations":[{"operation":"set","key":"succeeded","value":true}]',
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
252
291
|
it("reads buffered state before the step commits", async () => {
|
|
253
292
|
process.env.NOTION_API_BASE_URL = "https://api.example.com";
|
|
254
293
|
process.env.NOTION_API_TOKEN = "workflow-token";
|
|
@@ -522,7 +561,9 @@ describe("step", () => {
|
|
|
522
561
|
throw originalError;
|
|
523
562
|
});
|
|
524
563
|
|
|
525
|
-
await expect(step("Explode", fn)).rejects.toBe(
|
|
564
|
+
await expect(step("Explode", { retry: { maxAttempts: 1 } }, fn)).rejects.toBe(
|
|
565
|
+
originalError,
|
|
566
|
+
);
|
|
526
567
|
|
|
527
568
|
expect(fn).toHaveBeenCalledOnce();
|
|
528
569
|
expect(stdoutSpy).toHaveBeenCalledTimes(3);
|
|
@@ -908,6 +949,244 @@ describe("step", () => {
|
|
|
908
949
|
expect(keys).toEqual([workflowStepKey(["First"]), workflowStepKey(["First"])]);
|
|
909
950
|
});
|
|
910
951
|
|
|
952
|
+
it("retries a failed callback using the step policy", async () => {
|
|
953
|
+
const { step } = await createWorkflowContext();
|
|
954
|
+
const callback = vi
|
|
955
|
+
.fn<() => string>()
|
|
956
|
+
.mockRejectedValueOnce(new Error("temporary"))
|
|
957
|
+
.mockReturnValue("done");
|
|
958
|
+
|
|
959
|
+
await expect(
|
|
960
|
+
step("Retry request", { retry: { maxAttempts: 2, initialDelayMs: 0 } }, callback),
|
|
961
|
+
).resolves.toBe("done");
|
|
962
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("uses the workflow retry policy for steps without their own", async () => {
|
|
966
|
+
const { step } = await createWorkflowContext({ maxAttempts: 2, initialDelayMs: 0 });
|
|
967
|
+
const callback = vi.fn<() => string>().mockRejectedValue(new Error("down"));
|
|
968
|
+
|
|
969
|
+
await expect(step("Inherit policy", callback)).rejects.toThrow("down");
|
|
970
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it("replaces the workflow retry policy with the step policy", async () => {
|
|
974
|
+
const { step } = await createWorkflowContext({ maxAttempts: 3, initialDelayMs: 0 });
|
|
975
|
+
const callback = vi.fn<() => string>().mockRejectedValue(new Error("down"));
|
|
976
|
+
|
|
977
|
+
await expect(
|
|
978
|
+
step("Override policy", { retry: { maxAttempts: 1 } }, callback),
|
|
979
|
+
).rejects.toThrow("down");
|
|
980
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it("makes three attempts with backoff when no policy is configured", async () => {
|
|
984
|
+
vi.useFakeTimers();
|
|
985
|
+
try {
|
|
986
|
+
const { step } = await createWorkflowContext();
|
|
987
|
+
const callback = vi.fn<() => string>().mockRejectedValue(new Error("down"));
|
|
988
|
+
|
|
989
|
+
const result = step("Default policy", callback);
|
|
990
|
+
const assertion = expect(result).rejects.toThrow("down");
|
|
991
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
992
|
+
await assertion;
|
|
993
|
+
expect(callback).toHaveBeenCalledTimes(3);
|
|
994
|
+
} finally {
|
|
995
|
+
vi.useRealTimers();
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("retries only errors matched by retryOn", async () => {
|
|
1000
|
+
class TransientError extends Error {}
|
|
1001
|
+
const { step } = await createWorkflowContext({
|
|
1002
|
+
maxAttempts: 3,
|
|
1003
|
+
initialDelayMs: 0,
|
|
1004
|
+
retryOn: [TransientError, (error) => error instanceof Error && error.message === "429"],
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
const transient = vi.fn<() => string>().mockRejectedValue(new TransientError("blip"));
|
|
1008
|
+
await expect(step("Class match", transient)).rejects.toThrow("blip");
|
|
1009
|
+
expect(transient).toHaveBeenCalledTimes(3);
|
|
1010
|
+
|
|
1011
|
+
const predicate = vi.fn<() => string>().mockRejectedValue(new Error("429"));
|
|
1012
|
+
await expect(step("Predicate match", predicate)).rejects.toThrow("429");
|
|
1013
|
+
expect(predicate).toHaveBeenCalledTimes(3);
|
|
1014
|
+
|
|
1015
|
+
const other = vi.fn<() => string>().mockRejectedValue(new TypeError("bug"));
|
|
1016
|
+
await expect(step("No match", other)).rejects.toThrow("bug");
|
|
1017
|
+
expect(other).toHaveBeenCalledOnce();
|
|
1018
|
+
|
|
1019
|
+
const explicit = vi.fn<() => string>().mockRejectedValue(new RetryableError("again"));
|
|
1020
|
+
await expect(step("Explicit retryable", explicit)).rejects.toThrow("again");
|
|
1021
|
+
expect(explicit).toHaveBeenCalledTimes(3);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("accepts a single retryOn matcher", async () => {
|
|
1025
|
+
class TransientError extends Error {}
|
|
1026
|
+
const { step } = await createWorkflowContext({
|
|
1027
|
+
maxAttempts: 2,
|
|
1028
|
+
initialDelayMs: 0,
|
|
1029
|
+
retryOn: TransientError,
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
const transient = vi.fn<() => string>().mockRejectedValue(new TransientError("blip"));
|
|
1033
|
+
await expect(step("Class match", transient)).rejects.toThrow("blip");
|
|
1034
|
+
expect(transient).toHaveBeenCalledTimes(2);
|
|
1035
|
+
|
|
1036
|
+
const other = vi.fn<() => string>().mockRejectedValue(new TypeError("bug"));
|
|
1037
|
+
await expect(step("No match", other)).rejects.toThrow("bug");
|
|
1038
|
+
expect(other).toHaveBeenCalledOnce();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it("never retries a FatalError matched by retryOn", async () => {
|
|
1042
|
+
const { step } = await createWorkflowContext({
|
|
1043
|
+
maxAttempts: 3,
|
|
1044
|
+
initialDelayMs: 0,
|
|
1045
|
+
retryOn: [Error],
|
|
1046
|
+
});
|
|
1047
|
+
const callback = vi.fn<() => string>().mockRejectedValue(new FatalError("stop"));
|
|
1048
|
+
|
|
1049
|
+
await expect(step("Fatal", callback)).rejects.toThrow("stop");
|
|
1050
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("rejects step retry policies that cannot fit in one invocation", async () => {
|
|
1054
|
+
const { step } = await createWorkflowContext();
|
|
1055
|
+
const callback = vi.fn(() => "done");
|
|
1056
|
+
|
|
1057
|
+
await expect(
|
|
1058
|
+
step("Slow retries", { retry: { maxAttempts: 3, initialDelayMs: 200_000 } }, callback),
|
|
1059
|
+
).rejects.toThrow(
|
|
1060
|
+
'Workflow step "Slow retries" can spend up to 500000ms on retry delays and timeouts, which exceeds the 300000ms limit',
|
|
1061
|
+
);
|
|
1062
|
+
await expect(
|
|
1063
|
+
step("Long timeout", { timeoutMs: 120_000, retry: { maxAttempts: 3 } }, callback),
|
|
1064
|
+
).rejects.toThrow('Workflow step "Long timeout" can spend up to 363000ms');
|
|
1065
|
+
expect(callback).not.toHaveBeenCalled();
|
|
1066
|
+
expect(() =>
|
|
1067
|
+
workflow({
|
|
1068
|
+
name: "Slow workflow",
|
|
1069
|
+
description: "Its retry policy is the default for every step",
|
|
1070
|
+
triggers: [events.notionPageCreated()],
|
|
1071
|
+
retry: { maxAttempts: 3, initialDelayMs: 200_000 },
|
|
1072
|
+
handler: () => {},
|
|
1073
|
+
}),
|
|
1074
|
+
).toThrow("Workflow retry policy can spend up to 500000ms");
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it("leaves a retry that no longer fits in the invocation to the run", async () => {
|
|
1078
|
+
const definition = workflow({
|
|
1079
|
+
name: "Deferred retry",
|
|
1080
|
+
description: "Hands a long explicit delay to the platform",
|
|
1081
|
+
triggers: [events.notionPageCreated()],
|
|
1082
|
+
handler: async (_event, context) => {
|
|
1083
|
+
await context.step("Wait for service", () => {
|
|
1084
|
+
throw new RetryableError("busy", { retryAfterMs: 10 * 60 * 1000 });
|
|
1085
|
+
});
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
await expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
|
|
1090
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(
|
|
1091
|
+
expect.stringContaining(
|
|
1092
|
+
`<__notion_output__>{"_tag":"error","error":{"_tag":"retryable","name":"RetryableError","message":"busy"`,
|
|
1093
|
+
),
|
|
1094
|
+
);
|
|
1095
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(
|
|
1096
|
+
expect.stringContaining(`"retryAfterMs":600000`),
|
|
1097
|
+
);
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it("counts earlier work in the invocation against the step retry budget", async () => {
|
|
1101
|
+
vi.useFakeTimers();
|
|
1102
|
+
try {
|
|
1103
|
+
const callback = vi.fn(() => {
|
|
1104
|
+
throw new RetryableError("busy", { retryAfterMs: 60_000 });
|
|
1105
|
+
});
|
|
1106
|
+
const definition = workflow({
|
|
1107
|
+
name: "Late retry",
|
|
1108
|
+
description: "Fails a step near the end of the invocation",
|
|
1109
|
+
triggers: [events.notionPageCreated()],
|
|
1110
|
+
handler: async (_event, context) => {
|
|
1111
|
+
await new Promise((resolve) => setTimeout(resolve, 4.5 * 60 * 1000));
|
|
1112
|
+
await context.step("Call service", callback);
|
|
1113
|
+
},
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
const result = expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
|
|
1117
|
+
await vi.runAllTimersAsync();
|
|
1118
|
+
await result;
|
|
1119
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
1120
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(
|
|
1121
|
+
expect.stringContaining(`"retryAfterMs":60000`),
|
|
1122
|
+
);
|
|
1123
|
+
} finally {
|
|
1124
|
+
vi.useRealTimers();
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("writes a rate limit envelope for a RateLimitError", async () => {
|
|
1129
|
+
const definition = workflow({
|
|
1130
|
+
name: "Rate limited",
|
|
1131
|
+
description: "Reports the service's retry delay",
|
|
1132
|
+
triggers: [events.notionPageCreated()],
|
|
1133
|
+
handler: () => {
|
|
1134
|
+
throw new RateLimitError({ retryAfter: 30 });
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
await expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
|
|
1139
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(
|
|
1140
|
+
expect.stringContaining(
|
|
1141
|
+
`{"_tag":"error","error":{"_tag":"rate_limit","name":"RateLimitError"`,
|
|
1142
|
+
),
|
|
1143
|
+
);
|
|
1144
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(expect.stringContaining(`"retryAfter":30`));
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
it("waits for the delay requested by a RateLimitError", async () => {
|
|
1148
|
+
vi.useFakeTimers();
|
|
1149
|
+
try {
|
|
1150
|
+
const { step } = await createWorkflowContext();
|
|
1151
|
+
const callback = vi
|
|
1152
|
+
.fn<() => string>()
|
|
1153
|
+
.mockRejectedValueOnce(new RateLimitError({ retryAfter: 30 }))
|
|
1154
|
+
.mockReturnValue("done");
|
|
1155
|
+
|
|
1156
|
+
const result = step("Rate limited", { retry: { maxAttempts: 2 } }, callback);
|
|
1157
|
+
await vi.advanceTimersByTimeAsync(29_000);
|
|
1158
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
1159
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
1160
|
+
await expect(result).resolves.toBe("done");
|
|
1161
|
+
} finally {
|
|
1162
|
+
vi.useRealTimers();
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
it("does not retry a FatalError", async () => {
|
|
1167
|
+
const { step } = await createWorkflowContext();
|
|
1168
|
+
const error = new FatalError("invalid input");
|
|
1169
|
+
const callback = vi.fn(() => {
|
|
1170
|
+
throw error;
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
await expect(step("Validate input", { retry: { maxAttempts: 3 } }, callback)).rejects.toBe(
|
|
1174
|
+
error,
|
|
1175
|
+
);
|
|
1176
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
it("times out a step callback", async () => {
|
|
1180
|
+
const { step } = await createWorkflowContext();
|
|
1181
|
+
|
|
1182
|
+
await expect(
|
|
1183
|
+
step("Wait forever", { timeoutMs: 1 }, () => new Promise(() => {})),
|
|
1184
|
+
).rejects.toMatchObject({
|
|
1185
|
+
name: "StepTimeoutError",
|
|
1186
|
+
message: "Workflow step timed out after 1ms.",
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
|
|
911
1190
|
it("writes step events under concreteOutput", async () => {
|
|
912
1191
|
const definition = workflow({
|
|
913
1192
|
name: "Log Pages",
|
|
@@ -1350,6 +1629,74 @@ describe("workflow", () => {
|
|
|
1350
1629
|
});
|
|
1351
1630
|
});
|
|
1352
1631
|
|
|
1632
|
+
it("exposes deadline and retry configuration", () => {
|
|
1633
|
+
const deadline = { afterMs: 60_000 };
|
|
1634
|
+
const retry = {
|
|
1635
|
+
maxAttempts: 3,
|
|
1636
|
+
initialDelayMs: 1_000,
|
|
1637
|
+
maxDelayMs: 30_000,
|
|
1638
|
+
backoffMultiplier: 2,
|
|
1639
|
+
};
|
|
1640
|
+
const definition = workflow({
|
|
1641
|
+
name: "Reliable workflow",
|
|
1642
|
+
description: "Configures failure handling",
|
|
1643
|
+
triggers: [events.notionPageCreated()],
|
|
1644
|
+
deadline,
|
|
1645
|
+
retry,
|
|
1646
|
+
handler: () => {},
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
expect(definition.config.deadline).toEqual(deadline);
|
|
1650
|
+
expect(definition.config.retry).toEqual(retry);
|
|
1651
|
+
expect(
|
|
1652
|
+
workflow({
|
|
1653
|
+
name: "Classified workflow",
|
|
1654
|
+
description: "Keeps error matchers in the SDK",
|
|
1655
|
+
triggers: [events.notionPageCreated()],
|
|
1656
|
+
retry: { ...retry, retryOn: [TypeError] },
|
|
1657
|
+
handler: () => {},
|
|
1658
|
+
}).config.retry,
|
|
1659
|
+
).toEqual(retry);
|
|
1660
|
+
expect(definition.config.deadline).not.toBe(deadline);
|
|
1661
|
+
expect(definition.config.retry).not.toBe(retry);
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
it("rejects invalid workflow reliability configuration", () => {
|
|
1665
|
+
expect(() =>
|
|
1666
|
+
workflow({
|
|
1667
|
+
name: "Invalid workflow",
|
|
1668
|
+
description: "Has an invalid retry budget",
|
|
1669
|
+
triggers: [events.notionPageCreated()],
|
|
1670
|
+
deadline: { afterMs: 0 },
|
|
1671
|
+
retry: { maxAttempts: 0 },
|
|
1672
|
+
handler: () => {},
|
|
1673
|
+
}),
|
|
1674
|
+
).toThrow("Workflow deadline afterMs must be greater than zero.");
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
it("rejects reliability configuration beyond platform limits", () => {
|
|
1678
|
+
const define = (config: { deadline?: { afterMs: number }; retry?: WorkflowRetryPolicy }) =>
|
|
1679
|
+
workflow({
|
|
1680
|
+
name: "Limited workflow",
|
|
1681
|
+
description: "Exceeds platform limits",
|
|
1682
|
+
triggers: [events.notionPageCreated()],
|
|
1683
|
+
...config,
|
|
1684
|
+
handler: () => {},
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
expect(() => define({ retry: { maxAttempts: 4 } })).toThrow(
|
|
1688
|
+
"Workflow retry policy maxAttempts must be an integer between 1 and 3.",
|
|
1689
|
+
);
|
|
1690
|
+
expect(() => define({ deadline: { afterMs: 8 * 24 * 60 * 60 * 1000 } })).toThrow(
|
|
1691
|
+
"Workflow deadline afterMs must be at most 604800000.",
|
|
1692
|
+
);
|
|
1693
|
+
expect(() =>
|
|
1694
|
+
define({ retry: { maxAttempts: 2, retryOn: ["TypeError"] as unknown as [] } }),
|
|
1695
|
+
).toThrow(
|
|
1696
|
+
"Workflow retry policy retryOn must be an error class, a predicate, or an array of them.",
|
|
1697
|
+
);
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1353
1700
|
it("declares a Calendar connection requirement", () => {
|
|
1354
1701
|
const definition = workflow({
|
|
1355
1702
|
name: "Schedule Follow-up",
|
|
@@ -1457,6 +1804,81 @@ describe("workflow", () => {
|
|
|
1457
1804
|
expect(stdoutSpy).not.toHaveBeenCalled();
|
|
1458
1805
|
});
|
|
1459
1806
|
|
|
1807
|
+
it("writes retryable and terminal error envelopes", async () => {
|
|
1808
|
+
const retryable = workflow({
|
|
1809
|
+
name: "Retryable workflow",
|
|
1810
|
+
description: "Classifies temporary failures",
|
|
1811
|
+
triggers: [events.notionPageCreated()],
|
|
1812
|
+
handler: () => {
|
|
1813
|
+
throw new RetryableError("try later", { retryAfterMs: 5_000 });
|
|
1814
|
+
},
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
await expect(retryable.handler(event)).rejects.toBeInstanceOf(ExecutionError);
|
|
1818
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(
|
|
1819
|
+
expect.stringContaining(
|
|
1820
|
+
`<__notion_output__>{"_tag":"error","error":{"_tag":"retryable","name":"RetryableError","message":"try later"`,
|
|
1821
|
+
),
|
|
1822
|
+
);
|
|
1823
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(expect.stringContaining(`"retryAfterMs":5000`));
|
|
1824
|
+
|
|
1825
|
+
stdoutSpy.mockClear();
|
|
1826
|
+
const fatal = workflow({
|
|
1827
|
+
name: "Fatal workflow",
|
|
1828
|
+
description: "Classifies permanent failures",
|
|
1829
|
+
triggers: [events.notionPageCreated()],
|
|
1830
|
+
handler: () => {
|
|
1831
|
+
throw new FatalError("stop now");
|
|
1832
|
+
},
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
await expect(fatal.handler(event)).rejects.toBeInstanceOf(ExecutionError);
|
|
1836
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(
|
|
1837
|
+
expect.stringContaining(
|
|
1838
|
+
`<__notion_output__>{"_tag":"error","error":{"_tag":"terminal","name":"FatalError","message":"stop now"`,
|
|
1839
|
+
),
|
|
1840
|
+
);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
it("reports a step failure that used its retries as terminal", async () => {
|
|
1844
|
+
const definition = workflow({
|
|
1845
|
+
name: "Step failure",
|
|
1846
|
+
description: "Does not retry the run after a step exhausts its retries",
|
|
1847
|
+
triggers: [events.notionPageCreated()],
|
|
1848
|
+
handler: async (_event, context) => {
|
|
1849
|
+
await context.step("Fail", { retry: { maxAttempts: 1 } }, () => {
|
|
1850
|
+
throw new Error("step down");
|
|
1851
|
+
});
|
|
1852
|
+
},
|
|
1853
|
+
});
|
|
1854
|
+
|
|
1855
|
+
await expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
|
|
1856
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(
|
|
1857
|
+
expect.stringContaining(
|
|
1858
|
+
`<__notion_output__>{"_tag":"error","error":{"_tag":"terminal","name":"Error","message":"step down"`,
|
|
1859
|
+
),
|
|
1860
|
+
);
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
it("reports failures outside steps not matched by the workflow retryOn as terminal", async () => {
|
|
1864
|
+
const definition = workflow({
|
|
1865
|
+
name: "Classified failure",
|
|
1866
|
+
description: "Applies retryOn to failures outside steps",
|
|
1867
|
+
triggers: [events.notionPageCreated()],
|
|
1868
|
+
retry: { maxAttempts: 2, retryOn: RangeError },
|
|
1869
|
+
handler: () => {
|
|
1870
|
+
throw new TypeError("bug");
|
|
1871
|
+
},
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
await expect(definition.handler(event)).rejects.toBeInstanceOf(ExecutionError);
|
|
1875
|
+
expect(stdoutSpy).toHaveBeenLastCalledWith(
|
|
1876
|
+
expect.stringContaining(
|
|
1877
|
+
`<__notion_output__>{"_tag":"error","error":{"_tag":"terminal","name":"TypeError","message":"bug"`,
|
|
1878
|
+
),
|
|
1879
|
+
);
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1460
1882
|
it("writes an error envelope and throws ExecutionError on failure", async () => {
|
|
1461
1883
|
const definition = workflow({
|
|
1462
1884
|
name: "Log Pages",
|