@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/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;
@@ -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(): Promise<WorkflowContext<Record<never, never>>> {
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(originalError);
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",