jd-intel 0.8.3 → 0.10.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/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
+ }
package/src/index.js CHANGED
@@ -7,12 +7,40 @@
7
7
  */
8
8
 
9
9
  import { ADAPTERS, ATS_NAMES } from './adapters/index.js';
10
- import { loadRegistry, searchRegistry, detectAts, findAtsBySlug, findEntryBySlug, getRegistrySource } from './registry.js';
11
- import { applyFilters } from './filters.js';
10
+ import { loadRegistry, searchRegistry, detectAts, detectAtsDetailed, findAtsBySlug, findEntryBySlug, getRegistrySource, normSlug } from './registry.js';
11
+ import { filterJobs, pageJobs, compileFilterPatterns } from './filters.js';
12
+ import { AtsError, ArgumentError, ERROR_CODES } from './errors.js';
13
+ import { describeBoard } from './boards.js';
14
+
15
+ // The counts an adapter reports about its scan. boards[].scan carries these
16
+ // and nothing else; org_name and org_url from the same report go on the
17
+ // board itself.
18
+ const SCAN_KEYS = ['listed', 'prefiltered', 'hydrated', 'capped'];
12
19
 
13
20
  /**
14
21
  * Fetch jobs from a company's ATS board.
15
22
  *
23
+ * Same options as fetchJobsDetailed; returns the page as an array.
24
+ *
25
+ * Discovery that found no board while at least one check failed throws an
26
+ * AtsError (rate_limited when any failure was a 429, else ats_unreachable)
27
+ * instead of returning []: an array has no place for `failed`, and an empty
28
+ * one would read as "not found" (issue #55).
29
+ *
30
+ * @returns {Promise<Array>} Normalized, filtered job objects
31
+ */
32
+ export async function fetchJobs(options = {}) {
33
+ const result = await fetchJobsDetailed(options);
34
+ if (result.match === 'probe' && result.boards.length === 0 && result.failed.length > 0) {
35
+ throw discoveryFailure(options.company, result.failed);
36
+ }
37
+ return result.jobs;
38
+ }
39
+
40
+ /**
41
+ * Fetch jobs from a company's ATS board, with the counts and the boards
42
+ * behind the page.
43
+ *
16
44
  * @param {Object} options
17
45
  * @param {string} options.company - Company slug or name
18
46
  * @param {string} [options.ats] - Specific ATS platform. If omitted, auto-detects.
@@ -20,12 +48,34 @@ import { applyFilters } from './filters.js';
20
48
  * @param {string} [options.titleFilter] - Regex matched against title only. Use for role identity ("product manager", "staff engineer").
21
49
  * @param {string} [options.filter] - Regex matched across title, department, description. Use for topic/scope.
22
50
  * @param {number} [options.postedWithinDays] - Only return jobs posted within N days.
23
- * @param {string[]} [options.locationIncludes] - Keep jobs whose location contains any of these (case-insensitive).
24
- * @param {string[]} [options.locationExcludes] - Drop jobs whose location contains any of these (case-insensitive).
25
- * @param {number} [options.limit=100] - Maximum jobs to return after filtering.
26
- * @returns {Promise<Array>} Normalized, filtered job objects
51
+ * @param {string[]} [options.locationIncludes] - Keep jobs where any listed location contains any of these (case-insensitive).
52
+ * @param {string[]} [options.locationExcludes] - Drop jobs only when every listed location contains one of these (case-insensitive).
53
+ * @param {'newest'|'board'} [options.order='newest'] - 'newest': by postedAt descending, undated last, ties by id. 'board': the adapter's own order.
54
+ * @param {number} [options.offset=0] - Matches to skip after sorting (paging).
55
+ * @param {number} [options.limit=100] - Maximum jobs to return after offset.
56
+ * @returns {Promise<{
57
+ * jobs: Array,
58
+ * total_matched: number,
59
+ * total_before_filters: number,
60
+ * match: 'registry'|'probe'|'workday_override',
61
+ * company: { key: string, name: string }|null,
62
+ * boards: Array<object>,
63
+ * failed: Array<{ ats: string, slug: string, name: string|null, code: string, message: string }>,
64
+ * }>}
65
+ * jobs: the page. total_matched: matches before offset and limit.
66
+ * total_before_filters: rows every board listed before any filter (on
67
+ * Workday and SmartRecruiters the list count, not the rows they hydrated).
68
+ * match: how the company was resolved. company: the registry row's name
69
+ * and its key (normalized name); null unless match is 'registry'.
70
+ * boards: one entry per board that answered (see src/boards.js), with
71
+ * jobs_found and matched counted per board, and org_name and org_url as
72
+ * the board states them (null where its ATS exposes nothing). failed:
73
+ * adapters that threw an AtsError during discovery; on a registry hit
74
+ * the error propagates.
75
+ * @throws {ArgumentError} No company, unknown ats, or a filter regex that does not compile.
76
+ * @throws {AtsError} The board's ATS failed on a registry hit, an explicit ats, or a Workday override.
27
77
  */
