@notionhq/apps 0.0.40 → 0.0.42
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/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +26 -2
- 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/cli/build.test.ts +37 -0
- package/src/cli/build.ts +30 -2
- package/src/error.ts +25 -0
- package/src/workflow.test.ts +425 -3
- package/src/workflow.ts +398 -15
package/skills/workflow/SKILL.md
CHANGED
|
@@ -99,6 +99,52 @@ await context.step("Process page", { key: ["process-page", page.id] }, async ({
|
|
|
99
99
|
|
|
100
100
|
Keys must be stable across retries and unique within one workflow run.
|
|
101
101
|
|
|
102
|
+
Configure a workflow deadline and retry policy only when the platform defaults do
|
|
103
|
+
not fit the workflow:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
export default workflow({
|
|
107
|
+
name: "Charge customer",
|
|
108
|
+
description: "Charges a customer when an invoice is created",
|
|
109
|
+
triggers: [events.notionPageCreated()],
|
|
110
|
+
deadline: { afterMs: 7 * 24 * 60 * 60 * 1000 },
|
|
111
|
+
retry: {
|
|
112
|
+
maxAttempts: 3,
|
|
113
|
+
initialDelayMs: 5_000,
|
|
114
|
+
maxDelayMs: 5 * 60 * 1000,
|
|
115
|
+
backoffMultiplier: 2,
|
|
116
|
+
},
|
|
117
|
+
handler: async (_event, context) => {
|
|
118
|
+
await context.step(
|
|
119
|
+
"Charge customer",
|
|
120
|
+
{ timeoutMs: 30_000, retry: { maxAttempts: 2 } },
|
|
121
|
+
chargeCustomer,
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The workflow `retry` policy is the default for every step; a step's `retry`
|
|
128
|
+
replaces it rather than stacking. The run itself is retried only for failures
|
|
129
|
+
outside a step. A step that fails after using its retries fails the run.
|
|
130
|
+
|
|
131
|
+
`maxAttempts` includes the initial attempt and is at most 4. Without any policy,
|
|
132
|
+
steps make 3 attempts with jittered backoff starting at 1 second, doubling, and
|
|
133
|
+
capped at 5 minutes. A step retries ordinary errors and `RetryableError`, but
|
|
134
|
+
stops immediately for `FatalError`. Set `retryOn` to an error class, a predicate,
|
|
135
|
+
or an array of them to retry only matching errors, for example
|
|
136
|
+
`retryOn: TimeoutError` or `retryOn: [TimeoutError, RateLimitError]`. Pass
|
|
137
|
+
`retryAfterMs` to `RetryableError` when the failing service provides an explicit
|
|
138
|
+
delay. Workflow deadlines include retry delays and are at most 7 days, and step
|
|
139
|
+
timeouts apply to one callback attempt.
|
|
140
|
+
|
|
141
|
+
Step retries wait inside one workflow invocation, which runs for at most 5
|
|
142
|
+
minutes. The longest possible retry delays plus `timeoutMs` for every attempt
|
|
143
|
+
must fit in 5 minutes, for step policies and for the workflow `retry` policy
|
|
144
|
+
that steps inherit. Earlier work in the invocation counts against the same 5
|
|
145
|
+
minutes; a retry that no longer fits is left to a run retry. Use
|
|
146
|
+
`context.wait.until()` for longer waits.
|
|
147
|
+
|
|
102
148
|
An external effect can succeed before its step result is saved. Pass the
|
|
103
149
|
callback `id` as an idempotency key when supported; otherwise use a stable
|
|
104
150
|
external ID, upsert, or duplicate check.
|
package/src/cli/build.test.ts
CHANGED
|
@@ -456,6 +456,43 @@ describe("buildApp", () => {
|
|
|
456
456
|
});
|
|
457
457
|
});
|
|
458
458
|
|
|
459
|
+
it("emits APP.md as a private page with stable identity and resource references", async () => {
|
|
460
|
+
const root = await fs.promises.mkdtemp(path.join(FIXTURES, "app-md-"));
|
|
461
|
+
try {
|
|
462
|
+
await fs.promises.cp(
|
|
463
|
+
path.join(FIXTURES, "notion-as-code", "src"),
|
|
464
|
+
path.join(root, "src"),
|
|
465
|
+
{
|
|
466
|
+
recursive: true,
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
const markdown =
|
|
470
|
+
'# App guide\n\nSee <mention-page url="{{github-app-private-page}}">Home</mention-page>.\n';
|
|
471
|
+
await fs.promises.writeFile(path.join(root, "APP.md"), markdown);
|
|
472
|
+
await buildApp(root);
|
|
473
|
+
const provisioningPath = path.join(root, "dist", "provisioning.json");
|
|
474
|
+
const provisioning = JSON.parse(await fs.promises.readFile(provisioningPath, "utf8"));
|
|
475
|
+
expect(provisioning.intents.at(-1)).toEqual({
|
|
476
|
+
type: "page",
|
|
477
|
+
resourceId: "__notion_apps_app_md__",
|
|
478
|
+
parent: { type: "resourceId", resourceId: "__notion_apps_workspace__" },
|
|
479
|
+
properties: { title: [["APP.md"]] },
|
|
480
|
+
content: markdown,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
await fs.promises.rm(path.join(root, "APP.md"));
|
|
484
|
+
await buildApp(root);
|
|
485
|
+
const withoutAppPage = JSON.parse(await fs.promises.readFile(provisioningPath, "utf8"));
|
|
486
|
+
expect(withoutAppPage.intents).not.toEqual(
|
|
487
|
+
expect.arrayContaining([
|
|
488
|
+
expect.objectContaining({ resourceId: "__notion_apps_app_md__" }),
|
|
489
|
+
]),
|
|
490
|
+
);
|
|
491
|
+
} finally {
|
|
492
|
+
await fs.promises.rm(root, { recursive: true, force: true });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
459
496
|
it("removes provisioning when a rebuild no longer declares Notion resources", async () => {
|
|
460
497
|
const root = await fs.promises.mkdtemp(path.join(FIXTURES, "provisioning-rebuild-"));
|
|
461
498
|
try {
|
package/src/cli/build.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { rolldown } from "rolldown";
|
|
5
5
|
|
|
6
6
|
import type { AppManifest } from "../manifest.js";
|
|
7
|
+
import { APPS_WORKSPACE_RESOURCE_ID, type PageIntent } from "../notion-as-code/intents.js";
|
|
7
8
|
import {
|
|
8
9
|
createProvisioningArtifact,
|
|
9
10
|
type RecordedProvisioning,
|
|
@@ -13,6 +14,8 @@ import { generateEntry, generateMetadataEntry, generateBlocksEntry } from "./cod
|
|
|
13
14
|
import { discoverCapabilities, type DiscoveredCapability } from "./discover.js";
|
|
14
15
|
import { extractManifest, extractProvisioning, extractBlockConfigs } from "./emit-manifest.js";
|
|
15
16
|
|
|
17
|
+
const APP_MD_RESOURCE_ID = "__notion_apps_app_md__";
|
|
18
|
+
|
|
16
19
|
export type BuildResult = {
|
|
17
20
|
manifest: AppManifest;
|
|
18
21
|
bundlePath: string;
|
|
@@ -75,15 +78,40 @@ async function emitProvisioningArtifact(
|
|
|
75
78
|
entityProvisioning: RecordedProvisioning,
|
|
76
79
|
): Promise<void> {
|
|
77
80
|
const provisioningPath = path.join(projectRoot, "dist", "provisioning.json");
|
|
78
|
-
|
|
81
|
+
const appPage = await readAppPage(projectRoot);
|
|
82
|
+
if (entityProvisioning.intents.length === 0 && !appPage) {
|
|
79
83
|
await fs.promises.rm(provisioningPath, { force: true });
|
|
80
84
|
return;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
const artifact = createProvisioningArtifact(
|
|
87
|
+
const artifact = createProvisioningArtifact([
|
|
88
|
+
...entityProvisioning.intents,
|
|
89
|
+
...(appPage ? [appPage] : []),
|
|
90
|
+
]);
|
|
84
91
|
await fs.promises.writeFile(provisioningPath, `${JSON.stringify(artifact, null, "\t")}\n`);
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
async function readAppPage(
|
|
95
|
+
projectRoot: string,
|
|
96
|
+
): Promise<({ type: "page" } & PageIntent) | undefined> {
|
|
97
|
+
let content: string;
|
|
98
|
+
try {
|
|
99
|
+
content = await fs.promises.readFile(path.join(projectRoot, "APP.md"), "utf8");
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
type: "page",
|
|
108
|
+
resourceId: APP_MD_RESOURCE_ID,
|
|
109
|
+
parent: { type: "resourceId", resourceId: APPS_WORKSPACE_RESOURCE_ID },
|
|
110
|
+
properties: { title: [["APP.md"]] },
|
|
111
|
+
content,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
87
115
|
/** Bundle capabilities separately to record their Notion-as-Code declarations without shipping them. */
|
|
88
116
|
async function buildMetadata(
|
|
89
117
|
projectRoot: string,
|
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",
|