| 1 | /** |
| 2 | * How the outbound handler sends a sandbox's request on to an allowed |
| 3 | * host. An upstream that resets the connection or fails outright would |
| 4 | * otherwise throw out of the handler, and the sandbox would see its own |
| 5 | * connection reset, which tools such as npm count as a failure rather |
| 6 | * than something to retry. So a request that can be sent again (a GET or |
| 7 | * HEAD with no body) is tried twice more after a short wait, and what |
| 8 | * still fails comes back as a `502` saying which host and why, which |
| 9 | * those tools do retry. It never throws. |
| 10 | */ |
| 11 | |
| 12 | import { normalizeHost } from "./egress.ts"; |
| 13 | |
| 14 | /** The waits before the second and third tries, in milliseconds. */ |
| 15 | export const RETRY_WAITS_MS: readonly number[] = [250, 1000]; |
| 16 | |
| 17 | /** Whether a request can be sent again as it was: no body, nothing changed. */ |
| 18 | export function replayable(request: Request): boolean { |
| 19 | return (request.method === "GET" || request.method === "HEAD") && request.body === null; |
| 20 | } |
| 21 | |
| 22 | /** What a request that could not be sent gets back. */ |
| 23 | export function upstreamFailed(host: string, error: unknown): Response { |
| 24 | const why = error instanceof Error ? error.message : String(error); |
| 25 | return new Response(`g1t egress: ${normalizeHost(host)} could not be reached: ${why}\n`, { |
| 26 | status: 502, |
| 27 | headers: { "content-type": "text/plain; charset=utf-8", "x-g1t-egress": "upstream-failed" }, |
| 28 | }); |
| 29 | } |
| 30 | |
| 31 | const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); |
| 32 | |
| 33 | /** |
| 34 | * Sends `request` with `send` (`fetch`), trying a replayable one again on |
| 35 | * a thrown error, and answering `502` when it cannot. Each failure is one |
| 36 | * line of the Worker's log. |
| 37 | */ |
| 38 | export async function forward( |
| 39 | request: Request, |
| 40 | send: (request: Request) => Promise<Response> = fetch, |
| 41 | sleep: (ms: number) => Promise<void> = wait, |
| 42 | ): Promise<Response> { |
| 43 | const host = new URL(request.url).host; |
| 44 | const tries = replayable(request) ? RETRY_WAITS_MS.length + 1 : 1; |
| 45 | let failure: unknown = null; |
| 46 | for (let attempt = 1; attempt <= tries; attempt++) { |
| 47 | if (attempt > 1) await sleep(RETRY_WAITS_MS[attempt - 2]); |
| 48 | try { |
| 49 | return await send(request); |
| 50 | } catch (error) { |
| 51 | failure = error; |
| 52 | console.log("egress upstream failed", normalizeHost(host), request.method, `try ${attempt} of ${tries}`, String(error)); |
| 53 | } |
| 54 | } |
| 55 | return upstreamFailed(host, failure); |
| 56 | } |