@haystackeditor/cli 0.21.0 → 0.22.1
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.
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import chalk from 'chalk';
|
|
11
11
|
import { withSchema } from '../schema.js';
|
|
12
12
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
13
|
-
import { classifyHttpError,
|
|
13
|
+
import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
|
|
14
14
|
import { trackOnboardingEvent } from '../utils/telemetry.js';
|
|
15
15
|
import { gatewayFetch } from './case-batch.js';
|
|
16
16
|
import { ONBOARDING_STAGES, ONBOARDING_VERSION, } from './onboarding-contract.js';
|
|
@@ -93,6 +93,8 @@ export function parseOnboardingStatus(value, expected) {
|
|
|
93
93
|
invalid('its data absences');
|
|
94
94
|
if (!isStrings(value.versionChoices))
|
|
95
95
|
invalid('its version choices');
|
|
96
|
+
if (!isStrings(value.standIns) || !isStrings(value.unsetProviders))
|
|
97
|
+
invalid('its stand-ins');
|
|
96
98
|
if (value.block !== null)
|
|
97
99
|
parseOnboardingBlock(value.block);
|
|
98
100
|
const checkpoint = value.checkpoint;
|
|
@@ -128,6 +130,9 @@ async function body(response, what) {
|
|
|
128
130
|
return await response.json();
|
|
129
131
|
}
|
|
130
132
|
catch (error) {
|
|
133
|
+
// A connection that dropped mid-body is not a bad body: a wait reads again.
|
|
134
|
+
if (transientReadFailure(error) !== null)
|
|
135
|
+
throw error;
|
|
131
136
|
throw new OnboardingResponseError(`The onboarding service returned ${what} that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
132
137
|
}
|
|
133
138
|
}
|
|
@@ -159,8 +164,11 @@ const ONBOARDING_READ_EVERY_MS = 15_000;
|
|
|
159
164
|
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
160
165
|
/** Polls the base's onboarding until it is no longer running. No wall-time cap:
|
|
161
166
|
* an interrupt stops waiting and leaves the onboarding running (`resume` is the
|
|
162
|
-
* command that follows it again). A
|
|
163
|
-
*
|
|
167
|
+
* command that follows it again). A read that may work if asked again (a 429/5xx
|
|
168
|
+
* while a server or runner restarts, or no answer at all: a dropped connection
|
|
169
|
+
* ended a 1 h wait on 2026-10-02 with "fetch failed") is read again at the next
|
|
170
|
+
* poll, said once per run of failures, until the service has given no answer for
|
|
171
|
+
* SERVICE_SILENCE_LIMIT_MS (stall detection). */
|
|
164
172
|
export async function waitForOnboarding(first, token, progress, resume) {
|
|
165
173
|
const interrupted = () => {
|
|
166
174
|
console.error(`\nStopped waiting. The onboarding keeps running; run \`${resume}\` again to follow it.`);
|
|
@@ -171,6 +179,8 @@ export async function waitForOnboarding(first, token, progress, resume) {
|
|
|
171
179
|
let status = first;
|
|
172
180
|
progress(`Waiting for onboarding ${status.onboardRunId ?? ''} (Ctrl-C stops waiting; the onboarding keeps running).`);
|
|
173
181
|
progress(onboardingStep(status));
|
|
182
|
+
let failing = false;
|
|
183
|
+
let answeredAt = Date.now();
|
|
174
184
|
while (status.state === 'onboarding') {
|
|
175
185
|
await delay(ONBOARDING_READ_EVERY_MS);
|
|
176
186
|
let next;
|
|
@@ -178,11 +188,18 @@ export async function waitForOnboarding(first, token, progress, resume) {
|
|
|
178
188
|
next = await readOnboardingStatus(status.repository, status.baseCommit, token);
|
|
179
189
|
}
|
|
180
190
|
catch (error) {
|
|
181
|
-
|
|
191
|
+
const why = transientReadFailure(error);
|
|
192
|
+
if (why === null)
|
|
182
193
|
throw error;
|
|
183
|
-
|
|
194
|
+
if (Date.now() - answeredAt >= SERVICE_SILENCE_LIMIT_MS)
|
|
195
|
+
throw new ServiceSilentError('onboarding', resume);
|
|
196
|
+
if (!failing)
|
|
197
|
+
progress(`The onboarding status is unavailable for now (${why}); reading it again.`);
|
|
198
|
+
failing = true;
|
|
184
199
|
continue;
|
|
185
200
|
}
|
|
201
|
+
failing = false;
|
|
202
|
+
answeredAt = Date.now();
|
|
186
203
|
if (onboardingStep(next) !== onboardingStep(status))
|
|
187
204
|
progress(onboardingStep(next));
|
|
188
205
|
status = next;
|
|
@@ -273,6 +290,9 @@ export function formatOnboarding(status) {
|
|
|
273
290
|
lines.push('', ...status.dataAbsences.map(absence => chalk.yellow(`Note: ${safe(absence)}`)));
|
|
274
291
|
if (status.versionChoices.length)
|
|
275
292
|
lines.push('', ...status.versionChoices.map(choice => chalk.yellow(`Note: ${safe(choice)}`)));
|
|
293
|
+
const outside = [...status.standIns, ...status.unsetProviders];
|
|
294
|
+
if (outside.length)
|
|
295
|
+
lines.push('', ...outside.map(note => chalk.yellow(`Note: ${safe(note)}`)));
|
|
276
296
|
return lines.join('\n');
|
|
277
297
|
}
|
|
278
298
|
/** Telemetry: onboarding started, blocked or ready, once per state a command sees. */
|
|
@@ -351,7 +371,8 @@ export async function verifyOnboardingCommand(options) {
|
|
|
351
371
|
const origin = resolveOriginRepository(gitRoot);
|
|
352
372
|
const repository = `${origin.owner}/${origin.repository}`;
|
|
353
373
|
const auth = await resolveAuthContext({ preferredLogin: options.account, owner: origin.owner, repo: origin.repository });
|
|
354
|
-
|
|
374
|
+
const baseCommit = deriveBaseSha(gitRoot, Date.now() + EXPLICIT_WALL_MS);
|
|
375
|
+
let status = await readWithRetries(() => readOnboardingStatus(repository, baseCommit, auth.token));
|
|
355
376
|
reportOnboardingState(status);
|
|
356
377
|
if (options.wait && status.state === 'onboarding') {
|
|
357
378
|
status = await waitForOnboarding(status, auth.token, line => console.error(chalk.dim(`${clock()} ${line}`)), 'haystack verify onboarding --wait');
|
|
@@ -437,26 +437,10 @@ export function normalModeFailure(error) {
|
|
|
437
437
|
}
|
|
438
438
|
function printResult(result) {
|
|
439
439
|
const { outcome, runId, walker, crawl } = result;
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
console.log(chalk.green('Precompute result is already cached.'));
|
|
445
|
-
}
|
|
446
|
-
else if (outcome === 'existing') {
|
|
447
|
-
console.log(chalk.green('A matching precompute request already exists.'));
|
|
448
|
-
}
|
|
449
|
-
if (walker?.status === 'queued')
|
|
450
|
-
console.log(`Walker queued (${walker.runId}).`);
|
|
451
|
-
else if (walker?.status === 'existing')
|
|
452
|
-
console.log(`A matching walker run already exists (${walker.runId}).`);
|
|
453
|
-
else if (walker?.status === 'unavailable')
|
|
454
|
-
console.log(walker.reason === 'runner-unavailable'
|
|
455
|
-
? 'Walker unavailable: no runner is available for this repository.'
|
|
456
|
-
: 'Walker unavailable: the enqueue request failed.');
|
|
457
|
-
else
|
|
458
|
-
console.log('Walker status was not reported by the service.');
|
|
459
|
-
// Amendment 12: this capture builds and freezes the app only; `haystack verify` crawls it.
|
|
440
|
+
// What the change's check depends on comes first and alone (amendment 12: this capture builds and freezes the app;
|
|
441
|
+
// `haystack verify` crawls it). A coding agent reads this output: the background requests below follow it, marked as
|
|
442
|
+
// background, so neither their run ids nor an "unavailable" walker reads as the check failing (2026-10-02: a run id taken
|
|
443
|
+
// from the first line was the front-half request's, and "Walker unavailable" looked like an error).
|
|
460
444
|
if (crawl?.status === 'queued')
|
|
461
445
|
console.log(`Building and freezing the app for this change (${crawl.runId}); run \`haystack verify\` to check it.`);
|
|
462
446
|
else if (crawl?.status === 'existing')
|
|
@@ -471,6 +455,15 @@ function printResult(result) {
|
|
|
471
455
|
console.log(blockLines(crawl.block).join('\n'));
|
|
472
456
|
else
|
|
473
457
|
console.log('Crawl status was not reported by the service.');
|
|
458
|
+
const front = outcome === 'enqueued' ? `front-half precompute queued${runId ? ` (${runId})` : ''}`
|
|
459
|
+
: outcome === 'cached' ? 'front-half precompute already cached'
|
|
460
|
+
: outcome === 'existing' ? 'front-half precompute already requested' : null;
|
|
461
|
+
const walk = walker?.status === 'queued' ? `repository walk queued (${walker.runId})`
|
|
462
|
+
: walker?.status === 'existing' ? `repository walk already running (${walker.runId})`
|
|
463
|
+
: walker?.status === 'unavailable' ? (walker.reason === 'runner-unavailable'
|
|
464
|
+
? 'repository walk not started (no walker runner for this repository)' : 'repository walk not started (its request failed)')
|
|
465
|
+
: 'repository walk status not reported';
|
|
466
|
+
console.log(chalk.dim(`Background, not needed by the check above: ${[front, walk].filter(Boolean).join('; ')}.`));
|
|
474
467
|
}
|
|
475
468
|
/** The hook, an explicit precompute and `haystack verify` all capture here.
|
|
476
469
|
* The repository defaults to origin's, as the hook's does. */
|
package/dist/commands/verify.js
CHANGED
|
@@ -26,7 +26,7 @@ import chalk from 'chalk';
|
|
|
26
26
|
import { withSchema } from '../schema.js';
|
|
27
27
|
import { findGitRoot } from '../utils/hooks.js';
|
|
28
28
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
29
|
-
import { classifyHttpError,
|
|
29
|
+
import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
|
|
30
30
|
import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
|
|
31
31
|
import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_WAIT_MAX_MS, } from './crawl-contract.js';
|
|
32
32
|
import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
|
|
@@ -260,6 +260,9 @@ async function responseBody(response) {
|
|
|
260
260
|
return await response.json();
|
|
261
261
|
}
|
|
262
262
|
catch (error) {
|
|
263
|
+
// A connection that dropped mid-body is not a bad body: a wait reads again.
|
|
264
|
+
if (transientReadFailure(error) !== null)
|
|
265
|
+
throw error;
|
|
263
266
|
throw new CrawlResponseError(`The crawl service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
264
267
|
}
|
|
265
268
|
}
|
|
@@ -485,7 +488,11 @@ function printNewBugs(view, printed) {
|
|
|
485
488
|
}
|
|
486
489
|
/** Follows the same capture until its crawl answers at its budget or ends (amendment 9). Each read is held by the
|
|
487
490
|
* service until the crawl changes, so there is no pause between reads. No wall-time cap: an interrupt stops waiting
|
|
488
|
-
* and leaves the crawl running.
|
|
491
|
+
* and leaves the crawl running. A read that may work if asked again (transientReadFailure) is asked again after
|
|
492
|
+
* TRANSIENT_READ_PAUSE_MS, said once per run of failures: one dropped connection used to end the command. Only a
|
|
493
|
+
* service that has given no answer for SERVICE_SILENCE_LIMIT_MS stops the wait (stall detection). */
|
|
494
|
+
/** The pause before a followed crawl is read again after a read that may work if asked again. */
|
|
495
|
+
const TRANSIENT_READ_PAUSE_MS = 5_000;
|
|
489
496
|
async function waitForCrawl(first, identity, token) {
|
|
490
497
|
const interrupted = () => {
|
|
491
498
|
console.error('\nStopped waiting. The crawl keeps running; run `haystack verify` again to see it.');
|
|
@@ -498,17 +505,36 @@ async function waitForCrawl(first, identity, token) {
|
|
|
498
505
|
progress(`Waiting for crawl ${view.runId} (Ctrl-C stops waiting; the crawl keeps running).`);
|
|
499
506
|
progress(currentStep(view));
|
|
500
507
|
printNewBugs(view, printed);
|
|
508
|
+
let failing = false;
|
|
509
|
+
let answeredAt = Date.now();
|
|
501
510
|
while (!TERMINAL.has(reportedStatus(view))) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (
|
|
510
|
-
|
|
511
|
+
let next;
|
|
512
|
+
try {
|
|
513
|
+
// The followed crawl's own read is held, so only a change to it wakes the wait (Astra review: a capture read could sit up
|
|
514
|
+
// to 25 s on another title's crawl of the same code).
|
|
515
|
+
next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
|
|
516
|
+
// Once this crawl is being cancelled, the capture's newest crawl is read at once (not held) and followed if it is this
|
|
517
|
+
// same change (amendment 12: no crawl replaces another, but one can be cancelled and asked for again).
|
|
518
|
+
if (next.cancelRequested) {
|
|
519
|
+
const newest = await readCrawl(identity, token);
|
|
520
|
+
if (newest !== null && sameChange(newest, identity) && newest.runId !== view.runId)
|
|
521
|
+
next = newest;
|
|
522
|
+
}
|
|
511
523
|
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
const why = transientReadFailure(error);
|
|
526
|
+
if (why === null)
|
|
527
|
+
throw error;
|
|
528
|
+
if (Date.now() - answeredAt >= SERVICE_SILENCE_LIMIT_MS)
|
|
529
|
+
throw new ServiceSilentError('crawl', 'haystack verify');
|
|
530
|
+
if (!failing)
|
|
531
|
+
progress(`The crawl's status is unavailable for now (${why}); reading it again.`);
|
|
532
|
+
failing = true;
|
|
533
|
+
await new Promise(resolve => setTimeout(resolve, TRANSIENT_READ_PAUSE_MS));
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
failing = false;
|
|
537
|
+
answeredAt = Date.now();
|
|
512
538
|
if (next.runId !== view.runId)
|
|
513
539
|
progress(`A newer crawl of this same change took over (${next.runId}).`);
|
|
514
540
|
if (!TERMINAL.has(reportedStatus(next)) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
|
|
@@ -555,21 +581,9 @@ function staleWords(found, stale) {
|
|
|
555
581
|
* when no crawl could be started), whether the base is being onboarded or its
|
|
556
582
|
* onboarding is blocked, and the line to report. */
|
|
557
583
|
async function submitCapture(capture, token, found) {
|
|
558
|
-
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
const pauses = [2_000, 5_000, 10_000];
|
|
562
|
-
for (let attempt = 0; acknowledgment === undefined; attempt += 1) {
|
|
563
|
-
try {
|
|
564
|
-
acknowledgment = await postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS);
|
|
565
|
-
}
|
|
566
|
-
catch (error) {
|
|
567
|
-
const transient = error instanceof HaystackApiError && [429, 502, 503, 504].includes(error.status);
|
|
568
|
-
if (!transient || attempt >= pauses.length)
|
|
569
|
-
throw normalModeFailure(error);
|
|
570
|
-
await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
|
|
571
|
-
}
|
|
572
|
-
}
|
|
584
|
+
// A momentary 429/5xx or a dropped connection (a server or runner restarting) is asked again, as the stop hook's
|
|
585
|
+
// sender does: admission is idempotent for one capture, so a repeat can only find what the first created.
|
|
586
|
+
const acknowledgment = await readWithRetries(() => postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS)).catch((error) => { throw normalModeFailure(error); });
|
|
573
587
|
const crawl = acknowledgment.crawl;
|
|
574
588
|
const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
|
|
575
589
|
if (crawl === undefined) {
|
|
@@ -651,7 +665,8 @@ export async function verifyCommand(options) {
|
|
|
651
665
|
budgetMs: request.crawl.budgetMs ?? null,
|
|
652
666
|
};
|
|
653
667
|
const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
|
|
654
|
-
|
|
668
|
+
// One-shot reads ask again after a failure that may work if asked again, as the waits do.
|
|
669
|
+
let view = await readWithRetries(() => readCrawl(identity, auth.token));
|
|
655
670
|
let onboarding = null;
|
|
656
671
|
const stale = view === null ? null : staleCrawl(view, identity);
|
|
657
672
|
if (view === null || stale !== null) {
|
|
@@ -659,7 +674,7 @@ export async function verifyCommand(options) {
|
|
|
659
674
|
let submitted = await submitCapture(capture, auth.token, found);
|
|
660
675
|
note(submitted.line);
|
|
661
676
|
if (submitted.onboarding !== null) {
|
|
662
|
-
onboarding = await readOnboardingStatus(identity.repository, identity.baseCommit, auth.token);
|
|
677
|
+
onboarding = await readWithRetries(() => readOnboardingStatus(identity.repository, identity.baseCommit, auth.token));
|
|
663
678
|
reportOnboardingState(onboarding);
|
|
664
679
|
if (options.wait !== false && onboarding.state === 'onboarding') {
|
|
665
680
|
onboarding = await waitForOnboarding(onboarding, auth.token, progress, 'haystack verify');
|
|
@@ -681,7 +696,8 @@ export async function verifyCommand(options) {
|
|
|
681
696
|
process.exitCode = 1;
|
|
682
697
|
return;
|
|
683
698
|
}
|
|
684
|
-
|
|
699
|
+
const runId = submitted.runId;
|
|
700
|
+
view = await readWithRetries(() => readCrawlRun(runId, identity, auth.token));
|
|
685
701
|
if (found !== null && !anotherCrawl(found.stale) && view.runId === found.view.runId && TERMINAL.has(view.status)) {
|
|
686
702
|
note('The service did not run it again; its last result follows.');
|
|
687
703
|
}
|
|
@@ -693,7 +709,8 @@ export async function verifyCommand(options) {
|
|
|
693
709
|
process.stdout.write(`${JSON.stringify(withSchema('verify', { change, onboarding, crawl: view }), null, 2)}\n`);
|
|
694
710
|
else {
|
|
695
711
|
console.log(formatCrawl(view));
|
|
696
|
-
const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? [])]
|
|
712
|
+
const notes = [...(onboarding?.dataAbsences ?? []), ...(onboarding?.versionChoices ?? []), ...(onboarding?.standIns ?? []),
|
|
713
|
+
...(onboarding?.unsetProviders ?? [])];
|
|
697
714
|
if (notes.length)
|
|
698
715
|
console.log(['', ...notes.map(note => `Note: ${safe(note)}`)].join('\n'));
|
|
699
716
|
}
|
|
@@ -25,6 +25,70 @@ export class HaystackApiError extends Error {
|
|
|
25
25
|
return this.status === 401 || this.status === 403;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/** The network failures fetch (undici) reports as its TypeError's `cause.code`: the connection refused, reset, dropped,
|
|
29
|
+
* unreachable or timed out, or the name not resolved for now. A request it could not even build (ERR_INVALID_URL) is not one. */
|
|
30
|
+
const TRANSPORT_FAILURE_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'EPIPE', 'ETIMEDOUT', 'ENETUNREACH',
|
|
31
|
+
'ENETDOWN', 'EHOSTUNREACH', 'EHOSTDOWN', 'EAI_AGAIN', 'ENOTFOUND', 'UND_ERR_SOCKET', 'UND_ERR_CLOSED',
|
|
32
|
+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT']);
|
|
33
|
+
/**
|
|
34
|
+
* Why a read failed in a way that asking again may fix, or null when it did not: the service or a runner restarting
|
|
35
|
+
* (429, 502, 503, 504), no answer at all (fetch's TypeError whose cause is one of TRANSPORT_FAILURE_CODES), or a request
|
|
36
|
+
* past its own deadline (TimeoutError). A wait loop reads again on these; anything else (sign-in, a bad request, a request
|
|
37
|
+
* that could not be built, a bug) is the caller's to report.
|
|
38
|
+
*/
|
|
39
|
+
export function transientReadFailure(error) {
|
|
40
|
+
if (error instanceof HaystackApiError)
|
|
41
|
+
return [429, 502, 503, 504].includes(error.status) ? `HTTP ${error.status}` : null;
|
|
42
|
+
if (error instanceof Error && error.name === 'TimeoutError')
|
|
43
|
+
return 'no answer in time';
|
|
44
|
+
return error instanceof TypeError ? transportCause(error.cause, 0) : null;
|
|
45
|
+
}
|
|
46
|
+
/** The transport failure somewhere in a fetch TypeError's cause chain. A connection dropped while the body is read fails as
|
|
47
|
+
* TypeError "terminated", whose cause may be the socket error or the request's TimeoutError (Bugbot, #3760); several
|
|
48
|
+
* addresses tried at once (IPv4 and IPv6) fail as one AggregateError. */
|
|
49
|
+
function transportCause(cause, depth) {
|
|
50
|
+
if (!(cause instanceof Error) || depth > 4)
|
|
51
|
+
return null;
|
|
52
|
+
if (cause.name === 'TimeoutError')
|
|
53
|
+
return 'no answer in time';
|
|
54
|
+
const code = cause.code;
|
|
55
|
+
if (typeof code === 'string' && TRANSPORT_FAILURE_CODES.has(code))
|
|
56
|
+
return `no answer: ${code}`;
|
|
57
|
+
if (cause instanceof AggregateError) {
|
|
58
|
+
for (const inner of cause.errors) {
|
|
59
|
+
const found = transportCause(inner, depth + 1);
|
|
60
|
+
if (found !== null)
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return transportCause(cause.cause, depth + 1);
|
|
65
|
+
}
|
|
66
|
+
/** How long a wait keeps asking while the service gives no answer, counted from its last answer. Stall detection, not a
|
|
67
|
+
* cap on the work: a service that answers is followed for as long as the work runs. */
|
|
68
|
+
export const SERVICE_SILENCE_LIMIT_MS = 10 * 60_000;
|
|
69
|
+
/** A wait stopped because the service gave no answer for SERVICE_SILENCE_LIMIT_MS (a dead network, a wrong
|
|
70
|
+
* HAYSTACK_API_BASE, a long outage); the work it was following keeps running on the service. */
|
|
71
|
+
export class ServiceSilentError extends Error {
|
|
72
|
+
constructor(work, resume) {
|
|
73
|
+
super(`Haystack has not answered for ${SERVICE_SILENCE_LIMIT_MS / 60_000} minutes; the ${work} keeps running on the service. `
|
|
74
|
+
+ `Run \`${resume}\` again to follow it.`);
|
|
75
|
+
this.name = 'ServiceSilentError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** A one-shot read, asked again after a failure that may work if asked again (transientReadFailure), at most three more times. */
|
|
79
|
+
export async function readWithRetries(read) {
|
|
80
|
+
const pauses = [2_000, 5_000, 10_000];
|
|
81
|
+
for (let attempt = 0;; attempt += 1) {
|
|
82
|
+
try {
|
|
83
|
+
return await read();
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (transientReadFailure(error) === null || attempt >= pauses.length)
|
|
87
|
+
throw error;
|
|
88
|
+
await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
28
92
|
function headers(token, extra) {
|
|
29
93
|
const result = new Headers(extra);
|
|
30
94
|
result.set('Authorization', `Bearer ${token}`);
|