Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

225 linesCodeBlameRaw
1/**
2 * How the outbound handler sends a sandbox's request on, for a workflow
3 * job whose requests pass the Worker, and how a self-hosted job's brokered
4 * request is relayed. An upstream that resets the connection or fails outright would
5 * otherwise throw out of the handler, and the sandbox would see its own
6 * connection reset, which tools such as npm count as a failure rather
7 * than something to retry. So a request that can be sent again (a GET or
8 * HEAD with no body) is tried twice more after a short wait, and what
9 * still fails comes back as a `502` saying which host and why, which
10 * those tools do retry. It never throws.
11 *
12 * An upstream can also break off in the middle of a body, after its
13 * headers have been handed on: npm then fails with `ECONNRESET` and
14 * `network aborted`, and never retries it. So an answer of known, modest
15 * size to such a request is read whole before it is handed on, and tried
16 * again when it breaks off (`BUFFERED_MAX_BYTES`). A larger one, such as a
17 * 50 MB package tarball, streams as it comes, and when it breaks off the
18 * rest is asked for with a `Range` request from where it stopped and
19 * joined on, so the sandbox sees one unbroken answer (`resuming`).
20 */
21
22import { normalizeHost } from "./egress.ts";
23
24/** The waits before the second and third tries, in milliseconds; also before each resumption of a body. */
25export const RETRY_WAITS_MS: readonly number[] = [250, 1000];
26
27/**
28 * The largest answer to a replayable request that is read whole before the
29 * sandbox is answered. An answer read whole first is tried again like any
30 * other failure. Package tarballs and most API answers are well under this;
31 * larger ones stream, and resume when they break off (`resuming`).
32 */
33export const BUFFERED_MAX_BYTES = 8 * 1024 * 1024;
34
35/** The `content-length` of an answer sent as it is (no `content-encoding`), or null. */
36function plainLength(response: Response): number | null {
37 const encoding = (response.headers.get("content-encoding") ?? "identity").toLowerCase();
38 if (encoding !== "identity") return null;
39 const header = response.headers.get("content-length");
40 const length = header === null ? NaN : Number(header);
41 return Number.isSafeInteger(length) && length >= 0 ? length : null;
42}
43
44/** Whether an answer is to be read whole: a known length within the limit, sent as it is. */
45export function bufferable(response: Response): boolean {
46 if (response.body === null) return false;
47 const length = plainLength(response);
48 return length !== null && length <= BUFFERED_MAX_BYTES;
49}
50
51/** An answer whose body is read whole; throws when the body breaks off. */
52async function whole(response: Response): Promise<Response> {
53 const body = await response.arrayBuffer();
54 const expected = Number(response.headers.get("content-length"));
55 if (body.byteLength !== expected) throw new Error(`the answer broke off after ${body.byteLength} of ${expected} bytes`);
56 return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
57}
58
59/** Whether a request can be sent again as it was: no body, nothing changed. */
60export function replayable(request: Request): boolean {
61 return (request.method === "GET" || request.method === "HEAD") && request.body === null;
62}
63
64/**
65 * What a broken-off body can be resumed against: the validator that makes
66 * sure the rest comes from the same file (a strong `ETag`, else
67 * `Last-Modified`), and its whole length. Null when it cannot be: only a
68 * whole `200` to a GET that asked for no range, of known length, sent as it
69 * is, from a host that takes byte ranges.
70 */
71export function resumeFrom(request: Request, response: Response): { validator: string; length: number } | null {
72 if (request.method !== "GET" || request.headers.has("range") || response.status !== 200 || response.body === null) return null;
73 if (
74 !(response.headers.get("accept-ranges") ?? "")
75 .toLowerCase()
76 .split(/[,\s]+/)
77 .includes("bytes")
78 )
79 return null;
80 const length = plainLength(response);
81 if (length === null || length === 0) return null;
82 const etag = response.headers.get("etag");
83 const validator = etag && !etag.startsWith("W/") ? etag : response.headers.get("last-modified");
84 return validator ? { validator, length } : null;
85}
86
87/** Whether a `206` carries exactly the rest of the file from `offset`. */
88function restFrom(response: Response, offset: number, length: number): boolean {
89 // The rest is joined on byte for byte, so it must come as it is, like the start did.
90 const encoding = (response.headers.get("content-encoding") ?? "identity").toLowerCase();
91 if (response.status !== 206 || response.body === null || encoding !== "identity") return false;
92 const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec((response.headers.get("content-range") ?? "").trim());
93 return range !== null && Number(range[1]) === offset && Number(range[2]) === length - 1 && Number(range[3]) === length;
94}
95
96/**
97 * `response`'s body, joined across breaks: when it breaks off (an error, or
98 * an end short of its length), the rest is asked for with
99 * `Range: bytes=<received>-` and `If-Range`, after the waits of
100 * `RETRY_WAITS_MS`, and read on from there. When the rest cannot be had
101 * (no `206` for exactly that range, or every try failed), the body errors
102 * as it would have. Each break is one line of the Worker's log.
103 */
104export function resuming(
105 request: Request,
106 response: Response,
107 from: { validator: string; length: number },
108 send: (request: Request) => Promise<Response>,
109 sleep: (ms: number) => Promise<void>,
110): Response {
111 const host = normalizeHost(new URL(request.url).host);
112 const { validator, length } = from;
113 let reader = (response.body as ReadableStream<Uint8Array>).getReader();
114 let received = 0;
115 let resumes = 0;
116
117 /** A reader for the rest from `received`, or the reason there is none. */
118 const reopen = async (broke: unknown): Promise<ReadableStreamDefaultReader<Uint8Array>> => {
119 let failure = broke;
120 while (resumes < RETRY_WAITS_MS.length) {
121 const wait = RETRY_WAITS_MS[resumes++];
122 console.log("egress body broke off", host, `${received} of ${length} bytes`, `resuming, try ${resumes} of ${RETRY_WAITS_MS.length}`, String(failure));
123 await sleep(wait);
124 const headers = new Headers(request.headers);
125 headers.set("range", `bytes=${received}-`);
126 headers.set("if-range", validator);
127 let rest: Response;
128 try {
129 rest = await send(new Request(request.url, { method: "GET", headers, redirect: request.redirect }));
130 } catch (error) {
131 failure = error;
132 continue;
133 }
134 if (restFrom(rest, received, length)) return (rest.body as ReadableStream<Uint8Array>).getReader();
135 await rest.body?.cancel().catch(() => {});
136 // The file changed, or the host would not send that range: there is no rest to join on.
137 const why = `the rest could not be had (${rest.status} ${rest.headers.get("content-range") ?? "without a range"})`;
138 console.log("egress body not resumed", host, `${received} of ${length} bytes`, why);
139 throw new Error(`the answer broke off after ${received} of ${length} bytes, and ${why}`);
140 }
141 console.log("egress body not resumed", host, `${received} of ${length} bytes`, String(failure));
142 throw failure instanceof Error ? failure : new Error(String(failure));
143 };
144
145 const body = new ReadableStream<Uint8Array>({
146 async pull(controller) {
147 for (;;) {
148 let chunk: ReadableStreamReadResult<Uint8Array>;
149 try {
150 chunk = await reader.read();
151 if (chunk.done && received < length) throw new Error(`the answer ended after ${received} of ${length} bytes`);
152 } catch (error) {
153 try {
154 reader = await reopen(error);
155 } catch (final) {
156 controller.error(final);
157 return;
158 }
159 continue;
160 }
161 if (chunk.done) {
162 controller.close();
163 return;
164 }
165 if (received + chunk.value.byteLength > length) {
166 controller.error(new Error(`the answer ran past its ${length} bytes`));
167 return;
168 }
169 received += chunk.value.byteLength;
170 controller.enqueue(chunk.value);
171 return;
172 }
173 },
174 cancel(reason) {
175 return reader.cancel(reason);
176 },
177 });
178 // In the Worker, a stream of fixed length keeps the answer's
179 // `content-length`, so the sandbox can tell a whole answer from a short one.
180 const Fixed = (globalThis as { FixedLengthStream?: new (length: number) => TransformStream<Uint8Array, Uint8Array> }).FixedLengthStream;
181 const sent = Fixed ? body.pipeThrough(new Fixed(length)) : body;
182 return new Response(sent, { status: response.status, statusText: response.statusText, headers: response.headers });
183}
184
185/** What a request that could not be sent gets back. */
186export function upstreamFailed(host: string, error: unknown): Response {
187 const why = error instanceof Error ? error.message : String(error);
188 return new Response(`g1t egress: ${normalizeHost(host)} could not be reached: ${why}\n`, {
189 status: 502,
190 headers: { "content-type": "text/plain; charset=utf-8", "x-g1t-egress": "upstream-failed" },
191 });
192}
193
194const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
195
196/**
197 * Sends `request` with `send` (`fetch`), trying a replayable one again on
198 * a thrown error, and answering `502` when it cannot. Each failure is one
199 * line of the Worker's log.
200 */
201export async function forward(
202 request: Request,
203 send: (request: Request) => Promise<Response> = fetch,
204 sleep: (ms: number) => Promise<void> = wait,
205): Promise<Response> {
206 const host = new URL(request.url).host;
207 const tries = replayable(request) ? RETRY_WAITS_MS.length + 1 : 1;
208 let failure: unknown = null;
209 for (let attempt = 1; attempt <= tries; attempt++) {
210 if (attempt > 1) await sleep(RETRY_WAITS_MS[attempt - 2]);
211 try {
212 const response = await send(request);
213 if (tries === 1) return response;
214 // Read whole first when it can be sent again, so a body that breaks off is retried too.
215 if (bufferable(response)) return await whole(response);
216 // Too large to hold: streamed, and resumed where it breaks off.
217 const from = resumeFrom(request, response);
218 return from ? resuming(request, response, from, send, sleep) : response;
219 } catch (error) {
220 failure = error;
221 console.log("egress upstream failed", normalizeHost(host), request.method, `try ${attempt} of ${tries}`, String(error));
222 }
223 }
224 return upstreamFailed(host, failure);
225}