28
- export async function fetchJobs({
78
+ export async function fetchJobsDetailed({
29
79
  company,
30
80
  ats,
31
81
  config,
@@ -34,66 +84,135 @@ export async function fetchJobs({
34
84
  postedWithinDays,
35
85
  locationIncludes,
36
86
  locationExcludes,
87
+ order = 'newest',
88
+ offset = 0,
37
89
  limit = 100,
38
90
  } = {}) {
39
- if (!company) throw new Error('Company slug required');
91
+ if (!company) throw new ArgumentError('company is required');
92
+ compileFilterPatterns({ titleFilter, filter });
40
93
 
41
- // Unified slug normalization: strip all non-alphanumeric (matches detectAts)
42
- const slug = company.toLowerCase().replace(/[^a-z0-9]/g, '');
94
+ const slug = normSlug(company);
95
+ const filters = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes };
43
96
 
44
- // Filter context is passed as an additive 2nd arg to adapters. Existing
45
- // adapters declare fetch{Name}(slug) and ignore extra positional args
46
- // (JS no-op), so this is backward-compatible. Filter-aware adapters
47
- // (e.g. Workday) use it to avoid mass detail-fetching on huge tenants.
48
- const filterContext = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit };
97
+ // Which boards to fetch, and how the company was resolved. A registry hit
98
+ // and a Workday override are one board each and their errors propagate.
99
+ // Discovery (not in the registry, no ats) asks every adapter, keeps the
100
+ // ones that answered, and records the ones that failed.
101
+ let match = 'probe';
102
+ let discovery = false;
103
+ let targets;
49
104
 
50
- let jobs;
51
105
  if (ats) {
52
- const adapter = ADAPTERS[ats];
53
- if (!adapter) throw new Error(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
106
+ if (!ADAPTERS[ats]) throw new ArgumentError(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
54
107
  // Explicit ATS: an explicitly passed config wins (the only path that
55
108
  // can reach a Workday company not in the registry). With no explicit
56
- // config, fall back to the registry so config-keyed adapters
57
- // (Workday) and canonically-cased registry slugs (SmartRecruiters
58
- // "Visa") also work on the explicit path, not just under auto-detect.
59
- let fetchSlug = slug;
60
- let cfg = config;
61
- let companyName;
62
- if (!cfg) {
63
- const hit = await findEntryBySlug(slug);
64
- if (hit && hit.ats === ats) {
65
- fetchSlug = hit.entry.slug;
66
- cfg = hit.entry.config;
67
- companyName = hit.entry.name;
68
- }
109
+ // config, the registry supplies the canonical slug, config and name
110
+ // when it lists the company on that ATS (Workday's triple,
111
+ // SmartRecruiters' PascalCase slugs).
112
+ const hit = config ? null : await findEntryBySlug(slug);
113
+ if (hit && hit.ats === ats) {
114
+ match = 'registry';
115
+ targets = [registryTarget(hit, config)];
116
+ } else {
117
+ if (ats === 'workday' && config) match = 'workday_override';
118
+ targets = [{ ats, slug, name: null, config }];
69
119
  }
70
- jobs = await adapter.fetch(fetchSlug, { config: cfg, companyName, filterContext });
71
120
  } else {
72
- // Consult registry first — if we know which ATS this company uses,
73
- // skip probing the others (saves API calls, clearer error semantics).
74
- // The registry entry carries the canonical slug (so the adapter is
75
- // called with the ATS's own casing, e.g. SmartRecruiters "Visa") and
76
- // any adapter-specific config (the Workday {tenant,env,site} triple).
121
+ // Registry first: a known company costs one adapter call, with the
122
+ // ATS's own slug casing and any adapter config the row carries.
77
123
  const hit = await findEntryBySlug(slug);
78
124
  if (hit) {
79
- jobs = await ADAPTERS[hit.ats].fetch(hit.entry.slug, {
80
- config: config || hit.entry.config,
81
- companyName: hit.entry.name,
82
- filterContext,
83
- });
125
+ match = 'registry';
126
+ targets = [registryTarget(hit, config)];
84
127
  } else {
85
- // Discovery mode: company not in registry, probe all adapters.
86
- // (Registry-only adapters like Workday bail here via their guard.)
87
- const results = await Promise.allSettled(
88
- Object.entries(ADAPTERS).map(async ([name, adapter]) => adapter.fetch(slug, { filterContext }))
89
- );
90
- jobs = results
91
- .filter(r => r.status === 'fulfilled')
92
- .flatMap(r => r.value);
128
+ discovery = true;
129
+ targets = ATS_NAMES.map(atsName => ({ ats: atsName, slug, name: null, config: undefined }));
130
+ }
131
+ }
132
+
133
+ // Adapters get the filters and the page so filter-aware ones (Workday,
134
+ // SmartRecruiters) hydrate only what the page needs, plus `report`, which
135
+ // records what an adapter chooses to say about its own board: the scan
136
+ // counts, and the org name and host the board states. Every call for one
137
+ // adapter merges into one record, so the two can arrive together or apart.
138
+ const reports = {};
139
+ const outcomes = await Promise.allSettled(targets.map(t =>
140
+ ADAPTERS[t.ats].fetch(t.slug, {
141
+ config: t.config,
142
+ companyName: t.name ?? undefined,
143
+ filterContext: { ...filters, offset, limit },
144
+ report: (fields) => {
145
+ reports[t.ats] = { ...reports[t.ats], ...fields };
146
+ },
147
+ })
148
+ ));
149
+
150
+ const boards = [];
151
+ const failed = [];
152
+ const rows = [];
153
+ targets.forEach((t, i) => {
154
+ const outcome = outcomes[i];
155
+ if (outcome.status === 'rejected') {
156
+ const err = outcome.reason;
157
+ if (!discovery || !(err instanceof AtsError)) throw err;
158
+ failed.push({ ats: t.ats, slug: t.slug, name: t.name, code: err.code, message: err.message });
159
+ return;
93
160
  }
161
+ // jobs_found is the board's list before any filter. Workday and
162
+ // SmartRecruiters filter their list before hydrating and report what
163
+ // they listed; counting their rows instead would make a filter miss on
164
+ // a hiring company read as an empty board (issue #60). Every other
165
+ // adapter returns its whole list.
166
+ const report = reports[t.ats] ?? {};
167
+ const scan = SCAN_KEYS.some(k => report[k] !== undefined)
168
+ ? Object.fromEntries(SCAN_KEYS.map(k => [k, report[k]]))
169
+ : null;
170
+ const listed = scan?.listed ?? outcome.value.length;
171
+ // A probed board exists when it listed rows: a 404 and an empty board
172
+ // both come back as []. A registry hit or an override is a board
173
+ // whatever it returned.
174
+ if (match === 'probe' && listed === 0) return;
175
+ rows.push(...outcome.value);
176
+ boards.push(describeBoard({
177
+ ...t,
178
+ org_name: report.org_name ?? null,
179
+ org_url: report.org_url ?? null,
180
+ jobs_found: listed,
181
+ scan,
182
+ }));
183
+ });
184
+
185
+ const matched = filterJobs(rows, filters);
186
+ const perBoard = new Map();
187
+ for (const job of matched) {
188
+ const key = `${job.ats}|${job.companySlug}`;
189
+ perBoard.set(key, (perBoard.get(key) || 0) + 1);
94
190
  }
191
+ for (const board of boards) board.matched = perBoard.get(`${board.ats}|${board.slug}`) || 0;
95
192
 
96
- return applyFilters(jobs, { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit });
193
+ return {
194
+ jobs: pageJobs(matched, { order, offset, limit }),
195
+ total_matched: matched.length,
196
+ total_before_filters: boards.reduce((n, b) => n + b.jobs_found, 0),
197
+ match,
198
+ company: match === 'registry' ? { key: normSlug(targets[0].name), name: targets[0].name } : null,
199
+ boards,
200
+ failed,
201
+ };
202
+ }
203
+
204
+ function registryTarget(hit, config) {
205
+ return { ats: hit.ats, slug: hit.entry.slug, name: hit.entry.name, config: config || hit.entry.config };
206
+ }
207
+
208
+ function discoveryFailure(company, failed) {
209
+ const limited = failed.some(f => f.code === ERROR_CODES.RATE_LIMITED);
210
+ const checks = failed.map(f => `${f.ats} (${f.message})`).join('; ');
211
+ return new AtsError(
212
+ limited ? ERROR_CODES.RATE_LIMITED : ERROR_CODES.ATS_UNREACHABLE,
213
+ `No board answered for "${company}" and the check failed on ${checks}`,
214
+ limited ? 429 : undefined
215
+ );
97
216
  }
98
217
 
99
218
  /**
@@ -101,14 +220,16 @@ export async function fetchJobs({
101
220
  */
102
221
  export async function search({ keyword, location, ats } = {}) {
103
222
  // For now, search is registry-based. With SQLite store, this becomes a full-text search.
104
- if (!keyword) throw new Error('Keyword required');
223
+ if (!keyword) throw new ArgumentError('keyword is required');
105
224
  return searchRegistry(keyword);
106
225
  }
107
226
 
108
227
  /**
109
- * Detect which ATS platform a company uses (probes each adapter).
228
+ * Detect which ATS platform a company uses: the registry first, then a
229
+ * probe of each adapter. detectAts returns [{ ats, slug }]; detectAtsDetailed
230
+ * adds each board's source and the probes that failed.
110
231
  */
111
- export { detectAts } from './registry.js';
232
+ export { detectAts, detectAtsDetailed } from './registry.js';
112
233
 
113
234
  /**
114
235
  * Look up which ATS a slug belongs to in the registry (cached, no network).
@@ -123,6 +244,7 @@ export const registry = {
123
244
  load: loadRegistry,
124
245
  search: searchRegistry,
125
246
  detect: detectAts,
247
+ detectDetailed: detectAtsDetailed,
126
248
  findAtsBySlug,
127
249
  findEntryBySlug,
128
250
  getSource: getRegistrySource,
@@ -134,13 +256,18 @@ export { fetchLever } from './adapters/lever.js';
134
256
  export { fetchAshby } from './adapters/ashby.js';
135
257
 
136
258
  // Re-export filter logic for reuse (e.g., by the MCP server)
137
- export { applyFilters } from './filters.js';
259
+ export { applyFilters, applyFiltersDetailed } from './filters.js';
138
260
 
139
261
  // Re-export the list of supported ATS names (e.g. so the MCP layer can report
140
262
  // the full set detectAts probes, instead of hardcoding a stale subset).
141
263
  export { ATS_NAMES };
142
264
 
143
- // Error taxonomy + typed error. Adapters throw AtsError with a stable .code
144
- // (ats_unreachable / rate_limited) so the MCP layer maps failures without
145
- // parsing messages. ERROR_CODES is the single source of truth for both.
146
- export { ERROR_CODES, AtsError } from './errors.js';
265
+ // Error taxonomy + typed errors. Adapters throw AtsError with a stable .code
266
+ // (ats_unreachable / rate_limited); the library throws ArgumentError
267
+ // (invalid_args) for a call it cannot make. The MCP layer maps both by code
268
+ // without parsing messages. ERROR_CODES is the single source of truth.
269
+ export { ERROR_CODES, AtsError, ArgumentError } from './errors.js';
270
+
271
+ // HTTP settings for every adapter request: timeout, retries, per-host cap.
272
+ // For tests and scripts; the defaults are right for normal use.
273
+ export { configureHttp } from './http.js';