jd-intel 0.9.0 → 0.11.0
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/README.md +16 -1
- package/package.json +1 -1
- package/src/adapters/ashby.js +18 -76
- package/src/adapters/greenhouse.js +96 -34
- package/src/adapters/index.js +24 -14
- package/src/adapters/lever.js +13 -10
- package/src/adapters/recruitee.js +19 -9
- package/src/adapters/smartrecruiters.js +88 -38
- package/src/adapters/teamtailor.js +36 -15
- package/src/adapters/workday.js +97 -81
- package/src/boards.js +80 -0
- package/src/cli.js +42 -30
- package/src/errors.js +14 -0
- package/src/filters.js +109 -26
- package/src/http.js +184 -0
- package/src/index.js +179 -57
- package/src/normalizer.js +16 -2
- package/src/registry.js +80 -31
package/src/cli.js
CHANGED
|
@@ -11,38 +11,44 @@
|
|
|
11
11
|
|
|
12
12
|
import { realpathSync } from 'node:fs';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { parseArgs } from 'node:util';
|
|
14
15
|
import { fetchJobs } from './index.js';
|
|
15
|
-
import {
|
|
16
|
+
import { detectAtsDetailed, searchRegistry } from './registry.js';
|
|
16
17
|
|
|
17
18
|
const [,, command, ...args] = process.argv;
|
|
18
19
|
|
|
19
20
|
async function main() {
|
|
20
21
|
switch (command) {
|
|
21
22
|
case 'fetch': {
|
|
22
|
-
const
|
|
23
|
+
const string = { type: 'string' };
|
|
24
|
+
const { values: flags, positionals } = parseArgs({
|
|
25
|
+
args,
|
|
26
|
+
allowPositionals: true,
|
|
27
|
+
options: {
|
|
28
|
+
ats: string, 'title-filter': string, filter: string, 'posted-within-days': string,
|
|
29
|
+
'location-include': string, 'location-exclude': string, limit: string,
|
|
30
|
+
'workday-tenant': string, 'workday-env': string, 'workday-site': string,
|
|
31
|
+
json: { type: 'boolean' },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const company = positionals[0];
|
|
23
35
|
if (!company) { console.error('Usage: jd-intel fetch <company> [--ats <platform>] (omit --ats to auto-detect; run "jd-intel" for the platform list)'); process.exit(1); }
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const locIncludeRaw = getArg('--location-include');
|
|
34
|
-
const locationIncludes = locIncludeRaw ? locIncludeRaw.split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
|
35
|
-
const locExcludeRaw = getArg('--location-exclude');
|
|
36
|
-
const locationExcludes = locExcludeRaw ? locExcludeRaw.split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
|
37
|
-
const limitRaw = getArg('--limit');
|
|
38
|
-
const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
|
|
36
|
+
const number = (v) => (v !== undefined ? Number(v) : undefined);
|
|
37
|
+
const list = (v) => (v ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined);
|
|
38
|
+
let ats = flags.ats;
|
|
39
|
+
const titleFilter = flags['title-filter'];
|
|
40
|
+
const filter = flags.filter;
|
|
41
|
+
const postedWithinDays = number(flags['posted-within-days']);
|
|
42
|
+
const locationIncludes = list(flags['location-include']);
|
|
43
|
+
const locationExcludes = list(flags['location-exclude']);
|
|
44
|
+
const limit = number(flags.limit);
|
|
39
45
|
|
|
40
46
|
// Workday is keyed by a {tenant, env, site} triple, not a slug.
|
|
41
47
|
// Supplying it here makes a Workday board reachable without a
|
|
42
48
|
// registry entry; presence of the flags infers --ats workday.
|
|
43
|
-
const wdTenant =
|
|
44
|
-
const wdEnv =
|
|
45
|
-
const wdSite =
|
|
49
|
+
const wdTenant = flags['workday-tenant'];
|
|
50
|
+
const wdEnv = flags['workday-env'];
|
|
51
|
+
const wdSite = flags['workday-site'];
|
|
46
52
|
let config;
|
|
47
53
|
if (wdTenant || wdEnv || wdSite) {
|
|
48
54
|
if (!wdTenant || !wdEnv || !wdSite) {
|
|
@@ -91,8 +97,10 @@ async function main() {
|
|
|
91
97
|
const loc = job.location ? ` | ${job.location}` : '';
|
|
92
98
|
const dept = job.department ? ` [${job.department}]` : '';
|
|
93
99
|
console.log(` ${job.title}${dept}${loc}${salary}`);
|
|
94
|
-
console.log(` ${job.url}`);
|
|
95
|
-
if (job.
|
|
100
|
+
console.log(` ${job.url || '(no URL: posting not read)'}`);
|
|
101
|
+
if (job.content?.status === 'missing') {
|
|
102
|
+
console.log(` posting not read: ${job.content.reason}`);
|
|
103
|
+
} else if (job.description) {
|
|
96
104
|
const preview = job.description.substring(0, 120).replace(/\n/g, ' ');
|
|
97
105
|
console.log(` ${preview}...`);
|
|
98
106
|
}
|
|
@@ -103,7 +111,7 @@ async function main() {
|
|
|
103
111
|
console.log(` ... and ${jobs.length - 20} more. Use --json for full output.`);
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
if (
|
|
114
|
+
if (flags.json) {
|
|
107
115
|
console.log(JSON.stringify(jobs, null, 2));
|
|
108
116
|
}
|
|
109
117
|
break;
|
|
@@ -113,13 +121,17 @@ async function main() {
|
|
|
113
121
|
const company = args[0];
|
|
114
122
|
if (!company) { console.error('Usage: jd-intel detect <company>'); process.exit(1); }
|
|
115
123
|
console.log(`Detecting ATS for ${company}...`);
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
console.log('
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
const { boards, failed } = await detectAtsDetailed(company);
|
|
125
|
+
for (const b of boards) {
|
|
126
|
+
console.log(` Found: ${b.ats} (slug: ${b.slug}, ${b.source === 'registry' ? 'in the registry' : 'live probe'})`);
|
|
127
|
+
}
|
|
128
|
+
for (const f of failed) {
|
|
129
|
+
console.log(` Could not check ${f.ats}: ${f.message}`);
|
|
130
|
+
}
|
|
131
|
+
if (boards.length === 0) {
|
|
132
|
+
console.log(failed.length > 0
|
|
133
|
+
? 'No ATS board confirmed. At least one check failed, so this is not a definite miss. Retry in a moment.'
|
|
134
|
+
: 'No ATS board found for this company.');
|
|
123
135
|
}
|
|
124
136
|
break;
|
|
125
137
|
}
|
package/src/errors.js
CHANGED
|
@@ -34,6 +34,20 @@ export class AtsError extends Error {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Thrown when a call cannot proceed because of its arguments: a missing
|
|
39
|
+
* company, an unknown ATS name, a regex that does not compile. Carries
|
|
40
|
+
* `code: 'invalid_args'` so callers tell a bad request from a failed fetch
|
|
41
|
+
* (AtsError) without reading the message.
|
|
42
|
+
*/
|
|
43
|
+
export class ArgumentError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'ArgumentError';
|
|
47
|
+
this.code = ERROR_CODES.INVALID_ARGS;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
/**
|
|
38
52
|
* Helper for adapters: build an AtsError from an HTTP status (429 => rate
|
|
39
53
|
* limited, anything else => unreachable) with the given message.
|
package/src/filters.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ArgumentError } from './errors.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Apply filters to a list of normalized jobs.
|
|
3
5
|
*
|
|
@@ -23,30 +25,32 @@ export function applyFilters(jobs, options = {}) {
|
|
|
23
25
|
* @returns {{ jobs: Array, total_matched: number }}
|
|
24
26
|
*/
|
|
25
27
|
export function applyFiltersDetailed(jobs, options = {}) {
|
|
26
|
-
const {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
const { order = 'newest', offset = 0, limit = 100 } = options;
|
|
29
|
+
const matched = filterJobs(jobs, options);
|
|
30
|
+
return { jobs: pageJobs(matched, { order, offset, limit }), total_matched: matched.length };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The filter step on its own: every job that passes titleFilter, filter,
|
|
35
|
+
* postedWithinDays and the location filters, in the order given. No sort
|
|
36
|
+
* and no paging, so fetchJobsDetailed can count the matches per board
|
|
37
|
+
* before the page cut removes them.
|
|
38
|
+
*/
|
|
39
|
+
export function filterJobs(jobs, options = {}) {
|
|
40
|
+
const { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes } = options;
|
|
41
|
+
const { title, topic } = compileFilterPatterns({ titleFilter, filter });
|
|
36
42
|
|
|
37
43
|
let result = jobs;
|
|
38
44
|
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
result = result.filter(j => pattern.test(j.title || ''));
|
|
45
|
+
if (title) {
|
|
46
|
+
result = result.filter(j => title.test(j.title || ''));
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
if (
|
|
45
|
-
const pattern = new RegExp(filter, 'i');
|
|
49
|
+
if (topic) {
|
|
46
50
|
result = result.filter(j =>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
topic.test(j.title || '') ||
|
|
52
|
+
topic.test(j.department || '') ||
|
|
53
|
+
topic.test(j.description || '')
|
|
50
54
|
);
|
|
51
55
|
}
|
|
52
56
|
|
|
@@ -69,7 +73,14 @@ export function applyFiltersDetailed(jobs, options = {}) {
|
|
|
69
73
|
result = result.filter(j => !jobLocations(j).every(loc => matchers.some(m => m(loc))));
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Sort, then cut the page (see applyFiltersDetailed for the order rules).
|
|
81
|
+
*/
|
|
82
|
+
export function pageJobs(jobs, { order = 'newest', offset = 0, limit = 100 } = {}) {
|
|
83
|
+
let result = jobs;
|
|
73
84
|
|
|
74
85
|
if (order !== 'board') {
|
|
75
86
|
result = [...result].sort(byNewest);
|
|
@@ -77,11 +88,30 @@ export function applyFiltersDetailed(jobs, options = {}) {
|
|
|
77
88
|
|
|
78
89
|
const start = typeof offset === 'number' && offset > 0 ? offset : 0;
|
|
79
90
|
const end = typeof limit === 'number' ? start + limit : undefined;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
return result.slice(start, end);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compile the two regex arguments, or throw ArgumentError naming the one
|
|
96
|
+
* that does not compile. Both are case-insensitive. fetchJobsDetailed calls
|
|
97
|
+
* this before its first request, so a bad pattern is reported as a bad
|
|
98
|
+
* argument and costs no upstream traffic.
|
|
99
|
+
*
|
|
100
|
+
* @returns {{ title: RegExp|null, topic: RegExp|null }}
|
|
101
|
+
*/
|
|
102
|
+
export function compileFilterPatterns({ titleFilter, filter } = {}) {
|
|
103
|
+
return {
|
|
104
|
+
title: titleFilter ? compilePattern(titleFilter, 'titleFilter') : null,
|
|
105
|
+
topic: filter ? compilePattern(filter, 'filter') : null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
83
108
|
|
|
84
|
-
|
|
109
|
+
function compilePattern(source, name) {
|
|
110
|
+
try {
|
|
111
|
+
return new RegExp(source, 'i');
|
|
112
|
+
} catch (err) {
|
|
113
|
+
throw new ArgumentError(`${name}: ${err.message}`);
|
|
114
|
+
}
|
|
85
115
|
}
|
|
86
116
|
|
|
87
117
|
/**
|
|
@@ -118,14 +148,19 @@ function byNewest(a, b) {
|
|
|
118
148
|
}
|
|
119
149
|
|
|
120
150
|
/**
|
|
121
|
-
* Build a matcher for a single location keyword.
|
|
151
|
+
* Build a matcher for a single location keyword. The matcher takes a
|
|
152
|
+
* lowercased location string.
|
|
122
153
|
*
|
|
123
154
|
* Short tokens (≤4 chars) use word-boundary matching to prevent substring
|
|
124
155
|
* collisions like "US" matching "Australia", "Brussels", "Belarus", or "UK"
|
|
125
|
-
* matching "
|
|
156
|
+
* matching "Ukraine". Longer tokens use substring matching so phrases like
|
|
126
157
|
* "United States" can match "United States of America".
|
|
158
|
+
*
|
|
159
|
+
* Exported for the Workday list pre-filter, so one rule (trim, empty
|
|
160
|
+
* keywords never match, word boundaries for short tokens) applies before
|
|
161
|
+
* and after detail hydration (issue #61).
|
|
127
162
|
*/
|
|
128
|
-
function makeLocationMatcher(needle) {
|
|
163
|
+
export function makeLocationMatcher(needle) {
|
|
129
164
|
const lower = (needle || '').toLowerCase().trim();
|
|
130
165
|
if (!lower) return () => false;
|
|
131
166
|
if (lower.length <= 4) {
|
|
@@ -135,3 +170,51 @@ function makeLocationMatcher(needle) {
|
|
|
135
170
|
}
|
|
136
171
|
return (loc) => loc.includes(lower);
|
|
137
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The list pre-filter and detail budget the two-step adapters (Workday,
|
|
176
|
+
* SmartRecruiters) share: narrow the cheap list rows with the filters a row
|
|
177
|
+
* can answer, then bound how many get a detail request.
|
|
178
|
+
*
|
|
179
|
+
* The library re-applies every filter after hydration, so a keep here is
|
|
180
|
+
* never final. `location(row)` returns the lowercased location, or null
|
|
181
|
+
* when the row cannot say where it is (it then stays a candidate through
|
|
182
|
+
* both location filters). `postedWithin(row, days)` decides recency.
|
|
183
|
+
*
|
|
184
|
+
* A description `filter` runs only after hydration, so that case keeps the
|
|
185
|
+
* full `max` budget instead of truncating to the page (which could hydrate
|
|
186
|
+
* rows that all fail the regex while better matches go unscanned). Without
|
|
187
|
+
* one, the budget is the page plus the offset before it. List order is kept.
|
|
188
|
+
*
|
|
189
|
+
* @returns {{ candidates: Array, hydrate: Array }}
|
|
190
|
+
*/
|
|
191
|
+
export function prefilterRows(rows, fc, { title, location, postedWithin, max }) {
|
|
192
|
+
let candidates = rows;
|
|
193
|
+
|
|
194
|
+
if (fc.titleFilter) {
|
|
195
|
+
const re = new RegExp(fc.titleFilter, 'i');
|
|
196
|
+
candidates = candidates.filter(p => re.test(title(p)));
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(fc.locationIncludes) && fc.locationIncludes.length > 0) {
|
|
199
|
+
const inc = fc.locationIncludes.map(makeLocationMatcher);
|
|
200
|
+
candidates = candidates.filter(p => {
|
|
201
|
+
const loc = location(p);
|
|
202
|
+
return loc === null || inc.some(m => m(loc));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(fc.locationExcludes) && fc.locationExcludes.length > 0) {
|
|
206
|
+
const exc = fc.locationExcludes.map(makeLocationMatcher);
|
|
207
|
+
candidates = candidates.filter(p => {
|
|
208
|
+
const loc = location(p);
|
|
209
|
+
return loc === null || !exc.some(m => m(loc));
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (typeof fc.postedWithinDays === 'number') {
|
|
213
|
+
candidates = candidates.filter(p => postedWithin(p, fc.postedWithinDays));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const limit = typeof fc.limit === 'number' && fc.limit > 0 ? fc.limit : 100;
|
|
217
|
+
const skip = typeof fc.offset === 'number' && fc.offset > 0 ? fc.offset : 0;
|
|
218
|
+
const cap = fc.filter ? max : Math.min(skip + limit, max);
|
|
219
|
+
return { candidates, hydrate: candidates.slice(0, cap) };
|
|
220
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { AtsError, ERROR_CODES, atsErrorFromStatus } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one HTTP door for every adapter request (issue #7).
|
|
5
|
+
*
|
|
6
|
+
* atsFetch() wraps the global fetch with the politeness every ATS expects
|
|
7
|
+
* and the failure handling the adapters used to leave out:
|
|
8
|
+
* - a timeout per attempt on the wait for the response to start
|
|
9
|
+
* - retries with exponential backoff and jitter on 429, any 5xx, and
|
|
10
|
+
* network errors (DNS, reset, timeout), honoring Retry-After; a
|
|
11
|
+
* certificate failure is thrown at once, since it cannot pass later
|
|
12
|
+
* - a cap on requests in flight per host
|
|
13
|
+
*
|
|
14
|
+
* Every other status resolves normally, so an adapter keeps its own 404
|
|
15
|
+
* handling. Once the retries are used up the caller gets an AtsError:
|
|
16
|
+
* rate_limited for a 429, ats_unreachable for a 5xx or a network failure.
|
|
17
|
+
* The global fetch is read on every attempt so a test's mock of it applies.
|
|
18
|
+
*
|
|
19
|
+
* The timer stops once the headers are in. Reading the body is the caller's
|
|
20
|
+
* step (resp.json() in the adapter), and it is not timed: a signal left on
|
|
21
|
+
* the request would abort that read too, so a large board on a slow link
|
|
22
|
+
* would fail at the timeout with a raw TimeoutError thrown from resp.json(),
|
|
23
|
+
* outside this retry loop, where master downloaded it fine. undici's own
|
|
24
|
+
* body timeout (300s idle) still ends a stream that stalls.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const DEFAULTS = {
|
|
28
|
+
timeoutMs: 10_000,
|
|
29
|
+
retries: 3, // attempts per request in total; 1 turns retrying off
|
|
30
|
+
perHost: 4, // requests in flight per hostname
|
|
31
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const BASE_BACKOFF_MS = 1000; // 1s, 2s, 4s, ...
|
|
35
|
+
const JITTER_MS = 250;
|
|
36
|
+
const MAX_RETRY_AFTER_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
let settings = { ...DEFAULTS };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Replace the HTTP settings with the defaults plus `overrides`. For tests
|
|
42
|
+
* and scripts. `configureHttp()` restores the defaults. Returns a copy of
|
|
43
|
+
* the settings now in force.
|
|
44
|
+
*/
|
|
45
|
+
export function configureHttp(overrides = {}) {
|
|
46
|
+
settings = { ...DEFAULTS, ...overrides };
|
|
47
|
+
return { ...settings };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* fetch(url, init) with a timeout, retries and a per-host queue.
|
|
52
|
+
*
|
|
53
|
+
* Resolves with the Response for any status that is not retried (2xx, 3xx,
|
|
54
|
+
* and 4xx other than 429). Throws AtsError once the retries are used up on
|
|
55
|
+
* a 429 or 5xx (with `.status`), or on a network error or a timeout waiting
|
|
56
|
+
* for the response to start.
|
|
57
|
+
*/
|
|
58
|
+
export async function atsFetch(url, init = {}) {
|
|
59
|
+
const host = new URL(url).hostname;
|
|
60
|
+
const { retries, sleep, timeoutMs } = settings;
|
|
61
|
+
|
|
62
|
+
for (let attempt = 1; ; attempt++) {
|
|
63
|
+
let resp;
|
|
64
|
+
try {
|
|
65
|
+
resp = await withHostSlot(host, () => fetchWithTimeout(url, init, timeoutMs));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (attempt >= retries || isCertError(err)) {
|
|
68
|
+
const error = new AtsError(
|
|
69
|
+
ERROR_CODES.ATS_UNREACHABLE,
|
|
70
|
+
`${host}: ${describeCause(err, timeoutMs)} after ${attempts(attempt)}`
|
|
71
|
+
);
|
|
72
|
+
error.cause = err;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
await sleep(backoffMs(attempt));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!isRetried(resp.status)) return resp;
|
|
80
|
+
if (attempt >= retries) {
|
|
81
|
+
throw atsErrorFromStatus(resp.status, `${host}: HTTP ${resp.status} after ${attempts(attempt)}`);
|
|
82
|
+
}
|
|
83
|
+
await sleep(retryAfterMs(resp) ?? backoffMs(attempt));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Three-state probe outcome for an adapter's has(): true on 2xx, false on
|
|
89
|
+
* a 404, and an AtsError for anything else (401, 403, ...), so a board the
|
|
90
|
+
* probe could not check never reads as "not here" (issue #55). A 429 or
|
|
91
|
+
* 5xx never reaches this point: atsFetch throws on those itself.
|
|
92
|
+
*/
|
|
93
|
+
export function probeResult(resp, label) {
|
|
94
|
+
if (resp.ok) return true;
|
|
95
|
+
if (resp.status === 404) return false;
|
|
96
|
+
throw atsErrorFromStatus(resp.status, `${label}: ${resp.status}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The abort covers connecting and waiting for the headers, and is cleared as
|
|
100
|
+
// soon as fetch resolves so the body read that follows is never aborted
|
|
101
|
+
// (see the header comment). The reason is a TimeoutError like the one
|
|
102
|
+
// AbortSignal.timeout would raise, so describeCause reads both the same way.
|
|
103
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
const timer = setTimeout(
|
|
106
|
+
() => controller.abort(new DOMException(`Timed out after ${timeoutMs}ms`, 'TimeoutError')),
|
|
107
|
+
timeoutMs
|
|
108
|
+
);
|
|
109
|
+
try {
|
|
110
|
+
return await globalThis.fetch(url, { ...init, signal: controller.signal });
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isRetried(status) {
|
|
117
|
+
return status === 429 || status >= 500;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function backoffMs(attempt) {
|
|
121
|
+
return BASE_BACKOFF_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * JITTER_MS);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Retry-After is either delay-seconds or an HTTP-date. Mocked responses may
|
|
125
|
+
// carry no headers at all.
|
|
126
|
+
function retryAfterMs(resp) {
|
|
127
|
+
const raw = resp.headers?.get?.('retry-after');
|
|
128
|
+
if (!raw) return null;
|
|
129
|
+
const seconds = Number(raw);
|
|
130
|
+
const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(raw) - Date.now();
|
|
131
|
+
if (!Number.isFinite(ms)) return null;
|
|
132
|
+
return Math.min(Math.max(ms, 0), MAX_RETRY_AFTER_MS);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A certificate that fails validation fails the same way on the next
|
|
136
|
+
// attempt, so retrying it only costs time. Seen live: every
|
|
137
|
+
// {slug}.eu.teamtailor.com answers ERR_TLS_CERT_ALTNAME_INVALID.
|
|
138
|
+
const CERT_ERROR = /^(?:ERR_TLS_CERT_ALTNAME_INVALID|CERT_HAS_EXPIRED|CERT_NOT_YET_VALID|DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT(?:_LOCALLY)?)$/;
|
|
139
|
+
|
|
140
|
+
function isCertError(err) {
|
|
141
|
+
return CERT_ERROR.test(err?.cause?.code || '');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// undici reports socket failures as TypeError('fetch failed') with the OS
|
|
145
|
+
// code on `cause`, and rejects an aborted request with the signal's reason,
|
|
146
|
+
// here the TimeoutError from fetchWithTimeout.
|
|
147
|
+
function describeCause(err, timeoutMs) {
|
|
148
|
+
if (err?.name === 'TimeoutError') return `timed out after ${timeoutMs}ms`;
|
|
149
|
+
const detail = err?.cause?.code || err?.cause?.message;
|
|
150
|
+
const message = err?.message || String(err);
|
|
151
|
+
return detail ? `${message} (${detail})` : message;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function attempts(n) {
|
|
155
|
+
return `${n} attempt${n === 1 ? '' : 's'}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Per-host queue. A finished request hands its slot straight to the next
|
|
159
|
+
// waiter (the count never dips in between), so the cap holds even when a new
|
|
160
|
+
// caller arrives while a waiter is being woken.
|
|
161
|
+
const hosts = new Map(); // hostname -> { active, waiting: [resolve] }
|
|
162
|
+
|
|
163
|
+
async function withHostSlot(host, run) {
|
|
164
|
+
let slot = hosts.get(host);
|
|
165
|
+
if (!slot) {
|
|
166
|
+
slot = { active: 0, waiting: [] };
|
|
167
|
+
hosts.set(host, slot);
|
|
168
|
+
}
|
|
169
|
+
if (slot.active >= settings.perHost) {
|
|
170
|
+
await new Promise((resolve) => slot.waiting.push(resolve));
|
|
171
|
+
} else {
|
|
172
|
+
slot.active += 1;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return await run();
|
|
176
|
+
} finally {
|
|
177
|
+
const next = slot.waiting.shift();
|
|
178
|
+
if (next) next();
|
|
179
|
+
else {
|
|
180
|
+
slot.active -= 1;
|
|
181
|
+
if (slot.active === 0) hosts.delete(host);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|