socket-function 1.2.16 → 1.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +3 -1
- package/index.d.ts +19 -0
- package/package.json +3 -2
- package/src/dnsCache.d.ts +10 -0
- package/src/dnsCache.ts +203 -0
- package/src/dnsCacheTest.d.ts +1 -0
- package/src/dnsCacheTest.ts +76 -0
- package/src/https.ts +99 -37
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
"permissions": {
|
|
3
3
|
"allow": [
|
|
4
4
|
"Bash(mkdir -p C:/Users/quent/AppData/Local/Temp/claude/D--repos-socket-function/117d10bd-31c6-4c68-938d-1fff47788fa6/scratchpad/tm-recover)",
|
|
5
|
-
"Bash(cp -r 'C:/Users/quent/AppData/Local/BraveSoftware/Brave-Browser/User Data/Default/Local Extension Settings/dhdgffkkebhmkfjojejmpbldmpobfkfo' C:/Users/quent/AppData/Local/Temp/claude/D--repos-socket-function/117d10bd-31c6-4c68-938d-1fff47788fa6/scratchpad/tm-recover/LocalExtSettings)"
|
|
5
|
+
"Bash(cp -r 'C:/Users/quent/AppData/Local/BraveSoftware/Brave-Browser/User Data/Default/Local Extension Settings/dhdgffkkebhmkfjojejmpbldmpobfkfo' C:/Users/quent/AppData/Local/Temp/claude/D--repos-socket-function/117d10bd-31c6-4c68-938d-1fff47788fa6/scratchpad/tm-recover/LocalExtSettings)",
|
|
6
|
+
"Bash(yarn test-dns-claude *)",
|
|
7
|
+
"Bash(yarn type *)"
|
|
6
8
|
]
|
|
7
9
|
}
|
|
8
10
|
}
|
package/index.d.ts
CHANGED
|
@@ -852,6 +852,25 @@ declare module "socket-function/src/createSingleton" {
|
|
|
852
852
|
|
|
853
853
|
}
|
|
854
854
|
|
|
855
|
+
declare module "socket-function/src/dnsCache" {
|
|
856
|
+
/// <reference types="node" />
|
|
857
|
+
import * as net from "net";
|
|
858
|
+
interface DNSRecord {
|
|
859
|
+
address: string;
|
|
860
|
+
family: number;
|
|
861
|
+
}
|
|
862
|
+
export declare function resolveHost(hostname: string, family?: number): Promise<DNSRecord[]>;
|
|
863
|
+
export declare const dnsCacheLookup: net.LookupFunction;
|
|
864
|
+
export declare function reportConnectionFailure(hostname: string): Promise<boolean>;
|
|
865
|
+
export {};
|
|
866
|
+
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
declare module "socket-function/src/dnsCacheTest" {
|
|
870
|
+
export {};
|
|
871
|
+
|
|
872
|
+
}
|
|
873
|
+
|
|
855
874
|
declare module "socket-function/src/fixLargeNetworkCalls" {
|
|
856
875
|
export declare function markArrayAsSplitable<T>(data: T[]): T[];
|
|
857
876
|
export declare function isSplitableArray<T>(data: T): data is T & (unknown[]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "socket-function",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.17",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"dependencies": {
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"generate-index-dts": "node ./generateIndexDts.js",
|
|
31
31
|
"update-types": "yarn emit-dts && yarn generate-index-dts",
|
|
32
32
|
"prepublishOnly": "yarn update-types",
|
|
33
|
-
"testsni": "yarn typenode ./src/sniTest.ts"
|
|
33
|
+
"testsni": "yarn typenode ./src/sniTest.ts",
|
|
34
|
+
"test-dns-claude": "yarn typenode ./src/dnsCacheTest.ts"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@types/cookie": "^0.5.1",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import * as net from "net";
|
|
3
|
+
interface DNSRecord {
|
|
4
|
+
address: string;
|
|
5
|
+
family: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function resolveHost(hostname: string, family?: number): Promise<DNSRecord[]>;
|
|
8
|
+
export declare const dnsCacheLookup: net.LookupFunction;
|
|
9
|
+
export declare function reportConnectionFailure(hostname: string): Promise<boolean>;
|
|
10
|
+
export {};
|
package/src/dnsCache.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import * as dns from "dns";
|
|
2
|
+
import * as net from "net";
|
|
3
|
+
import { delay } from "./batching";
|
|
4
|
+
|
|
5
|
+
// Node's default `dns.lookup` (getaddrinfo) delegates to glibc on Linux, which caches certain
|
|
6
|
+
// failures (notably EAI_AGAIN from a temporarily unreachable resolver) effectively forever within
|
|
7
|
+
// the process. Once poisoned, every subsequent connection to that host keeps failing even after the
|
|
8
|
+
// network recovers. To avoid this we resolve names ourselves via `dns.resolve*` (which talks to the
|
|
9
|
+
// resolver directly and keeps no such sticky failure cache) and maintain our own cache that we can
|
|
10
|
+
// invalidate on demand. httpsRequest drives that invalidation: on a connection-establishment error it
|
|
11
|
+
// asks us to re-resolve, then retries against the fresh address.
|
|
12
|
+
|
|
13
|
+
// How long a successful resolution is trusted before we resolve again. Node by default does no positive
|
|
14
|
+
// caching at all — it calls getaddrinfo for every single connection — so any cache we add is strictly
|
|
15
|
+
// better than the baseline; even a 1s TTL would be an improvement. (The failure case is the exception:
|
|
16
|
+
// glibc caches certain resolution failures process-forever, which is the whole reason this module exists.)
|
|
17
|
+
const CACHE_TTL = 60 * 1000;
|
|
18
|
+
// A single resolution keeps retrying for up to this long / this many attempts before giving up. We very
|
|
19
|
+
// rarely hit genuinely invalid domains, so we happily trade latency for riding out flaky resolvers.
|
|
20
|
+
const RESOLVE_MAX_TRIES = 10;
|
|
21
|
+
const RESOLVE_MAX_DURATION = 5 * 1000;
|
|
22
|
+
const RESOLVE_RETRY_INTERVAL = 500;
|
|
23
|
+
// Minimum spacing between forced re-resolutions of the same host. This both protects the resolver from a
|
|
24
|
+
// retry storm and defines when a re-resolution is pointless: if we re-resolved this recently, retrying
|
|
25
|
+
// the request would just reuse the same fresh answer, so httpsRequest is told not to bother.
|
|
26
|
+
const RERESOLVE_THROTTLE = 5 * 1000;
|
|
27
|
+
// How long a failed resolution is remembered. Without this, a genuinely unresolvable host would re-run the
|
|
28
|
+
// full (already-exhaustive) 5s resolve loop on every retry and every parallel caller — the resolve loop
|
|
29
|
+
// takes as long as the throttle window, so the throttle alone never engages. A failure that just survived
|
|
30
|
+
// RESOLVE_MAX_TRIES won't differ if we immediately try again, so we serve it from cache instead.
|
|
31
|
+
const NEGATIVE_CACHE_TTL = 5 * 1000;
|
|
32
|
+
|
|
33
|
+
interface DNSRecord {
|
|
34
|
+
address: string;
|
|
35
|
+
// 4 or 6
|
|
36
|
+
family: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface DNSEntry {
|
|
40
|
+
hostname: string;
|
|
41
|
+
records?: DNSRecord[];
|
|
42
|
+
resolvedAt: number;
|
|
43
|
+
// Shared by all callers that arrive while a resolution is in flight, so parallel requests to the
|
|
44
|
+
// same host block on a single resolution rather than each firing their own.
|
|
45
|
+
resolving?: Promise<DNSRecord[]>;
|
|
46
|
+
lastReresolve: number;
|
|
47
|
+
// Negative cache: the last resolution failure and when it completed, so recent failures short-circuit
|
|
48
|
+
// rather than re-running the resolve loop. Kept as the real Error so we rethrow it with its code intact.
|
|
49
|
+
lastError?: Error;
|
|
50
|
+
failedAt?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const entries = new Map<string, DNSEntry>();
|
|
54
|
+
function getEntry(hostname: string): DNSEntry {
|
|
55
|
+
let entry = entries.get(hostname);
|
|
56
|
+
if (!entry) {
|
|
57
|
+
entry = { hostname, resolvedAt: 0, lastReresolve: 0 };
|
|
58
|
+
entries.set(hostname, entry);
|
|
59
|
+
}
|
|
60
|
+
return entry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function filterFamily(records: DNSRecord[], family: number): DNSRecord[] {
|
|
64
|
+
if (!family) return records;
|
|
65
|
+
return records.filter(r => r.family === family);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Resolves both address families directly (ignoring the requested family so the cache is family-agnostic;
|
|
69
|
+
// callers filter afterwards). Falls back to getaddrinfo for names dns.resolve can't see — IP literals,
|
|
70
|
+
// localhost, and anything in /etc/hosts.
|
|
71
|
+
async function resolveOnce(hostname: string): Promise<DNSRecord[]> {
|
|
72
|
+
let ipFamily = net.isIP(hostname);
|
|
73
|
+
if (ipFamily) {
|
|
74
|
+
return [{ address: hostname, family: ipFamily }];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let records: DNSRecord[] = [];
|
|
78
|
+
await Promise.all([
|
|
79
|
+
dns.promises.resolve4(hostname).then(
|
|
80
|
+
addresses => { for (let address of addresses) records.push({ address, family: 4 }); },
|
|
81
|
+
// A host having no A (or no AAAA) record is normal, not a failure of the whole resolution.
|
|
82
|
+
() => { }
|
|
83
|
+
),
|
|
84
|
+
dns.promises.resolve6(hostname).then(
|
|
85
|
+
addresses => { for (let address of addresses) records.push({ address, family: 6 }); },
|
|
86
|
+
() => { }
|
|
87
|
+
),
|
|
88
|
+
]);
|
|
89
|
+
if (records.length > 0) {
|
|
90
|
+
return records;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// dns.resolve* ignores /etc/hosts and the `localhost` entry, so fall back to getaddrinfo. We only
|
|
94
|
+
// reach the sticky-failure-caching path when our own resolution found nothing, which is exactly the
|
|
95
|
+
// case where /etc/hosts is the likely source of truth.
|
|
96
|
+
let fallback = await dns.promises.lookup(hostname, { all: true });
|
|
97
|
+
return fallback.map(r => ({ address: r.address, family: r.family }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function resolveFresh(hostname: string): Promise<DNSRecord[]> {
|
|
101
|
+
let start = Date.now();
|
|
102
|
+
let attempt = 0;
|
|
103
|
+
let lastError: unknown;
|
|
104
|
+
while (true) {
|
|
105
|
+
attempt++;
|
|
106
|
+
try {
|
|
107
|
+
let records = await resolveOnce(hostname);
|
|
108
|
+
if (records.length > 0) {
|
|
109
|
+
return records;
|
|
110
|
+
}
|
|
111
|
+
lastError = new Error(`No DNS records found for ${hostname}`);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
lastError = e;
|
|
114
|
+
}
|
|
115
|
+
if (attempt >= RESOLVE_MAX_TRIES) break;
|
|
116
|
+
if (Date.now() - start >= RESOLVE_MAX_DURATION) break;
|
|
117
|
+
await delay(RESOLVE_RETRY_INTERVAL);
|
|
118
|
+
}
|
|
119
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function startResolve(entry: DNSEntry): Promise<DNSRecord[]> {
|
|
123
|
+
let resolving = (async () => {
|
|
124
|
+
try {
|
|
125
|
+
let records = await resolveFresh(entry.hostname);
|
|
126
|
+
entry.records = records;
|
|
127
|
+
entry.resolvedAt = Date.now();
|
|
128
|
+
entry.lastError = undefined;
|
|
129
|
+
entry.failedAt = undefined;
|
|
130
|
+
return records;
|
|
131
|
+
} catch (e) {
|
|
132
|
+
entry.records = undefined;
|
|
133
|
+
entry.lastError = e instanceof Error ? e : new Error(String(e));
|
|
134
|
+
entry.failedAt = Date.now();
|
|
135
|
+
throw entry.lastError;
|
|
136
|
+
} finally {
|
|
137
|
+
entry.resolving = undefined;
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
entry.resolving = resolving;
|
|
141
|
+
return resolving;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function resolveHost(hostname: string, family = 0): Promise<DNSRecord[]> {
|
|
145
|
+
let entry = getEntry(hostname);
|
|
146
|
+
if (entry.records && Date.now() - entry.resolvedAt < CACHE_TTL) {
|
|
147
|
+
return filterFamily(entry.records, family);
|
|
148
|
+
}
|
|
149
|
+
// Serve a recent failure from the negative cache rather than re-running the exhaustive resolve loop.
|
|
150
|
+
if (entry.lastError && entry.failedAt !== undefined && Date.now() - entry.failedAt < NEGATIVE_CACHE_TTL) {
|
|
151
|
+
throw entry.lastError;
|
|
152
|
+
}
|
|
153
|
+
let resolving = entry.resolving ?? startResolve(entry);
|
|
154
|
+
return filterFamily(await resolving, family);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A `lookup` function matching Node's net/http contract, so we can hand it to http(s).request and have
|
|
158
|
+
// the socket use our cache instead of getaddrinfo. Always resolves via our cache; happy eyeballs
|
|
159
|
+
// (autoSelectFamily) then races the returned addresses.
|
|
160
|
+
export const dnsCacheLookup: net.LookupFunction = function dnsCacheLookup(hostname, options, callback) {
|
|
161
|
+
let rawFamily = typeof options === "number" ? options : options.family ?? 0;
|
|
162
|
+
let family = rawFamily === "IPv4" ? 4 : rawFamily === "IPv6" ? 6 : rawFamily;
|
|
163
|
+
let all = typeof options === "object" && options.all;
|
|
164
|
+
resolveHost(hostname, family).then(
|
|
165
|
+
records => {
|
|
166
|
+
if (all) {
|
|
167
|
+
callback(null, records.map(r => ({ address: r.address, family: r.family })));
|
|
168
|
+
} else {
|
|
169
|
+
let record = records[0];
|
|
170
|
+
callback(null, record.address, record.family);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
(err: NodeJS.ErrnoException) => callback(err, "", undefined)
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Called by httpsRequest when a connection could not be established. Forces a throttled re-resolution and
|
|
178
|
+
// reports whether retrying is worthwhile. Returns false when we re-resolved too recently — in that case
|
|
179
|
+
// the cache already holds the freshest answer we can get, so retrying would hit the same address.
|
|
180
|
+
export async function reportConnectionFailure(hostname: string): Promise<boolean> {
|
|
181
|
+
let entry = getEntry(hostname);
|
|
182
|
+
|
|
183
|
+
// Another failure already kicked off a re-resolution; block on it and then retry, so parallel
|
|
184
|
+
// failures for the same host share a single resolution.
|
|
185
|
+
if (entry.resolving) {
|
|
186
|
+
await entry.resolving.catch(() => { });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The resolver itself just failed exhaustively; re-resolving now would only reproduce that failure, so
|
|
191
|
+
// there's no point making httpsRequest wait and retry.
|
|
192
|
+
if (entry.lastError && entry.failedAt !== undefined && Date.now() - entry.failedAt < NEGATIVE_CACHE_TTL) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (Date.now() - entry.lastReresolve < RERESOLVE_THROTTLE) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
entry.lastReresolve = Date.now();
|
|
200
|
+
entry.records = undefined;
|
|
201
|
+
await startResolve(entry).catch(() => { });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as net from "net";
|
|
2
|
+
import * as tls from "tls";
|
|
3
|
+
import * as dns from "dns";
|
|
4
|
+
import { httpsRequest } from "./https";
|
|
5
|
+
|
|
6
|
+
const HOST = "a.querysubtest.com";
|
|
7
|
+
// 443 is expected to serve HTTPS; the rest are expected to have nothing listening.
|
|
8
|
+
const OPEN_PORT = 443;
|
|
9
|
+
const CLOSED_PORTS = [444, 8443, 12345];
|
|
10
|
+
// Hostnames that cannot resolve: an NXDOMAIN subdomain of a real zone, and the reserved .invalid TLD.
|
|
11
|
+
const BAD_HOSTS = ["nonexistent-abc123.querysubtest.com", "this-cannot-resolve.invalid"];
|
|
12
|
+
|
|
13
|
+
// Probe the raw socket layer directly, so we can see exactly which errno a not-listening port produces
|
|
14
|
+
// (the specific concern: ECONNRESET vs ECONNREFUSED/ETIMEDOUT). This bypasses our retry/DNS logic.
|
|
15
|
+
async function probeRaw(host: string, port: number, useTls: boolean): Promise<string> {
|
|
16
|
+
return new Promise(resolve => {
|
|
17
|
+
let start = Date.now();
|
|
18
|
+
let socket = useTls
|
|
19
|
+
? tls.connect({ host, port, servername: host, rejectUnauthorized: false })
|
|
20
|
+
: net.connect({ host, port });
|
|
21
|
+
let done = (result: string) => {
|
|
22
|
+
let elapsed = Date.now() - start;
|
|
23
|
+
socket.destroy();
|
|
24
|
+
resolve(`${result} (${elapsed}ms)`);
|
|
25
|
+
};
|
|
26
|
+
socket.once(useTls ? "secureConnect" : "connect", () => done("CONNECTED"));
|
|
27
|
+
socket.once("error", (e: NodeJS.ErrnoException) => done(`error code=${e.code} message=${e.message}`));
|
|
28
|
+
setTimeout(() => done("TIMED OUT after 10s"), 10_000);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function tryHttps(url: string): Promise<string> {
|
|
33
|
+
let start = Date.now();
|
|
34
|
+
try {
|
|
35
|
+
let result = await httpsRequest(url);
|
|
36
|
+
return `OK ${result.length} bytes (${Date.now() - start}ms)`;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
let err = e as NodeJS.ErrnoException;
|
|
39
|
+
return `FAILED code=${err.code} (${Date.now() - start}ms)\n ${(err.stack ?? String(err)).split("\n").slice(0, 3).join("\n ")}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Show what dns.resolve4 does with a bad name directly, so we can see the errno our resolve loop retries on.
|
|
44
|
+
async function probeResolve(host: string): Promise<string> {
|
|
45
|
+
let start = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
let addrs = await dns.promises.resolve4(host);
|
|
48
|
+
return `resolved ${JSON.stringify(addrs)} (${Date.now() - start}ms)`;
|
|
49
|
+
} catch (e) {
|
|
50
|
+
let err = e as NodeJS.ErrnoException;
|
|
51
|
+
return `error code=${err.code} (${Date.now() - start}ms)`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
console.log(`=== Raw socket probes (which errno does a closed port give?) ===`);
|
|
57
|
+
console.log(` ${HOST}:${OPEN_PORT} (tls) -> ${await probeRaw(HOST, OPEN_PORT, true)}`);
|
|
58
|
+
for (let port of CLOSED_PORTS) {
|
|
59
|
+
console.log(` ${HOST}:${port} (tcp) -> ${await probeRaw(HOST, port, false)}`);
|
|
60
|
+
console.log(` ${HOST}:${port} (tls) -> ${await probeRaw(HOST, port, true)}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`\n=== httpsRequest (full path: DNS cache + retry) ===`);
|
|
64
|
+
console.log(` https://${HOST}:${OPEN_PORT}/ -> ${await tryHttps(`https://${HOST}:${OPEN_PORT}/`)}`);
|
|
65
|
+
for (let port of CLOSED_PORTS) {
|
|
66
|
+
console.log(` https://${HOST}:${port}/ -> ${await tryHttps(`https://${HOST}:${port}/`)}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log(`\n=== Bad hostnames (resolve loop + ENOTFOUND retry path) ===`);
|
|
70
|
+
for (let host of BAD_HOSTS) {
|
|
71
|
+
console.log(` dns.resolve4(${host}) -> ${await probeResolve(host)}`);
|
|
72
|
+
console.log(` https://${host}/ -> ${await tryHttps(`https://${host}/`)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().then(() => process.exit(0), e => { console.error(e.stack ?? e); process.exit(1); });
|
package/src/https.ts
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
import * as https from "https";
|
|
2
2
|
import * as http from "http";
|
|
3
3
|
import { isNode } from "./misc";
|
|
4
|
+
import { delay } from "./batching";
|
|
5
|
+
import { dnsCacheLookup, reportConnectionFailure } from "./dnsCache";
|
|
4
6
|
|
|
5
7
|
const textEncoder = new TextEncoder();
|
|
6
8
|
const textDecoder = new TextDecoder();
|
|
7
9
|
|
|
10
|
+
// Error codes that mean the TCP connection was never established (so no request bytes reached the server
|
|
11
|
+
// and a full retry — even of a non-idempotent method — is safe). These are the failures a stale DNS
|
|
12
|
+
// answer produces, and the only ones we re-resolve + retry on.
|
|
13
|
+
const CONNECTION_ERROR_CODES = new Set([
|
|
14
|
+
"EAI_AGAIN",
|
|
15
|
+
"ENOTFOUND",
|
|
16
|
+
"ECONNREFUSED",
|
|
17
|
+
"ETIMEDOUT",
|
|
18
|
+
"EHOSTUNREACH",
|
|
19
|
+
"ENETUNREACH",
|
|
20
|
+
"EHOSTDOWN",
|
|
21
|
+
"EADDRNOTAVAIL",
|
|
22
|
+
]);
|
|
23
|
+
// Upper bound on retries; the real limiter is the DNS re-resolve throttle, which stops us once a fresh
|
|
24
|
+
// answer can't differ from what we already tried.
|
|
25
|
+
const CONNECTION_RETRY_COUNT = 3;
|
|
26
|
+
// A short pause before retrying, giving a transient network/resolver blip time to clear.
|
|
27
|
+
const CONNECTION_RETRY_WAIT = 500;
|
|
28
|
+
|
|
29
|
+
function isConnectionError(error: unknown): boolean {
|
|
30
|
+
if (!error || typeof error !== "object") return false;
|
|
31
|
+
let code = (error as NodeJS.ErrnoException).code;
|
|
32
|
+
if (code && CONNECTION_ERROR_CODES.has(code)) return true;
|
|
33
|
+
// autoSelectFamily surfaces all-candidates-failed as an AggregateError; treat it as connection-level
|
|
34
|
+
// if any underlying attempt was.
|
|
35
|
+
let aggregate = (error as AggregateError).errors;
|
|
36
|
+
if (Array.isArray(aggregate) && aggregate.some(isConnectionError)) return true;
|
|
37
|
+
let cause = (error as { cause?: unknown }).cause;
|
|
38
|
+
if (cause && cause !== error) return isConnectionError(cause);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
8
42
|
export function httpsRequest(
|
|
9
43
|
url: string,
|
|
10
44
|
payload?: Buffer | Buffer[],
|
|
@@ -24,50 +58,78 @@ export function httpsRequest(
|
|
|
24
58
|
if (urlObj.port) {
|
|
25
59
|
port = +urlObj.port;
|
|
26
60
|
}
|
|
27
|
-
return new Promise<Buffer>((resolve, reject) => {
|
|
28
|
-
let httpRequest = requestor.request(
|
|
29
|
-
urlObj + "",
|
|
30
|
-
{
|
|
31
|
-
method,
|
|
32
|
-
headers: config?.headers,
|
|
33
|
-
// NOTE: We get a lot of backblaze errors when we try to re-use connections. It might be faster,
|
|
34
|
-
// but... anything that cares about speed should use websockets anyways...
|
|
35
|
-
agent: new requestor.Agent({ keepAlive: false }),
|
|
36
|
-
},
|
|
37
|
-
async httpResponse => {
|
|
38
|
-
let data: Buffer[] = [];
|
|
39
|
-
httpResponse.on("data", chunk => {
|
|
40
|
-
data.push(chunk);
|
|
41
|
-
});
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
let attempt = 0;
|
|
63
|
+
while (true) {
|
|
64
|
+
try {
|
|
65
|
+
return await sendOnce();
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (attempt >= CONNECTION_RETRY_COUNT || !isConnectionError(e)) {
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
attempt++;
|
|
71
|
+
// A brief pause lets a transient blip settle, then we ask the DNS cache to re-resolve.
|
|
72
|
+
// If it declines (throttled — a fresh answer would match what just failed), retrying
|
|
73
|
+
// is pointless, so we surface the original error instead of looping.
|
|
74
|
+
await delay(CONNECTION_RETRY_WAIT);
|
|
75
|
+
let willReresolve = await reportConnectionFailure(urlObj.hostname);
|
|
76
|
+
if (!willReresolve) {
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sendOnce() {
|
|
83
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
84
|
+
let httpRequest = requestor.request(
|
|
85
|
+
urlObj + "",
|
|
86
|
+
{
|
|
87
|
+
method,
|
|
88
|
+
headers: config?.headers,
|
|
89
|
+
// NOTE: We get a lot of backblaze errors when we try to re-use connections. It might be faster,
|
|
90
|
+
// but... anything that cares about speed should use websockets anyways...
|
|
91
|
+
agent: new requestor.Agent({ keepAlive: false }),
|
|
92
|
+
// Resolve via our own DNS cache so we can bypass glibc's sticky failure caching and
|
|
93
|
+
// re-resolve on demand. autoSelectFamily forces happy eyeballs across the returned
|
|
94
|
+
// addresses even on older Node.
|
|
95
|
+
lookup: dnsCacheLookup,
|
|
96
|
+
autoSelectFamily: true,
|
|
97
|
+
} as https.RequestOptions,
|
|
98
|
+
async httpResponse => {
|
|
99
|
+
let data: Buffer[] = [];
|
|
100
|
+
httpResponse.on("data", chunk => {
|
|
101
|
+
data.push(chunk);
|
|
46
102
|
});
|
|
47
|
-
});
|
|
48
103
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
104
|
+
await new Promise<void>(resolve => {
|
|
105
|
+
httpResponse.on("end", () => {
|
|
106
|
+
resolve();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (!httpResponse.statusCode?.toString().startsWith("2")) {
|
|
111
|
+
reject(new Error(`Error for ${url}, ${httpResponse.statusCode} ${httpResponse.statusMessage}\n` + Buffer.concat(data).toString()));
|
|
112
|
+
} else {
|
|
113
|
+
resolve(Buffer.concat(data));
|
|
114
|
+
}
|
|
53
115
|
}
|
|
116
|
+
);
|
|
117
|
+
if (config?.cancel) {
|
|
118
|
+
void config.cancel.finally(() => {
|
|
119
|
+
httpRequest.destroy();
|
|
120
|
+
});
|
|
54
121
|
}
|
|
55
|
-
|
|
56
|
-
if (config?.cancel) {
|
|
57
|
-
void config.cancel.finally(() => {
|
|
58
|
-
httpRequest.destroy();
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
httpRequest.on("error", reject);
|
|
122
|
+
httpRequest.on("error", reject);
|
|
62
123
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
124
|
+
if (payload) {
|
|
125
|
+
if (Array.isArray(payload)) {
|
|
126
|
+
payload = Buffer.concat(payload);
|
|
127
|
+
}
|
|
128
|
+
httpRequest.write(payload);
|
|
66
129
|
}
|
|
67
|
-
httpRequest.
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
});
|
|
130
|
+
httpRequest.end();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
71
133
|
})();
|
|
72
134
|
|
|
73
135
|
} else {
|