Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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}`;
Expand Down
161 changes: 85 additions & 76 deletions app/src/pages/ipfs/[...path].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Loading