From dfff93a1052412b5c047a5aadd161a6f94849483 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 12 Oct 2026 16:42:00 +0200 Subject: [PATCH] fix: retry cold ipfs proxy connects once per gateway --- .../ipfs-route.test.ts} | 42 ++++- app/src/pages/ipfs/[...path].ts | 161 +++++++++--------- 2 files changed, 126 insertions(+), 77 deletions(-) rename app/src/{pages/ipfs/[...path].test.ts => lib/ipfs-route.test.ts} (84%) diff --git a/app/src/pages/ipfs/[...path].test.ts b/app/src/lib/ipfs-route.test.ts similarity index 84% rename from app/src/pages/ipfs/[...path].test.ts rename to app/src/lib/ipfs-route.test.ts index 380739e..bd74d92 100644 --- a/app/src/pages/ipfs/[...path].test.ts +++ b/app/src/lib/ipfs-route.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { GET, upstreamForPath, gatewaysForPath } from './[...path]'; +import { GET, upstreamForPath, gatewaysForPath } from '../pages/ipfs/[...path]'; const CID_V0 = 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG'; const CID_V1 = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'; @@ -115,6 +115,46 @@ describe('GET /ipfs/[...path]', () => { expect(res.headers.get('cache-control')).toBe('no-store'); }); + it('retries a network-level throw on the same gateway once', async () => { + // Cold connects to the proxy intermittently fail (ETIMEDOUT well + // under the per-gateway budget) and succeed on an immediate retry. + const calls: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL) => { + calls.push(String(url)); + if (calls.length === 1) { + const err = new Error('connect ETIMEDOUT') as Error & { cause?: unknown }; + err.cause = Object.assign(new Error('connect ETIMEDOUT'), { code: 'ETIMEDOUT' }); + throw err; + } + return new Response('{"ok":true}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch, + ); + const res = await GET(ctx(CID_V0)); + expect(res.status).toBe(200); + expect(calls).toHaveLength(2); + expect(calls[0]).toBe(calls[1]); // same gateway, retried + expect(res.headers.get('x-ipfs-gateway')).toBe('bridge.mintlayer.org'); + }); + + it('does not retry HTTP-level failures', async () => { + const calls: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string | URL) => { + calls.push(String(url)); + return new Response('still down', { status: 503 }); + }) as unknown as typeof fetch, + ); + const res = await GET(ctx(CID_V0)); + expect(res.status).toBe(502); + expect(calls).toHaveLength(1); // HTTP answer → next gateway (none) → give up + }); + it('follows same-site redirects but never foreign ones', async () => { stubTwoGateways(); const subdomain = `https://${CID_V0.toLowerCase()}.ipfs.ipfs.io/ipfs/${CID_V0}`; diff --git a/app/src/pages/ipfs/[...path].ts b/app/src/pages/ipfs/[...path].ts index 504720d..038bdbf 100644 --- a/app/src/pages/ipfs/[...path].ts +++ b/app/src/pages/ipfs/[...path].ts @@ -133,91 +133,100 @@ export async function GET(ctx: { let lastStatus = 502; for (const upstream of candidates) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT_MS); - let upstreamRes: Response; - try { - // Redirects are handled manually: the CID allowlist gates hop 1, - // so only a same-site redirect (CIDv1 → subdomain gateway) is - // followed — never a bounce to an arbitrary host. - upstreamRes = await fetch(upstream, { - redirect: 'manual', - signal: controller.signal, - headers: { accept: 'application/json, image/*;q=0.9, text/plain;q=0.5' }, - }); - } catch { - clearTimeout(timer); - lastStatus = 502; - continue; - } + // Cold connects from small containers to the proxy intermittently + // fail at connect time (ETIMEDOUT/AggregateError well under the + // per-gateway budget) and succeed on an immediate retry — the + // browser-visible symptom was missing token icons. Retry the same + // gateway once on a network-level throw; HTTP statuses are never + // retried (they are answers, not accidents). + for (let attempt = 0; attempt < 2; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT_MS); + let upstreamRes: Response; + try { + // Redirects are handled manually: the CID allowlist gates hop 1, + // so only a same-site redirect (CIDv1 → subdomain gateway) is + // followed — never a bounce to an arbitrary host. + upstreamRes = await fetch(upstream, { + redirect: 'manual', + signal: controller.signal, + headers: { accept: 'application/json, image/*;q=0.9, text/plain;q=0.5' }, + }); + } catch { + clearTimeout(timer); + lastStatus = 502; + continue; // network accident — retry this gateway once + } - if (upstreamRes.status >= 300 && upstreamRes.status < 400) { - const location = upstreamRes.headers.get('location') ?? ''; - // Timer stays armed — both hops share the per-gateway budget. - if (isSameSite(upstream, location)) { - try { - upstreamRes = await fetch(location, { - redirect: 'error', - signal: controller.signal, - headers: { accept: 'application/json, image/*;q=0.9, text/plain;q=0.5' }, - }); - } catch { + if (upstreamRes.status >= 300 && upstreamRes.status < 400) { + const location = upstreamRes.headers.get('location') ?? ''; + // Timer stays armed — both hops share the per-gateway budget. + if (isSameSite(upstream, location)) { + try { + upstreamRes = await fetch(location, { + redirect: 'error', + signal: controller.signal, + headers: { accept: 'application/json, image/*;q=0.9, text/plain;q=0.5' }, + }); + } catch { + clearTimeout(timer); + lastStatus = 502; + continue; + } + } else { + clearTimeout(timer); lastStatus = 502; - continue; + break; // foreign redirect — this gateway misbehaves, next one } - } else { - clearTimeout(timer); - lastStatus = 502; - continue; } - } - if (upstreamRes.status !== 200) { - clearTimeout(timer); - lastStatus = upstreamRes.status === 404 ? 404 : 502; - continue; - } + if (upstreamRes.status !== 200) { + clearTimeout(timer); + lastStatus = upstreamRes.status === 404 ? 404 : 502; + break; // an HTTP answer — next gateway, not a retry + } - // Honour an honest content-length before reading anything. - const declaredLength = Number(upstreamRes.headers.get('content-length') ?? ''); - if (Number.isFinite(declaredLength) && declaredLength > MAX_BYTES) { - clearTimeout(timer); - return errorResponse('Too large', 413); - } + // Honour an honest content-length before reading anything. + const declaredLength = Number(upstreamRes.headers.get('content-length') ?? ''); + if (Number.isFinite(declaredLength) && declaredLength > MAX_BYTES) { + clearTimeout(timer); + return errorResponse('Too large', 413); + } - const contentType = (upstreamRes.headers.get('content-type') ?? '') - .split(';')[0] - .trim(); - if (!ALLOWED_TYPES.test(contentType)) { - clearTimeout(timer); - // Fail fast: content-type is a property of the CID content, not - // of the gateway — another gateway would serve the same bytes. - return errorResponse('Unsupported media type', 415); - } + const contentType = (upstreamRes.headers.get('content-type') ?? '') + .split(';')[0] + .trim(); + if (!ALLOWED_TYPES.test(contentType)) { + clearTimeout(timer); + // Fail fast: content-type is a property of the CID content, not + // of the gateway — another gateway would serve the same bytes. + return errorResponse('Unsupported media type', 415); + } - try { - const buffer = await readCapped(upstreamRes); - if (buffer === null) { - return errorResponse('Too large', 413); + try { + const buffer = await readCapped(upstreamRes); + if (buffer === null) { + return errorResponse('Too large', 413); + } + return new Response(buffer.buffer as ArrayBuffer, { + status: 200, + headers: { + 'content-type': contentType || 'application/octet-stream', + // Content-addressed: the bytes for a CID can never change. + 'cache-control': 'public, max-age=2592000, immutable', + 'x-content-type-options': 'nosniff', + 'content-security-policy': RESPONSE_CSP, + // A poisoned/buggy gateway caching wrong bytes is diagnosable + // with one curl. + 'x-ipfs-gateway': new URL(upstream).host, + }, + }); + } catch { + lastStatus = 502; + continue; + } finally { + clearTimeout(timer); } - return new Response(buffer.buffer as ArrayBuffer, { - status: 200, - headers: { - 'content-type': contentType || 'application/octet-stream', - // Content-addressed: the bytes for a CID can never change. - 'cache-control': 'public, max-age=2592000, immutable', - 'x-content-type-options': 'nosniff', - 'content-security-policy': RESPONSE_CSP, - // A poisoned/buggy gateway caching wrong bytes is diagnosable - // with one curl. - 'x-ipfs-gateway': new URL(upstream).host, - }, - }); - } catch { - lastStatus = 502; - continue; - } finally { - clearTimeout(timer); } }