mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(relay): wrap non-JSON upstream errors in JSON envelope (#1700)
* fix(relay): wrap non-JSON upstream errors in JSON envelope
When upstream services (Cloudflare, nginx) return HTML error pages
(502/503), the relay handler passed them through with the original
content-type. Clients expecting JSON would fail to parse the response,
causing silent panel failures in the desktop app.
Extracts a buildRelayResponse() helper that detects non-JSON error
responses and wraps them in a { error, status } JSON envelope. Success
responses and JSON error responses pass through unchanged.
Applies the same fix to the telegram-feed handler which had an
identical passthrough pattern.
Ref #976
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(relay): address review feedback on buildRelayResponse
- Export buildRelayResponse so telegram-feed.js can use it directly,
eliminating the duplicate inline implementation
- Fix +json content-type detection: use /json and +json substring checks
so application/vnd.api+json and application/problem+json responses are
treated as JSON and passed through unchanged instead of being wrapped
- Add console.warn with body preview when wrapping, to aid production debugging
- Include HTTP status code in the error message for actionable diagnostics
- Update tests: vnd.api+json errors now pass through; error assertions use
startsWith so they remain valid as the message evolves
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
committed by
GitHub
parent
15e2a6fccb
commit
c6d43d984b
@@ -30,6 +30,29 @@ export async function fetchWithTimeout(url, options, timeoutMs = 15000) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the final relay response — wraps non-JSON errors in a JSON envelope
|
||||
* so the client can always parse the body (guards against Cloudflare HTML 502s).
|
||||
* Exported so that standalone handlers (e.g. telegram-feed.js) can reuse it. */
|
||||
export function buildRelayResponse(response, body, headers) {
|
||||
const ct = (response.headers.get('content-type') || '').toLowerCase();
|
||||
// Treat any JSON-compatible type as JSON: application/json, application/problem+json,
|
||||
// application/vnd.api+json, application/ld+json, etc.
|
||||
const isNonJsonError = !response.ok && !ct.includes('/json') && !ct.includes('+json');
|
||||
if (isNonJsonError) {
|
||||
console.warn(`[relay] Wrapping non-JSON ${response.status} upstream error (ct: ${ct || 'none'}); body preview: ${String(body).slice(0, 120)}`);
|
||||
}
|
||||
return new Response(
|
||||
isNonJsonError ? JSON.stringify({ error: `Upstream error: HTTP ${response.status}`, status: response.status }) : body,
|
||||
{
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': isNonJsonError ? 'application/json' : (response.headers.get('content-type') || 'application/json'),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createRelayHandler(cfg) {
|
||||
return async function handler(req) {
|
||||
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
|
||||
@@ -85,15 +108,7 @@ export function createRelayHandler(cfg) {
|
||||
const isSuccess = response.status >= 200 && response.status < 300;
|
||||
const cacheHeaders = cfg.cacheHeaders ? cfg.cacheHeaders(isSuccess) : {};
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||
...cacheHeaders,
|
||||
...extraHeaders,
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
return buildRelayResponse(response, body, { ...cacheHeaders, ...extraHeaders, ...corsHeaders });
|
||||
} catch (error) {
|
||||
if (cfg.fallback) return cfg.fallback(req, corsHeaders);
|
||||
const isTimeout = error?.name === 'AbortError';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getRelayBaseUrl, getRelayHeaders, fetchWithTimeout } from './_relay.js';
|
||||
import { getRelayBaseUrl, getRelayHeaders, fetchWithTimeout, buildRelayResponse } from './_relay.js';
|
||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
||||
import { jsonResponse } from './_json-response.js';
|
||||
|
||||
@@ -47,13 +47,9 @@ export default async function handler(req) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||
'Cache-Control': cacheControl,
|
||||
...corsHeaders,
|
||||
},
|
||||
return buildRelayResponse(response, body, {
|
||||
'Cache-Control': response.ok ? cacheControl : 'no-store',
|
||||
...corsHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error?.name === 'AbortError';
|
||||
|
||||
@@ -366,4 +366,651 @@ describe('createRelayHandler', () => {
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(await res.text(), '{"upstream":"error"}');
|
||||
});
|
||||
|
||||
it('wraps non-JSON error responses in a JSON envelope', async () => {
|
||||
// Simulate Cloudflare/nginx returning an HTML error page
|
||||
mockFetch(async () => new Response(
|
||||
'<html><body><h1>502 Bad Gateway</h1></body></html>',
|
||||
{ status: 502, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 502);
|
||||
});
|
||||
|
||||
it('wraps text/plain error responses in a JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'Service Unavailable',
|
||||
{ status: 503, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 503);
|
||||
});
|
||||
|
||||
it('preserves JSON error responses as-is', async () => {
|
||||
mockFetchStatus(502, '{"upstream":"error"}');
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
assert.equal(await res.text(), '{"upstream":"error"}');
|
||||
});
|
||||
|
||||
it('passes through non-JSON success responses unchanged', async () => {
|
||||
// Some endpoints legitimately return non-JSON on success (e.g. XML feeds)
|
||||
mockFetch(async () => new Response(
|
||||
'<rss><channel></channel></rss>',
|
||||
{ status: 200, headers: { 'Content-Type': 'application/xml' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'application/xml');
|
||||
assert.equal(await res.text(), '<rss><channel></channel></rss>');
|
||||
});
|
||||
|
||||
it('wraps error response with no content-type in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response('bad gateway', { status: 502, headers: {} }));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
});
|
||||
|
||||
// ── Content-Type edge cases ──────────────────────────────────────────
|
||||
|
||||
it('wraps text/html with charset param in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<html><body>Bad Gateway</body></html>',
|
||||
{ status: 502, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 502);
|
||||
});
|
||||
|
||||
it('preserves JSON error with uppercase APPLICATION/JSON content-type', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'{"detail":"bad request"}',
|
||||
{ status: 400, headers: { 'Content-Type': 'APPLICATION/JSON' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.equal(body.detail, 'bad request');
|
||||
});
|
||||
|
||||
it('preserves JSON error with application/json; charset=utf-8 content-type', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'{"message":"not found"}',
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json; charset=utf-8' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 404);
|
||||
const text = await res.text();
|
||||
assert.equal(text, '{"message":"not found"}');
|
||||
});
|
||||
|
||||
it('passes application/vnd.api+json error through unchanged (JSON-compatible type)', async () => {
|
||||
// application/vnd.api+json contains "+json" so it is treated as JSON and passed through
|
||||
mockFetch(async () => new Response(
|
||||
'{"errors":[{"status":"500"}]}',
|
||||
{ status: 500, headers: { 'Content-Type': 'application/vnd.api+json' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 500);
|
||||
assert.equal(res.headers.get('content-type'), 'application/vnd.api+json');
|
||||
const body = await res.json();
|
||||
assert.deepEqual(body.errors, [{ status: '500' }]);
|
||||
});
|
||||
|
||||
it('wraps error with empty string content-type in JSON envelope', async () => {
|
||||
mockFetch(async () => {
|
||||
const resp = new Response('something broke', { status: 500 });
|
||||
// Explicitly set empty content-type via headers
|
||||
return new Response('something broke', {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': '' },
|
||||
});
|
||||
});
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 500);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 500);
|
||||
});
|
||||
|
||||
it('wraps multipart/form-data error in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'some binary data',
|
||||
{ status: 502, headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 502);
|
||||
});
|
||||
|
||||
it('preserves mixed-case Application/Json error response as-is', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'{"err":"server error"}',
|
||||
{ status: 500, headers: { 'Content-Type': 'Application/Json' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 500);
|
||||
const text = await res.text();
|
||||
assert.equal(text, '{"err":"server error"}');
|
||||
});
|
||||
|
||||
// ── Status code edge cases ───────────────────────────────────────────
|
||||
|
||||
it('wraps 400 text/html error in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<html>Bad Request</html>',
|
||||
{ status: 400, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 400);
|
||||
});
|
||||
|
||||
it('wraps 401 text/html error in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'Unauthorized',
|
||||
{ status: 401, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 401);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 401);
|
||||
});
|
||||
|
||||
it('wraps 403 text/plain error in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'Forbidden',
|
||||
{ status: 403, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 403);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 403);
|
||||
});
|
||||
|
||||
it('wraps 404 text/html error in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<h1>Not Found</h1>',
|
||||
{ status: 404, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 404);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 404);
|
||||
});
|
||||
|
||||
it('wraps 499 text/plain error in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'Client Closed Request',
|
||||
{ status: 499, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 499);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 499);
|
||||
});
|
||||
|
||||
it('wraps 500 text/html error in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<html>Internal Server Error</html>',
|
||||
{ status: 500, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 500);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 500);
|
||||
});
|
||||
|
||||
it('wraps 504 text/html error in JSON envelope', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<html>Gateway Timeout</html>',
|
||||
{ status: 504, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 504);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 504);
|
||||
});
|
||||
|
||||
it('does NOT wrap 200 non-JSON response (success passthrough)', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<html>OK page</html>',
|
||||
{ status: 200, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'text/html');
|
||||
assert.equal(await res.text(), '<html>OK page</html>');
|
||||
});
|
||||
|
||||
it('does NOT wrap 201 non-JSON response (success passthrough)', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'Created',
|
||||
{ status: 201, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.headers.get('content-type'), 'text/plain');
|
||||
assert.equal(await res.text(), 'Created');
|
||||
});
|
||||
|
||||
it('does NOT wrap 299 non-JSON response (upper bound of success range)', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'success boundary',
|
||||
{ status: 299, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 299);
|
||||
assert.equal(res.headers.get('content-type'), 'text/plain');
|
||||
assert.equal(await res.text(), 'success boundary');
|
||||
});
|
||||
|
||||
it('wraps 300 non-JSON response (first non-2xx)', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'Multiple Choices',
|
||||
{ status: 300, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 300);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 300);
|
||||
});
|
||||
|
||||
// ── Body edge cases ──────────────────────────────────────────────────
|
||||
|
||||
it('wraps empty body with non-JSON error content-type', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'',
|
||||
{ status: 502, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 502);
|
||||
});
|
||||
|
||||
it('wraps very large HTML body and still returns parseable JSON', async () => {
|
||||
const largeHtml = '<html>' + '<p>error</p>'.repeat(10000) + '</html>';
|
||||
mockFetch(async () => new Response(
|
||||
largeHtml,
|
||||
{ status: 502, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 502);
|
||||
// The large HTML body should NOT leak into the JSON envelope
|
||||
const text = JSON.stringify(body);
|
||||
assert.ok(!text.includes('<html>'));
|
||||
});
|
||||
|
||||
it('wraps body that looks like JSON but has wrong content-type', async () => {
|
||||
// Server returns valid JSON body but says it is text/html
|
||||
mockFetch(async () => new Response(
|
||||
'{"actually":"json"}',
|
||||
{ status: 500, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 500);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
// The original JSON body is replaced by the envelope
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 500);
|
||||
assert.equal(body.actually, undefined);
|
||||
});
|
||||
|
||||
it('wraps null body with error status', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
null,
|
||||
{ status: 503, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 503);
|
||||
});
|
||||
|
||||
// ── Interaction with fallback + onlyOk ───────────────────────────────
|
||||
|
||||
it('calls fallback BEFORE wrapping when onlyOk is true and response is non-JSON error', async () => {
|
||||
// When onlyOk is true and response is non-2xx, fallback should fire
|
||||
// regardless of content-type — wrapping never gets a chance
|
||||
mockFetch(async () => new Response(
|
||||
'<html>502</html>',
|
||||
{ status: 502, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
let fallbackCalled = false;
|
||||
const handler = createRelayHandler({
|
||||
relayPath: '/test',
|
||||
onlyOk: true,
|
||||
fallback: (_req, cors) => {
|
||||
fallbackCalled = true;
|
||||
return new Response('{"from":"fallback"}', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json', ...cors },
|
||||
});
|
||||
},
|
||||
});
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(fallbackCalled, true);
|
||||
assert.equal(res.status, 503);
|
||||
const body = await res.json();
|
||||
assert.equal(body.from, 'fallback');
|
||||
});
|
||||
|
||||
it('wraps non-JSON error when onlyOk is true but fallback is NOT set', async () => {
|
||||
// onlyOk without fallback: the code path falls through to buildRelayResponse
|
||||
mockFetch(async () => new Response(
|
||||
'<html>502</html>',
|
||||
{ status: 502, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({
|
||||
relayPath: '/test',
|
||||
onlyOk: true,
|
||||
// no fallback
|
||||
});
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 502);
|
||||
});
|
||||
|
||||
it('does NOT call fallback for non-2xx JSON error when onlyOk is false', async () => {
|
||||
mockFetchStatus(502, '{"upstream":"error"}');
|
||||
let fallbackCalled = false;
|
||||
const handler = createRelayHandler({
|
||||
relayPath: '/test',
|
||||
onlyOk: false,
|
||||
fallback: () => {
|
||||
fallbackCalled = true;
|
||||
return new Response('{}', { status: 200 });
|
||||
},
|
||||
});
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(fallbackCalled, false);
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(await res.text(), '{"upstream":"error"}');
|
||||
});
|
||||
|
||||
// ── Interaction with extraHeaders and cacheHeaders ───────────────────
|
||||
|
||||
it('preserves extraHeaders in wrapped non-JSON error response', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<html>502</html>',
|
||||
{ status: 502, headers: { 'Content-Type': 'text/html', 'X-Cache': 'MISS' } },
|
||||
));
|
||||
const handler = createRelayHandler({
|
||||
relayPath: '/test',
|
||||
extraHeaders: (response) => {
|
||||
const xc = response.headers.get('x-cache');
|
||||
return xc ? { 'X-Cache': xc } : {};
|
||||
},
|
||||
});
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
assert.equal(res.headers.get('x-cache'), 'MISS');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
});
|
||||
|
||||
it('preserves cacheHeaders in wrapped non-JSON error response', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<html>503</html>',
|
||||
{ status: 503, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({
|
||||
relayPath: '/test',
|
||||
cacheHeaders: (ok) => ({
|
||||
'Cache-Control': ok ? 'public, max-age=60' : 'no-store',
|
||||
}),
|
||||
});
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
});
|
||||
|
||||
it('preserves both extraHeaders and cacheHeaders in wrapped response', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'Unavailable',
|
||||
{ status: 503, headers: { 'Content-Type': 'text/plain', 'X-Request-Id': 'abc-123' } },
|
||||
));
|
||||
const handler = createRelayHandler({
|
||||
relayPath: '/test',
|
||||
cacheHeaders: (ok) => ({
|
||||
'Cache-Control': ok ? 'public, max-age=120' : 'no-cache',
|
||||
}),
|
||||
extraHeaders: (response) => ({
|
||||
'X-Request-Id': response.headers.get('x-request-id') || '',
|
||||
}),
|
||||
});
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 503);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
assert.equal(res.headers.get('cache-control'), 'no-cache');
|
||||
assert.equal(res.headers.get('x-request-id'), 'abc-123');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 503);
|
||||
});
|
||||
|
||||
it('includes CORS headers in wrapped non-JSON error response', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<html>502</html>',
|
||||
{ status: 502, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 502);
|
||||
assert.ok(res.headers.get('access-control-allow-origin'));
|
||||
assert.ok(res.headers.get('vary'));
|
||||
});
|
||||
|
||||
// ── JSON envelope is always parseable ────────────────────────────────
|
||||
|
||||
it('produces parseable JSON envelope for every non-2xx non-JSON status', async () => {
|
||||
const statuses = [300, 301, 302, 400, 401, 403, 404, 405, 429, 499, 500, 502, 503, 504];
|
||||
for (const status of statuses) {
|
||||
mockFetch(async () => new Response(
|
||||
`<html>Error ${status}</html>`,
|
||||
{ status, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, status, `Status mismatch for ${status}`);
|
||||
const text = await res.text();
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => { parsed = JSON.parse(text); }, `Body not valid JSON for status ${status}`);
|
||||
assert.ok(parsed.error.startsWith('Upstream error'), `Missing error field for status ${status}`);
|
||||
assert.equal(parsed.status, status, `Missing status field for status ${status}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces parseable JSON even when upstream body contains characters that need escaping', async () => {
|
||||
// The wrapping replaces the body, but let us verify the envelope itself is clean
|
||||
mockFetch(async () => new Response(
|
||||
'<script>alert("xss")</script>\n\t\r\0',
|
||||
{ status: 502, headers: { 'Content-Type': 'text/html' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
const text = await res.text();
|
||||
const parsed = JSON.parse(text);
|
||||
assert.ok(parsed.error.startsWith('Upstream error'));
|
||||
assert.equal(parsed.status, 502);
|
||||
});
|
||||
|
||||
// ── Success responses with unusual content-types pass through unchanged ──
|
||||
|
||||
it('passes through application/xml success response unchanged', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'<?xml version="1.0"?><data/>',
|
||||
{ status: 200, headers: { 'Content-Type': 'application/xml' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'application/xml');
|
||||
assert.equal(await res.text(), '<?xml version="1.0"?><data/>');
|
||||
});
|
||||
|
||||
it('passes through text/csv success response unchanged', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'name,value\nfoo,1\nbar,2',
|
||||
{ status: 200, headers: { 'Content-Type': 'text/csv' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'text/csv');
|
||||
assert.equal(await res.text(), 'name,value\nfoo,1\nbar,2');
|
||||
});
|
||||
|
||||
it('passes through application/octet-stream success response unchanged', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'binary-ish-data',
|
||||
{ status: 200, headers: { 'Content-Type': 'application/octet-stream' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'application/octet-stream');
|
||||
assert.equal(await res.text(), 'binary-ish-data');
|
||||
});
|
||||
|
||||
it('passes through text/plain success response unchanged', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'just plain text',
|
||||
{ status: 200, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'text/plain');
|
||||
assert.equal(await res.text(), 'just plain text');
|
||||
});
|
||||
|
||||
it('passes through application/vnd.api+json success response unchanged', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'{"data":{"type":"articles","id":"1"}}',
|
||||
{ status: 200, headers: { 'Content-Type': 'application/vnd.api+json' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'application/vnd.api+json');
|
||||
assert.equal(await res.text(), '{"data":{"type":"articles","id":"1"}}');
|
||||
});
|
||||
|
||||
it('passes through success response with no explicit content-type (gets default text/plain)', async () => {
|
||||
// When Response has no explicit Content-Type, the runtime defaults to text/plain;charset=UTF-8
|
||||
// The upstream response.headers.get('content-type') returns that default, so the
|
||||
// `|| 'application/json'` fallback in buildRelayResponse never fires.
|
||||
mockFetch(async () => new Response('{"ok":true}', { status: 200, headers: {} }));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.headers.get('content-type').includes('text/plain'));
|
||||
assert.equal(await res.text(), '{"ok":true}');
|
||||
});
|
||||
|
||||
// ── Boundary: status < 200 is not valid for Response constructor ────
|
||||
// Node rejects status codes outside 200-599, so an upstream that somehow
|
||||
// triggers a RangeError is caught and returned as a 502.
|
||||
|
||||
it('returns 502 when upstream produces an invalid status code (triggers catch)', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'informational-ish',
|
||||
{ status: 199, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
// The RangeError from Response constructor is caught by the handler
|
||||
assert.equal(res.status, 502);
|
||||
const body = await res.json();
|
||||
assert.equal(body.error, 'Relay request failed');
|
||||
});
|
||||
|
||||
it('wraps non-JSON 599 error (upper bound of valid HTTP status)', async () => {
|
||||
mockFetch(async () => new Response(
|
||||
'custom error',
|
||||
{ status: 599, headers: { 'Content-Type': 'text/plain' } },
|
||||
));
|
||||
const handler = createRelayHandler({ relayPath: '/test' });
|
||||
const res = await handler(makeRequest('https://worldmonitor.app/api/test'));
|
||||
assert.equal(res.status, 599);
|
||||
assert.equal(res.headers.get('content-type'), 'application/json');
|
||||
const body = await res.json();
|
||||
assert.ok(body.error.startsWith('Upstream error'), `unexpected error: ${body.error}`);
|
||||
assert.equal(body.status, 599);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user