Edge functions: crash-safety hardening (timeouts + vendored deno_dom) - #194
Conversation
The edge function's internal fetch for the .md variant had no timeout. On a cold/slow origin it could hold the invocation open until the platform killed it with "edge function invocation failed" (transient, first-request-only, gone on retry -- matching the report from deploy-preview-190 testing). Add a 1.5s AbortController timeout; on abort or any error, fall through to context.next() and serve the HTML. The markdown variant is an optimization, never required, so a timeout degrades cleanly instead of erroring the page. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two edge functions had fetch() calls with no timeout, the same crash class as serve-markdown: a hung upstream holds the edge invocation open until the platform kills it with "edge function invocation failed", bypassing the surrounding try/catch (a hang never throws). - proxy-api-docs: MCP passthrough fetch to Bump.sh had no timeout (the main proxy path was already protected via fetchWithRetry's AbortSignal.timeout). Add AbortSignal.timeout(10000) so the catch returns a clean 502 instead. - agent-skills-index: same-origin digest fetches (to other edge functions / llms.txt) inside Promise.all had no timeout; a cold origin could hang the invocation. Add AbortSignal.timeout(3000); on timeout, fall back to the placeholder digest. Co-Authored-By: Claude Opus 4.8 <[email protected]>
The edge function imported deno_dom from https://deno.land at module load. Netlify bundles it at deploy time, so it's not a runtime crash risk -- but a build would fail if deno.land were down, and it pins a critical dependency to a third-party host. Vendor a self-contained copy of [email protected] (incl. the 513KB WASM, which loads via local module import -- no runtime fetch) under _vendor/ and import it by relative path. Underscore-prefixed subdir so it isn't treated as an edge function. Verified offline with Deno 2.9 (the edge runtime engine): - deno run --no-remote: parses HTML + createElement/innerHTML/appendChild/ outerHTML (every op proxy-api-docs uses) - deno check --no-remote: type-resolves with zero network deps - esbuild --bundle: full JS/TS graph resolves, only .wasm external Final gate is a deploy-preview build (Netlify bundler embedding the local wasm is the one thing not verifiable locally; the same graph already bundles today from the remote URL). Co-Authored-By: Claude Opus 4.8 <[email protected]>
✅ Deploy Preview for redpanda-documentation ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
The timeout guards convert previously-silent hangs into catchable errors; make
those catches log structured (JSON with an event tag) and distinguish a timeout
(AbortError, i.e. cold/slow origin — the class behind the reported edge crash)
from ordinary fetch/parse errors, so a recurrence is greppable.
- serve-markdown: event serve_markdown_fallback {reason, path, mdPath, error}
- agent-skills-index: event agent_skills_digest_fallback {reason, skill, url, error}
Scoped to the two functions with thin logging; proxy-api-docs already logs its
failure paths. Happy paths on hot routes are intentionally not logged (noise /
edge CPU budget).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Feediver1
left a comment
There was a problem hiding this comment.
Code review
Scope: 3 edited edge functions + 28 vendored deno_dom files. Overall: high quality — correct diagnosis, minimal targeted fixes, honest verification notes. One minor real bug in the new logging, two hardening suggestions.
Correctness
serve-markdown.js— correct pattern:AbortController+setTimeout(1500), and cruciallyclearTimeoutinfinally, so no timer dangles on the happy path. Fallback tocontext.next()is right for an optional optimization, and theAbortErrorname check is correct forcontroller.abort().agent-skills-index.ts— the per-skilltry/catchsits inside thePromise.allmap, so one timeout degrades one skill to a placeholder digest instead of rejecting the whole index.- 🐛 Minor bug (the one actionable finding): the same file classifies timeouts with
e.name === 'AbortError', butAbortSignal.timeout()aborts with aTimeoutErrorDOMException (per spec, and in Deno) — so actual timeouts get logged asreason: 'error', defeating the stated purpose of making timeout recurrences greppable. Fallback behavior is unaffected; only the label is wrong.
Fix:const isTimeout = e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError');
(serve-markdown is unaffected —controller.abort()genuinely producesAbortError.) proxy-api-docs.js—AbortSignal.timeout(10000)on the MCP passthrough matches the protection the main path already had viafetchWithRetry; the existing catch's 502 is the right conversion.- Timeout budgets (1.5s / 3s / 10s) are sensibly proportioned to each fallback's cost.
Supply chain
Verified: all 28 vendored files are byte-identical (SHA-256) to upstream b-fuze/deno-dom at tag v0.1.56 — no modifications, no injected code, and the vendored set is a sensible minimal subset. Removing the deploy-time deno.land dependency is both an availability and a supply-chain win.
Suggestions
- Add a
_vendor/deno_dom/VENDOR.mdrecording the upstream repo, tag, file list, and re-vendor command, so the byte-identity stays re-verifiable without archaeology (the import-site comment covers most of this already). - Promote
deno check --no-remote netlify/edge-functions/*.tsinto CI to lock in the newly won zero-remote-imports property and fail fast if a remote import is ever reintroduced.
Risks
Low. The 1.5s budget on serve-markdown runs on the hottest path; if origin latency regularly exceeds it, the markdown variant silently degrades to HTML — the structured logs will make that visible once the TimeoutError label fix lands.
🤖 Generated with Claude Code
Feediver1
left a comment
There was a problem hiding this comment.
please take a look at the fixes from the review.
…md, CI check - agent-skills-index: classify AbortSignal.timeout() aborts as timeouts. Per spec (and verified on Deno 2.2.7) the abort reason is a TimeoutError DOMException, not AbortError, so real timeouts were logged as reason:'error'. Match both names; fallback behavior unchanged. - _vendor/deno_dom/VENDOR.md: record upstream repo, tag (v0.1.56, commit 92ff9600), file list, re-vendor and byte-identity verification commands. All 28 files independently re-verified byte-identical to upstream. - CI: new edge-function-checks workflow runs deno check --no-remote netlify/edge-functions/*.ts *.js on PRs touching edge functions, locking in the zero-remote-imports property. Verified the command passes on this tree with Deno 2.x.
|
Thanks @Feediver1 — all three review items verified and applied in 16511ee. 1. TimeoutError classification bug (applied) — confirmed: 2. 3. CI Nothing declined. |
…, 40s budget Co-Authored-By: Claude Fable 5 <[email protected]>

Why
Crash-safety audit of all 9 edge functions, prompted by an intermittent "edge function invocation failed" reported during deploy-preview-190 testing. The reviewed functions are otherwise solid (most are static generators); this PR fixes the failure modes that can surface as a platform-level invocation crash.
Root cause addressed
The recurring pattern was a
fetch()with no timeout. The subtlety: a hang never throws, so the surroundingtry/catchdoesn't catch it — the request holds the edge invocation open until Netlify's platform kills it and reports "edge function invocation failed." Timeouts convert the hang into anAbortErrorthe existing fallback handles cleanly.Fixes (4)
serve-markdown(/*, runs on every request) — internal.mdfetch had no timeout. Added a 1.5sAbortController; on abort/error, fall through tocontext.next()(serve HTML). The markdown variant is an optimization, never required.proxy-api-docs(/api/*) — the MCP-passthrough fetch to Bump.sh had no timeout (the main proxy path was already protected byfetchWithRetry'sAbortSignal.timeout). AddedAbortSignal.timeout(10000)so the existing catch returns a clean 502.agent-skills-index— same-origin digest fetches insidePromise.allhad no timeout; a cold origin could hang the invocation. AddedAbortSignal.timeout(3000)with placeholder-digest fallback.proxy-api-docs— vendoreddeno_dom— removed the deploy-time dependency ondeno.land(a build there would fail ifdeno.landwere down; it also pinned a critical dep to a third-party host). Self-contained copy of[email protected](incl. inlined WASM) under_vendor/, imported by relative path. Everyfetch()across all edge functions is now timeout-bounded.Verification (local, offline)
deno_domparses HTML +createElement/innerHTML/appendChild/outerHTMLwith--no-remote(zero network);deno check --no-remoteresolves.esbuild --bundle: full JS/TS graph resolves, only.wasmexternal.Please verify on the deploy preview
/api/*API reference pages render — confirms Netlify's bundler embedded the local WASM (the one thing not verifiable locally; the same module graph already bundles today from the remote URL)._vendor/files are NOT listed as deployed edge functions (they're in an underscore-prefixed subdir and have noconfigexport, so they shouldn't be). If either fails: move_vendorout of the edge-functions dir and wire adeno_import_mapinnetlify.toml.🤖 Generated with Claude Code