mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
fix(firms): keep the IPv4 retry from crashing the server; guard the call sites
- fetchIpv4 builds its Response inside the https response callback, outside the promise. A 204, 205 or 304, a status above 599 or an invalid reason phrase made the Response constructor throw there, an uncaught exception that stops the dev server. Null-body statuses now get a null body and anything else that cannot become a Response rejects, as the Radio Browser transport does. - A non-OK FIRMS or mapkey-status response cancels its body so the IPv4 socket closes at once instead of at the deadline. - Tests: the crash cases (fail on the old code), a source check that every upstream request goes through fetchFirms and that fetchSource's body read shares its deadline (fails if either call reverts to fetch), a real-fetch contract test for the address-selection error shape, and an explicit skip in the IPv4-only socket test where the host cannot tell the families apart. - Changelog entry moves to changelog.d/1078-firms-ipv4-fallback.md per CONTRIBUTING; CURRENT-STATE says which timeouts are retried and that the retry does not follow redirects.
This commit is contained in:
1 parent
bae3433b24
commit
df01964171
4 files changed
+155
-16
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
- FIRMS fire data loads on hosts where IPv6 is unreachable and IPv4 is slow to
|
||||||
|
connect: a request whose every address fails to connect retries once over
|
||||||
|
IPv4, and FIRMS warnings name the underlying connection errors instead of
|
||||||
|
only `fetch failed` (jeffblakely, #1078).
|
||||||
@@ -2118,10 +2118,14 @@ FIRMS requests retry once over IPv4 when every resolved address fails to
|
|||||||
connect. Node's network-family auto-selection gives each address a short
|
connect. Node's network-family auto-selection gives each address a short
|
||||||
attempt timeout, so on hosts where IPv6 is unreachable a slow IPv4 handshake
|
attempt timeout, so on hosts where IPv6 is unreachable a slow IPv4 handshake
|
||||||
was abandoned and the request reported only `fetch failed` (#68). The retry
|
was abandoned and the request reported only `fetch failed` (#68). The retry
|
||||||
makes a single IPv4 connection within the same deadline; HTTP errors,
|
makes a single IPv4 connection within the same deadline and the same body
|
||||||
timeouts and DNS failures are not retried. FIRMS warnings include the
|
cap, and does not follow redirects. HTTP errors, an expired request deadline
|
||||||
per-address connection errors and never the request URL, which embeds the
|
and DNS failures are not retried; a per-address connect timeout, which Node 26
|
||||||
MAP_KEY.
|
reports as `ConnectTimeoutError`, is. A retry answered with a null-body status
|
||||||
|
(204, 205, 304) or a status above 599 fails that source instead of crashing
|
||||||
|
the server, and a non-OK response releases its connection at once. FIRMS
|
||||||
|
warnings include the per-address connection errors and never the request URL,
|
||||||
|
which embeds the MAP_KEY.
|
||||||
|
|
||||||
## Installations and map-source guidance
|
## Installations and map-source guidance
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Readable } from 'node:stream';
|
|||||||
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
||||||
import { readResponseTextCapped } from './common/http.js';
|
import { readResponseTextCapped } from './common/http.js';
|
||||||
|
|
||||||
|
const NULL_BODY_STATUSES = new Set([204, 205, 304]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-address connection errors behind a fetch failure, or null. Node 24
|
* The per-address connection errors behind a fetch failure, or null. Node 24
|
||||||
* puts the AggregateError directly on `err.cause`; Node 26's fetch wraps it in
|
* puts the AggregateError directly on `err.cause`; Node 26's fetch wraps it in
|
||||||
@@ -71,19 +73,31 @@ export function fetchIpv4(url, { signal } = {}) {
|
|||||||
agent: false,
|
agent: false,
|
||||||
},
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
|
// Nothing thrown here may escape: this callback runs outside the
|
||||||
|
// promise, so an exception would be uncaught and crash the server.
|
||||||
|
try {
|
||||||
|
const status = response.statusCode || 500;
|
||||||
|
// A `Response` only represents statuses up to 599; Node never hands
|
||||||
|
// this callback a 1xx.
|
||||||
|
if (status > 599)
|
||||||
|
throw new Error(`FIRMS returned unsupported status ${status}`);
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
for (const [name, value] of Object.entries(response.headers)) {
|
for (const [name, value] of Object.entries(response.headers)) {
|
||||||
if (Array.isArray(value))
|
if (Array.isArray(value))
|
||||||
value.forEach((item) => headers.append(name, item));
|
value.forEach((item) => headers.append(name, item));
|
||||||
else if (value !== undefined) headers.set(name, String(value));
|
else if (value !== undefined) headers.set(name, String(value));
|
||||||
}
|
}
|
||||||
resolve(
|
const nullBody = NULL_BODY_STATUSES.has(status);
|
||||||
new Response(Readable.toWeb(response), {
|
const upstream = new Response(
|
||||||
status: response.statusCode || 500,
|
nullBody ? null : Readable.toWeb(response),
|
||||||
statusText: response.statusMessage || '',
|
{ status, statusText: response.statusMessage || '', headers },
|
||||||
headers,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
if (nullBody) response.resume();
|
||||||
|
resolve(upstream);
|
||||||
|
} catch (error) {
|
||||||
|
response.destroy();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
request.on('error', reject);
|
request.on('error', reject);
|
||||||
@@ -201,7 +215,11 @@ export function firmsProxy() {
|
|||||||
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
|
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
|
||||||
const signal = AbortSignal.timeout(60_000);
|
const signal = AbortSignal.timeout(60_000);
|
||||||
const res = await fetchFirms(url, { signal });
|
const res = await fetchFirms(url, { signal });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) {
|
||||||
|
// Release the connection now rather than when the deadline fires.
|
||||||
|
await res.body?.cancel().catch(() => {});
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
const records = parseFirmsCsv(
|
const records = parseFirmsCsv(
|
||||||
await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal),
|
await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal),
|
||||||
);
|
);
|
||||||
@@ -268,7 +286,10 @@ export function firmsProxy() {
|
|||||||
const res = await fetchFirms(url, {
|
const res = await fetchFirms(url, {
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) {
|
||||||
|
await res.body?.cancel().catch(() => {});
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
const used = Number(body?.current_transactions);
|
const used = Number(body?.current_transactions);
|
||||||
const limit = Number(body?.transaction_limit);
|
const limit = Number(body?.transaction_limit);
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import dns from 'node:dns/promises';
|
||||||
|
import https from 'node:https';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js';
|
||||||
import {
|
import {
|
||||||
describeFetchError,
|
describeFetchError,
|
||||||
fetchFirms,
|
fetchFirms,
|
||||||
fetchIpv4,
|
fetchIpv4,
|
||||||
|
isAddressSelectionFailure,
|
||||||
} from '../../server/providers/firms.js';
|
} from '../../server/providers/firms.js';
|
||||||
|
|
||||||
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
|
const config = fs.readFileSync(new URL('../../server/providers/firms.js', import.meta.url), 'utf8');
|
||||||
@@ -182,13 +187,118 @@ test('the IPv4 retry connects to an IPv4 address only', async (t) => {
|
|||||||
const v4 = await listen('127.0.0.1', 0);
|
const v4 = await listen('127.0.0.1', 0);
|
||||||
t.after(() => v4.close());
|
t.after(() => v4.close());
|
||||||
const { port } = v4.address();
|
const { port } = v4.address();
|
||||||
let v6 = null;
|
let v6;
|
||||||
try {
|
try {
|
||||||
v6 = await listen('::1', port);
|
v6 = await listen('::1', port);
|
||||||
t.after(() => v6.close());
|
|
||||||
} catch {
|
} catch {
|
||||||
// No IPv6 loopback on this host; the IPv4 assertion still holds.
|
t.skip('no IPv6 loopback on this host, so IPv4-only cannot be told apart');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
t.after(() => v6.close());
|
||||||
|
const order = await dns.lookup('localhost', { all: true });
|
||||||
|
if (order[0]?.family !== 6) {
|
||||||
|
t.skip('localhost resolves IPv4 first here, so IPv4-only cannot be told apart');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
|
await assert.rejects(fetchIpv4(`https://localhost:${port}/`, { signal: AbortSignal.timeout(5_000) }));
|
||||||
assert.deepEqual(reached, ['127.0.0.1']);
|
assert.deepEqual(reached, ['127.0.0.1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Answer the IPv4 retry with `status`, invoking the response callback
|
||||||
|
// asynchronously as `https.request` does, outside the caller's promise.
|
||||||
|
// The returned promise rejects on an uncaught exception, so a regression fails
|
||||||
|
// the race at once instead of crashing the test runner.
|
||||||
|
function mockIpv4Status(t, status, statusMessage = '') {
|
||||||
|
let crash;
|
||||||
|
const crashed = new Promise((_resolve, reject) => (crash = reject));
|
||||||
|
const onUncaught = (error) =>
|
||||||
|
crash(new Error(`uncaught exception: ${error.message}`));
|
||||||
|
process.on('uncaughtException', onUncaught);
|
||||||
|
t.after(() => process.off('uncaughtException', onUncaught));
|
||||||
|
t.mock.method(https, 'request', (_url, _options, callback) => {
|
||||||
|
const request = new EventEmitter();
|
||||||
|
request.end = () =>
|
||||||
|
setImmediate(() =>
|
||||||
|
callback(
|
||||||
|
Object.assign(Readable.from([]), {
|
||||||
|
statusCode: status,
|
||||||
|
statusMessage,
|
||||||
|
headers: { 'content-type': 'text/csv' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
return crashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const status of [204, 205, 304]) {
|
||||||
|
test(`the IPv4 retry answering null-body status ${status} resolves instead of crashing`, async (t) => {
|
||||||
|
const crashed = mockIpv4Status(t, status);
|
||||||
|
const response = await Promise.race([fetchIpv4(URL_WITH_KEY), crashed]);
|
||||||
|
assert.equal(response.status, status);
|
||||||
|
assert.equal(response.body, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const status of [600, 999]) {
|
||||||
|
test(`the IPv4 retry answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
|
||||||
|
const crashed = mockIpv4Status(t, status);
|
||||||
|
await assert.rejects(
|
||||||
|
Promise.race([fetchIpv4(URL_WITH_KEY), crashed]),
|
||||||
|
new RegExp(`unsupported status ${status}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the IPv4 retry answering an invalid reason phrase rejects instead of crashing', async (t) => {
|
||||||
|
const crashed = mockIpv4Status(t, 200, 'OK\x7f');
|
||||||
|
await assert.rejects(Promise.race([fetchIpv4(URL_WITH_KEY), crashed]), TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fixtures above copy Node's error shape; check it against a real fetch so
|
||||||
|
// a Node or undici change that stops matching fails here instead of silently
|
||||||
|
// disabling the retry. A closed port on `localhost` refuses both addresses.
|
||||||
|
test('a real all-addresses connect failure is recognised as address selection', async (t) => {
|
||||||
|
const probe = net.createServer();
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => probe.once('error', reject).listen(0, '::1', resolve));
|
||||||
|
} catch {
|
||||||
|
t.skip('no IPv6 loopback on this host');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { port } = probe.address();
|
||||||
|
await new Promise((resolve) => probe.close(resolve));
|
||||||
|
const v4 = net.createServer();
|
||||||
|
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
|
||||||
|
await new Promise((resolve) => v4.close(resolve));
|
||||||
|
const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error);
|
||||||
|
assert.ok(err, 'a closed port refuses the connection');
|
||||||
|
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
|
||||||
|
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #68 only stays fixed while both upstream calls go through fetchFirms; the
|
||||||
|
// refresh harness injects fetchSource, so guard the call sites directly.
|
||||||
|
test('every FIRMS upstream request goes through the IPv4 fallback', async () => {
|
||||||
|
assert.doesNotMatch(config, /await fetch\(/, 'no FIRMS request bypasses fetchFirms');
|
||||||
|
const fetchStart = config.indexOf(' async function fetchSource(key, source) {');
|
||||||
|
assert.notEqual(fetchStart, -1, 'FIRMS fetchSource exists');
|
||||||
|
const fetchSourceText = config.slice(fetchStart, config.indexOf('\n }', fetchStart) + 4);
|
||||||
|
const calls = [];
|
||||||
|
const response = new Response('latitude,longitude\n');
|
||||||
|
const fetchSource = new Function('fetchFirms', 'readResponseTextCapped', 'parseFirmsCsv',
|
||||||
|
'FIRMS_MAX_RESPONSE_BYTES', 'AbortSignal', 'fetch', `return (${fetchSourceText});`)(
|
||||||
|
async (url, options) => { calls.push(['fetchFirms', url, options.signal]); return response; },
|
||||||
|
async (res, cap, signal) => { calls.push(['read', res, signal]); return 'csv'; },
|
||||||
|
() => [],
|
||||||
|
1024,
|
||||||
|
AbortSignal,
|
||||||
|
() => assert.fail('fetchSource must call fetchFirms, not fetch'),
|
||||||
|
);
|
||||||
|
assert.deepEqual(await fetchSource('SECRET', 'MODIS_NRT'), []);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(calls[0][0], 'fetchFirms');
|
||||||
|
assert.ok(calls[0][2] instanceof AbortSignal);
|
||||||
|
assert.deepEqual(calls[1], ['read', response, calls[0][2]], 'the body read shares the request deadline');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user