@agent-native/scheduling 0.2.52 → 0.2.53-nightly-20261105200106
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/dist/server/availability-engine.d.ts +24 -0
- package/dist/server/availability-engine.d.ts.map +1 -1
- package/dist/server/availability-engine.js +73 -5
- package/dist/server/availability-engine.js.map +1 -1
- package/dist/server/providers/errors.d.ts +22 -0
- package/dist/server/providers/errors.d.ts.map +1 -0
- package/dist/server/providers/errors.js +53 -0
- package/dist/server/providers/errors.js.map +1 -0
- package/dist/server/providers/google-calendar.d.ts +0 -11
- package/dist/server/providers/google-calendar.d.ts.map +1 -1
- package/dist/server/providers/google-calendar.js +57 -3
- package/dist/server/providers/google-calendar.js.map +1 -1
- package/dist/server/providers/index.d.ts +1 -0
- package/dist/server/providers/index.d.ts.map +1 -1
- package/dist/server/providers/index.js +1 -0
- package/dist/server/providers/index.js.map +1 -1
- package/dist/server/providers/office365.d.ts.map +1 -1
- package/dist/server/providers/office365.js +87 -25
- package/dist/server/providers/office365.js.map +1 -1
- package/docs/llms-full.txt +29 -8
- package/docs/providers.md +10 -0
- package/docs/skills/integrations/SKILL.md +19 -8
- package/package.json +4 -4
- package/src/server/availability-engine.test.ts +459 -23
- package/src/server/availability-engine.ts +110 -10
- package/src/server/providers/calendar-busy.test.ts +417 -0
- package/src/server/providers/errors.ts +66 -0
- package/src/server/providers/google-calendar.ts +71 -4
- package/src/server/providers/index.ts +1 -0
- package/src/server/providers/office365.ts +111 -33
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CalendarCredentialInvalidError,
|
|
5
|
+
CalendarLookupRateLimitedError,
|
|
6
|
+
} from "./errors.js";
|
|
7
|
+
import { createGoogleCalendarProvider } from "./google-calendar.js";
|
|
8
|
+
import { createOffice365Provider } from "./office365.js";
|
|
9
|
+
|
|
10
|
+
const START = new Date("2030-01-07T00:00:00.000Z");
|
|
11
|
+
const END = new Date("2030-01-08T00:00:00.000Z");
|
|
12
|
+
const OUTLOOK_CALENDAR_ID = "AAMkAGI2TGuLAAA=";
|
|
13
|
+
|
|
14
|
+
function respondWith(...bodies: unknown[]) {
|
|
15
|
+
const fetchMock = vi.fn(async () => {
|
|
16
|
+
const body = bodies.length > 1 ? bodies.shift() : bodies[0];
|
|
17
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
18
|
+
});
|
|
19
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
20
|
+
return fetchMock;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function respondWithStatus(
|
|
24
|
+
status: number,
|
|
25
|
+
body = "denied",
|
|
26
|
+
headers: Record<string, string> = {},
|
|
27
|
+
) {
|
|
28
|
+
vi.stubGlobal(
|
|
29
|
+
"fetch",
|
|
30
|
+
vi.fn(async () => new Response(body, { status, headers })),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function googleError(code: number, reason: string) {
|
|
35
|
+
return JSON.stringify({
|
|
36
|
+
error: {
|
|
37
|
+
code,
|
|
38
|
+
message: "Error",
|
|
39
|
+
errors: [{ domain: "usageLimits", reason, message: "Error" }],
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function providerConfig() {
|
|
45
|
+
return {
|
|
46
|
+
clientId: "client-example",
|
|
47
|
+
clientSecret: "secret-example",
|
|
48
|
+
getAccessToken: async () => "token-example",
|
|
49
|
+
markInvalid: vi.fn(async () => {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function googleBusy(calendarExternalIds: string[], config = providerConfig()) {
|
|
54
|
+
return createGoogleCalendarProvider(config).getBusy({
|
|
55
|
+
credentialId: "credential-example",
|
|
56
|
+
calendarExternalIds,
|
|
57
|
+
start: START,
|
|
58
|
+
end: END,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function outlookBusy(calendarExternalIds: string[], config = providerConfig()) {
|
|
63
|
+
return createOffice365Provider(config).getBusy({
|
|
64
|
+
credentialId: "credential-example",
|
|
65
|
+
calendarExternalIds,
|
|
66
|
+
start: START,
|
|
67
|
+
end: END,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requestedUrl(fetchMock: ReturnType<typeof vi.fn>, call: number) {
|
|
72
|
+
return new URL(String(fetchMock.mock.calls[call]?.[0]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.unstubAllGlobals();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("Google Calendar getBusy", () => {
|
|
80
|
+
it("returns busy intervals for every requested calendar", async () => {
|
|
81
|
+
respondWith({
|
|
82
|
+
calendars: {
|
|
83
|
+
"host@example.com": {
|
|
84
|
+
busy: [
|
|
85
|
+
{ start: "2030-01-07T10:00:00Z", end: "2030-01-07T11:00:00Z" },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await expect(googleBusy(["host@example.com"])).resolves.toEqual([
|
|
92
|
+
{
|
|
93
|
+
start: "2030-01-07T10:00:00Z",
|
|
94
|
+
end: "2030-01-07T11:00:00Z",
|
|
95
|
+
source: "host@example.com",
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("throws when free/busy reports an error for a calendar", async () => {
|
|
101
|
+
respondWith({
|
|
102
|
+
calendars: {
|
|
103
|
+
"host@example.com": {
|
|
104
|
+
busy: [],
|
|
105
|
+
errors: [{ domain: "global", reason: "notFound" }],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await expect(googleBusy(["host@example.com"])).rejects.toThrow(
|
|
111
|
+
"Google Calendar free/busy failed for host@example.com: notFound",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("throws when a requested calendar is missing from the response", async () => {
|
|
116
|
+
respondWith({ calendars: {} });
|
|
117
|
+
|
|
118
|
+
await expect(googleBusy(["host@example.com"])).rejects.toThrow(
|
|
119
|
+
"missing from response",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("throws a credential-invalid error and marks the credential on 401", async () => {
|
|
124
|
+
respondWithStatus(401);
|
|
125
|
+
const config = providerConfig();
|
|
126
|
+
|
|
127
|
+
await expect(
|
|
128
|
+
googleBusy(["host@example.com"], config),
|
|
129
|
+
).rejects.toBeInstanceOf(CalendarCredentialInvalidError);
|
|
130
|
+
expect(config.markInvalid).toHaveBeenCalledWith("credential-example");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("Google Calendar getBusy throttling", () => {
|
|
135
|
+
it.each(["rateLimitExceeded", "userRateLimitExceeded", "quotaExceeded"])(
|
|
136
|
+
"treats a 403 %s as a retryable throttle without invalidating the credential",
|
|
137
|
+
async (reason) => {
|
|
138
|
+
respondWithStatus(403, googleError(403, reason), {
|
|
139
|
+
"retry-after": "30",
|
|
140
|
+
});
|
|
141
|
+
const config = providerConfig();
|
|
142
|
+
|
|
143
|
+
const error = await googleBusy(["host@example.com"], config).then(
|
|
144
|
+
() => null,
|
|
145
|
+
(thrown: unknown) => thrown,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(error).toBeInstanceOf(CalendarLookupRateLimitedError);
|
|
149
|
+
expect(error).not.toBeInstanceOf(CalendarCredentialInvalidError);
|
|
150
|
+
expect((error as CalendarLookupRateLimitedError).retryAfterSeconds).toBe(
|
|
151
|
+
30,
|
|
152
|
+
);
|
|
153
|
+
expect(config.markInvalid).not.toHaveBeenCalled();
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
it("treats a 429 as a retryable throttle", async () => {
|
|
158
|
+
respondWithStatus(429, googleError(429, "rateLimitExceeded"));
|
|
159
|
+
const config = providerConfig();
|
|
160
|
+
|
|
161
|
+
await expect(
|
|
162
|
+
googleBusy(["host@example.com"], config),
|
|
163
|
+
).rejects.toBeInstanceOf(CalendarLookupRateLimitedError);
|
|
164
|
+
expect(config.markInvalid).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("still invalidates the credential on a 403 that is not a throttle", async () => {
|
|
168
|
+
respondWithStatus(403, googleError(403, "insufficientPermissions"));
|
|
169
|
+
const config = providerConfig();
|
|
170
|
+
|
|
171
|
+
await expect(
|
|
172
|
+
googleBusy(["host@example.com"], config),
|
|
173
|
+
).rejects.toBeInstanceOf(CalendarCredentialInvalidError);
|
|
174
|
+
expect(config.markInvalid).toHaveBeenCalledWith("credential-example");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("Office 365 getBusy concurrency", () => {
|
|
179
|
+
function calendarViewFor(_url: string) {
|
|
180
|
+
return {
|
|
181
|
+
value: [
|
|
182
|
+
{
|
|
183
|
+
start: { dateTime: "2030-01-07T10:00:00" },
|
|
184
|
+
end: { dateTime: "2030-01-07T11:00:00" },
|
|
185
|
+
showAs: "busy",
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it("reads several calendars at most four at a time and keeps their order", async () => {
|
|
192
|
+
const ids = Array.from({ length: 6 }, (_, index) => `calendar-${index}`);
|
|
193
|
+
let active = 0;
|
|
194
|
+
let maximumActive = 0;
|
|
195
|
+
vi.stubGlobal(
|
|
196
|
+
"fetch",
|
|
197
|
+
vi.fn(async (url: string) => {
|
|
198
|
+
active += 1;
|
|
199
|
+
maximumActive = Math.max(maximumActive, active);
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
201
|
+
active -= 1;
|
|
202
|
+
return new Response(JSON.stringify(calendarViewFor(url)), {
|
|
203
|
+
status: 200,
|
|
204
|
+
});
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const busy = await outlookBusy(ids);
|
|
209
|
+
|
|
210
|
+
expect(maximumActive).toBeGreaterThan(1);
|
|
211
|
+
expect(maximumActive).toBeLessThanOrEqual(4);
|
|
212
|
+
expect(busy.map((interval) => interval.source)).toEqual(ids);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("fails the whole lookup when any one calendar can't be read", async () => {
|
|
216
|
+
vi.stubGlobal(
|
|
217
|
+
"fetch",
|
|
218
|
+
vi.fn(async (url: string) =>
|
|
219
|
+
url.includes("calendar-broken")
|
|
220
|
+
? new Response("boom", { status: 500 })
|
|
221
|
+
: new Response(JSON.stringify(calendarViewFor(url)), {
|
|
222
|
+
status: 200,
|
|
223
|
+
}),
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
outlookBusy(["calendar-ok-1", "calendar-broken", "calendar-ok-2"]),
|
|
229
|
+
).rejects.toThrow("Graph 500");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("stops the remaining calendar reads once one fails", async () => {
|
|
233
|
+
const signals: AbortSignal[] = [];
|
|
234
|
+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
235
|
+
if (init?.signal) signals.push(init.signal);
|
|
236
|
+
if (url.includes("calendar-broken")) {
|
|
237
|
+
return new Response("boom", { status: 500 });
|
|
238
|
+
}
|
|
239
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
240
|
+
return new Response(JSON.stringify(calendarViewFor(url)), {
|
|
241
|
+
status: 200,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
245
|
+
const ids = [
|
|
246
|
+
"calendar-broken",
|
|
247
|
+
...Array.from({ length: 5 }, (_, index) => `calendar-${index}`),
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
await expect(outlookBusy(ids)).rejects.toThrow("Graph 500");
|
|
251
|
+
|
|
252
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
253
|
+
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("treats a Graph 429 as a retryable throttle with its Retry-After", async () => {
|
|
257
|
+
respondWithStatus(429, "throttled", { "retry-after": "12" });
|
|
258
|
+
const config = providerConfig();
|
|
259
|
+
|
|
260
|
+
const error = await outlookBusy([OUTLOOK_CALENDAR_ID], config).then(
|
|
261
|
+
() => null,
|
|
262
|
+
(thrown: unknown) => thrown,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
expect(error).toBeInstanceOf(CalendarLookupRateLimitedError);
|
|
266
|
+
expect((error as CalendarLookupRateLimitedError).retryAfterSeconds).toBe(
|
|
267
|
+
12,
|
|
268
|
+
);
|
|
269
|
+
expect(config.markInvalid).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("Office 365 getBusy", () => {
|
|
274
|
+
it("reads each selected calendar's calendarView by its Graph calendar id", async () => {
|
|
275
|
+
const fetchMock = respondWith({
|
|
276
|
+
value: [
|
|
277
|
+
{
|
|
278
|
+
start: { dateTime: "2030-01-07T10:00:00.0000000" },
|
|
279
|
+
end: { dateTime: "2030-01-07T11:00:00.0000000" },
|
|
280
|
+
showAs: "busy",
|
|
281
|
+
isCancelled: false,
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
start: { dateTime: "2030-01-07T12:00:00.0000000" },
|
|
285
|
+
end: { dateTime: "2030-01-07T13:00:00.0000000" },
|
|
286
|
+
showAs: "free",
|
|
287
|
+
isCancelled: false,
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
start: { dateTime: "2030-01-07T14:00:00.0000000" },
|
|
291
|
+
end: { dateTime: "2030-01-07T15:00:00.0000000" },
|
|
292
|
+
showAs: "busy",
|
|
293
|
+
isCancelled: true,
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await expect(outlookBusy([OUTLOOK_CALENDAR_ID])).resolves.toEqual([
|
|
299
|
+
{
|
|
300
|
+
start: "2030-01-07T10:00:00.0000000Z",
|
|
301
|
+
end: "2030-01-07T11:00:00.0000000Z",
|
|
302
|
+
source: OUTLOOK_CALENDAR_ID,
|
|
303
|
+
},
|
|
304
|
+
]);
|
|
305
|
+
const url = requestedUrl(fetchMock, 0);
|
|
306
|
+
expect(url.origin).toBe("https://graph.microsoft.com");
|
|
307
|
+
expect(url.pathname).toBe(
|
|
308
|
+
`/v1.0/me/calendars/${encodeURIComponent(OUTLOOK_CALENDAR_ID)}/calendarView`,
|
|
309
|
+
);
|
|
310
|
+
expect(url.searchParams.get("startDateTime")).toBe(START.toISOString());
|
|
311
|
+
expect(url.searchParams.get("endDateTime")).toBe(END.toISOString());
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("follows calendarView paging links", async () => {
|
|
315
|
+
const nextLink =
|
|
316
|
+
"https://graph.microsoft.com/v1.0/me/calendars/next-page?$skip=250";
|
|
317
|
+
const fetchMock = respondWith(
|
|
318
|
+
{
|
|
319
|
+
value: [
|
|
320
|
+
{
|
|
321
|
+
start: { dateTime: "2030-01-07T10:00:00" },
|
|
322
|
+
end: { dateTime: "2030-01-07T11:00:00" },
|
|
323
|
+
showAs: "busy",
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
"@odata.nextLink": nextLink,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
value: [
|
|
330
|
+
{
|
|
331
|
+
start: { dateTime: "2030-01-07T15:00:00" },
|
|
332
|
+
end: { dateTime: "2030-01-07T16:00:00" },
|
|
333
|
+
showAs: "tentative",
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const busy = await outlookBusy([OUTLOOK_CALENDAR_ID]);
|
|
340
|
+
|
|
341
|
+
expect(busy.map((interval) => interval.start)).toEqual([
|
|
342
|
+
"2030-01-07T10:00:00Z",
|
|
343
|
+
"2030-01-07T15:00:00Z",
|
|
344
|
+
]);
|
|
345
|
+
expect(String(fetchMock.mock.calls[1]?.[0])).toBe(nextLink);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("refuses a paging link that leaves Graph", async () => {
|
|
349
|
+
respondWith({
|
|
350
|
+
value: [],
|
|
351
|
+
"@odata.nextLink": "https://example.com/steal-token",
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await expect(outlookBusy([OUTLOOK_CALENDAR_ID])).rejects.toThrow(
|
|
355
|
+
"Refusing to call a non-Graph URL",
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("throws when calendarView returns no events list", async () => {
|
|
360
|
+
respondWith({ error: { code: "ErrorItemNotFound" } });
|
|
361
|
+
|
|
362
|
+
await expect(outlookBusy([OUTLOOK_CALENDAR_ID])).rejects.toThrow(
|
|
363
|
+
`Graph calendarView returned no events list for ${OUTLOOK_CALENDAR_ID}`,
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it.each([
|
|
368
|
+
[401, "denied"],
|
|
369
|
+
[
|
|
370
|
+
403,
|
|
371
|
+
JSON.stringify({
|
|
372
|
+
error: { code: "InvalidAuthenticationToken", message: "Expired" },
|
|
373
|
+
}),
|
|
374
|
+
],
|
|
375
|
+
])(
|
|
376
|
+
"throws a credential-invalid error and marks the credential on a %s token error",
|
|
377
|
+
async (status, body) => {
|
|
378
|
+
respondWithStatus(status, body);
|
|
379
|
+
const config = providerConfig();
|
|
380
|
+
|
|
381
|
+
await expect(
|
|
382
|
+
outlookBusy([OUTLOOK_CALENDAR_ID], config),
|
|
383
|
+
).rejects.toBeInstanceOf(CalendarCredentialInvalidError);
|
|
384
|
+
expect(config.markInvalid).toHaveBeenCalledWith("credential-example");
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
it("fails only the lookup, not the credential, when one calendar is denied", async () => {
|
|
389
|
+
vi.stubGlobal(
|
|
390
|
+
"fetch",
|
|
391
|
+
vi.fn(async (url: string) =>
|
|
392
|
+
url.includes("calendar-shared")
|
|
393
|
+
? new Response(
|
|
394
|
+
JSON.stringify({
|
|
395
|
+
error: { code: "ErrorAccessDenied", message: "Denied" },
|
|
396
|
+
}),
|
|
397
|
+
{ status: 403 },
|
|
398
|
+
)
|
|
399
|
+
: new Response(JSON.stringify({ value: [] }), { status: 200 }),
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
const config = providerConfig();
|
|
403
|
+
|
|
404
|
+
const error = await outlookBusy(
|
|
405
|
+
["calendar-own", "calendar-shared"],
|
|
406
|
+
config,
|
|
407
|
+
).then(
|
|
408
|
+
() => null,
|
|
409
|
+
(thrown: unknown) => thrown,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
expect(error).toBeInstanceOf(Error);
|
|
413
|
+
expect(error).not.toBeInstanceOf(CalendarCredentialInvalidError);
|
|
414
|
+
expect((error as Error).message).toContain("Graph 403");
|
|
415
|
+
expect(config.markInvalid).not.toHaveBeenCalled();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by a calendar provider when the provider rejected the credential
|
|
3
|
+
* (401/403), so the host has to reconnect rather than retry.
|
|
4
|
+
*/
|
|
5
|
+
export class CalendarCredentialInvalidError extends Error {
|
|
6
|
+
readonly calendarCredentialInvalid = true;
|
|
7
|
+
|
|
8
|
+
constructor(message: string) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "CalendarCredentialInvalidError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Thrown by a calendar provider when it throttled the request (rate or quota
|
|
16
|
+
* limit). The credential is still good; the lookup can be retried.
|
|
17
|
+
*/
|
|
18
|
+
export class CalendarLookupRateLimitedError extends Error {
|
|
19
|
+
readonly calendarLookupRateLimited = true;
|
|
20
|
+
readonly retryAfterSeconds?: number;
|
|
21
|
+
|
|
22
|
+
constructor(message: string, retryAfterSeconds?: number) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "CalendarLookupRateLimitedError";
|
|
25
|
+
if (retryAfterSeconds !== undefined) {
|
|
26
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isCalendarLookupRateLimitedError(
|
|
32
|
+
error: unknown,
|
|
33
|
+
): error is CalendarLookupRateLimitedError {
|
|
34
|
+
return (
|
|
35
|
+
error instanceof CalendarLookupRateLimitedError ||
|
|
36
|
+
(!!error &&
|
|
37
|
+
typeof error === "object" &&
|
|
38
|
+
(error as { calendarLookupRateLimited?: unknown })
|
|
39
|
+
.calendarLookupRateLimited === true)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Seconds from a `Retry-After` header (delta-seconds or HTTP date). */
|
|
44
|
+
export function parseRetryAfterSeconds(
|
|
45
|
+
header: string | null,
|
|
46
|
+
now: number = Date.now(),
|
|
47
|
+
): number | undefined {
|
|
48
|
+
if (!header) return undefined;
|
|
49
|
+
const trimmed = header.trim();
|
|
50
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed);
|
|
51
|
+
const at = Date.parse(trimmed);
|
|
52
|
+
if (Number.isNaN(at)) return undefined;
|
|
53
|
+
return Math.max(0, Math.ceil((at - now) / 1000));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isCalendarCredentialInvalidError(
|
|
57
|
+
error: unknown,
|
|
58
|
+
): error is CalendarCredentialInvalidError {
|
|
59
|
+
return (
|
|
60
|
+
error instanceof CalendarCredentialInvalidError ||
|
|
61
|
+
(!!error &&
|
|
62
|
+
typeof error === "object" &&
|
|
63
|
+
(error as { calendarCredentialInvalid?: unknown })
|
|
64
|
+
.calendarCredentialInvalid === true)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -10,8 +10,42 @@ import type { BusyInterval } from "../../shared/index.js";
|
|
|
10
10
|
* the same for every scheduling consumer. Custom token stores are supported
|
|
11
11
|
* via the `getAccessToken` callback.
|
|
12
12
|
*/
|
|
13
|
+
import {
|
|
14
|
+
CalendarCredentialInvalidError,
|
|
15
|
+
CalendarLookupRateLimitedError,
|
|
16
|
+
parseRetryAfterSeconds,
|
|
17
|
+
} from "./errors.js";
|
|
13
18
|
import type { CalendarProvider } from "./types.js";
|
|
14
19
|
|
|
20
|
+
const GOOGLE_RATE_LIMIT_REASONS = new Set([
|
|
21
|
+
"rateLimitExceeded",
|
|
22
|
+
"userRateLimitExceeded",
|
|
23
|
+
"quotaExceeded",
|
|
24
|
+
"dailyLimitExceeded",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function googleRateLimitReason(body: string): string | undefined {
|
|
28
|
+
let parsed: unknown;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(body);
|
|
31
|
+
// coercion-ok: a non-JSON 403 names no throttle reason, so the caller still treats it as an authorization failure
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const errors = (parsed as { error?: { errors?: { reason?: unknown }[] } })
|
|
36
|
+
.error?.errors;
|
|
37
|
+
if (!Array.isArray(errors)) return undefined;
|
|
38
|
+
for (const entry of errors) {
|
|
39
|
+
if (
|
|
40
|
+
typeof entry.reason === "string" &&
|
|
41
|
+
GOOGLE_RATE_LIMIT_REASONS.has(entry.reason)
|
|
42
|
+
) {
|
|
43
|
+
return entry.reason;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
15
49
|
export interface GoogleCalendarProviderConfig {
|
|
16
50
|
clientId: string;
|
|
17
51
|
clientSecret: string;
|
|
@@ -44,9 +78,21 @@ export function createGoogleCalendarProvider(
|
|
|
44
78
|
...init,
|
|
45
79
|
headers,
|
|
46
80
|
});
|
|
47
|
-
if (res.status === 401 || res.status === 403) {
|
|
81
|
+
if (res.status === 401 || res.status === 403 || res.status === 429) {
|
|
82
|
+
const body = await res.text();
|
|
83
|
+
// Google sends rate and quota limits as 403 (or 429); those are
|
|
84
|
+
// throttles on a good credential and must not force a reconnect.
|
|
85
|
+
const reason = googleRateLimitReason(body);
|
|
86
|
+
if (res.status === 429 || reason) {
|
|
87
|
+
throw new CalendarLookupRateLimitedError(
|
|
88
|
+
`Google Calendar: rate limited (${res.status} ${reason ?? "rateLimitExceeded"})`,
|
|
89
|
+
parseRetryAfterSeconds(res.headers.get("retry-after")),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
48
92
|
await config.markInvalid?.(credentialId);
|
|
49
|
-
throw new
|
|
93
|
+
throw new CalendarCredentialInvalidError(
|
|
94
|
+
`Google Calendar: credential invalid (${res.status})`,
|
|
95
|
+
);
|
|
50
96
|
}
|
|
51
97
|
if (!res.ok) {
|
|
52
98
|
const body = await res.text();
|
|
@@ -134,7 +180,15 @@ export function createGoogleCalendarProvider(
|
|
|
134
180
|
|
|
135
181
|
async getBusy({ credentialId, calendarExternalIds, start, end }) {
|
|
136
182
|
const resp = await apiCall<{
|
|
137
|
-
calendars
|
|
183
|
+
calendars?: Partial<
|
|
184
|
+
Record<
|
|
185
|
+
string,
|
|
186
|
+
{
|
|
187
|
+
busy?: { start: string; end: string }[];
|
|
188
|
+
errors?: { domain?: string; reason?: string }[];
|
|
189
|
+
}
|
|
190
|
+
>
|
|
191
|
+
>;
|
|
138
192
|
}>(credentialId, "https://www.googleapis.com/calendar/v3/freeBusy", {
|
|
139
193
|
method: "POST",
|
|
140
194
|
body: JSON.stringify({
|
|
@@ -143,8 +197,21 @@ export function createGoogleCalendarProvider(
|
|
|
143
197
|
items: calendarExternalIds.map((id) => ({ id })),
|
|
144
198
|
}),
|
|
145
199
|
});
|
|
200
|
+
// freeBusy answers 200 with an empty `busy` and an `errors` entry for a
|
|
201
|
+
// calendar it couldn't read; that must not count as a free calendar.
|
|
146
202
|
const out: BusyInterval[] = [];
|
|
147
|
-
for (const
|
|
203
|
+
for (const calId of calendarExternalIds) {
|
|
204
|
+
const data =
|
|
205
|
+
resp.calendars?.[calId] ??
|
|
206
|
+
Object.entries(resp.calendars ?? {}).find(
|
|
207
|
+
([key]) => key.toLowerCase() === calId.toLowerCase(),
|
|
208
|
+
)?.[1];
|
|
209
|
+
if (!data || data.errors?.length || !Array.isArray(data.busy)) {
|
|
210
|
+
const reason = data?.errors?.[0]?.reason ?? "missing from response";
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Google Calendar free/busy failed for ${calId}: ${reason}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
148
215
|
for (const b of data.busy) {
|
|
149
216
|
out.push({ start: b.start, end: b.end, source: calId });
|
|
150
217
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
2
|
export * from "./registry.js";
|
|
3
|
+
export * from "./errors.js";
|
|
3
4
|
export { createGoogleCalendarProvider } from "./google-calendar.js";
|
|
4
5
|
export { createOffice365Provider } from "./office365.js";
|
|
5
6
|
export { createZoomProvider, ZoomProviderError } from "./zoom.js";
|