Skip to content
54 linesCodeBlameRaw
1/**
2 * How long the site waits on a service before it gives up. Nothing on
3 * Workers ends a subrequest that stops answering: the page waits as long
4 * as the visitor does, so a service stuck on one call holds the page on
5 * its loading bar for good. With a deadline it fails instead, as an error
6 * the page can draw and the next click can get past.
7 */
8
9/** A read: no page should wait longer than this on one service call. */
10export const READ_DEADLINE_MS = 15_000;
11
12/** A write: some do real work (an import, a merge), so they get longer. */
13export const WRITE_DEADLINE_MS = 60_000;
14
15/** What a call past its deadline throws. */
16export class DeadlineExceeded extends Error {
17 constructor(what: string, ms: number) {
18 super(`${what} did not answer within ${ms}ms`);
19 this.name = "DeadlineExceeded";
20 }
21}
22
23/**
24 * `run`'s answer, or a `DeadlineExceeded` after `ms`. `run` is handed a
25 * signal that aborts at the deadline, so the call it makes is cancelled
26 * too; `outer`, when given, aborts it sooner. Whatever `run` reads must be
27 * read inside it: the deadline ends when it returns.
28 */
29export async function withDeadline<T>(
30 what: string,
31 ms: number,
32 run: (signal: AbortSignal) => Promise<T>,
33 outer?: AbortSignal | null,
34): Promise<T> {
35 const controller = new AbortController();
36 let timer: ReturnType<typeof setTimeout> | undefined;
37 const expired = new Promise<never>((_, reject) => {
38 timer = setTimeout(() => {
39 const error = new DeadlineExceeded(what, ms);
40 controller.abort(error);
41 reject(error);
42 }, ms);
43 });
44 const stop = () => controller.abort(outer?.reason);
45 if (outer?.aborted) stop();
46 else outer?.addEventListener("abort", stop, { once: true });
47 try {
48 // Raced as well as aborted: a binding that ignores the signal still lets go.
49 return await Promise.race([run(controller.signal), expired]);
50 } finally {
51 clearTimeout(timer);
52 outer?.removeEventListener("abort", stop);
53 }
54}