@remit/web-client 0.0.269 → 0.0.271

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.
@@ -11,6 +11,24 @@ import {
11
11
  runPredicateAction,
12
12
  } from "./bulk-actions.js";
13
13
 
14
+ /** A run nobody stops. */
15
+ const neverAborted = (): AbortSignal => new AbortController().signal;
16
+
17
+ /**
18
+ * What an aborted request does: reject with the signal's own reason, whether it
19
+ * was already aborted when the call was made or aborted while in flight.
20
+ */
21
+ const abortable = (signal: AbortSignal): Promise<never> =>
22
+ new Promise((_, reject) => {
23
+ if (signal.aborted) {
24
+ reject(signal.reason);
25
+ return;
26
+ }
27
+ signal.addEventListener("abort", () => reject(signal.reason), {
28
+ once: true,
29
+ });
30
+ });
31
+
14
32
  const ids = (count: number, prefix = "m"): string[] =>
15
33
  Array.from({ length: count }, (_, i) => `${prefix}${i}`);
16
34
 
@@ -112,7 +130,6 @@ describe("chunkTargets", () => {
112
130
  });
113
131
 
114
132
  describe("runChunkedAction", () => {
115
- const neverCancelled = () => false;
116
133
  const noopProgress = () => undefined;
117
134
 
118
135
  test("zero ids does nothing and reports done=0", async () => {
@@ -124,7 +141,7 @@ describe("runChunkedAction", () => {
124
141
  return { successCount: chunk.length, failureCount: 0 };
125
142
  },
126
143
  noopProgress,
127
- neverCancelled,
144
+ neverAborted(),
128
145
  );
129
146
  assert.deepEqual(outcome, {
130
147
  done: 0,
@@ -144,7 +161,7 @@ describe("runChunkedAction", () => {
144
161
  return { successCount: chunk.length, failureCount: 0 };
145
162
  },
146
163
  noopProgress,
147
- neverCancelled,
164
+ neverAborted(),
148
165
  );
149
166
  assert.equal(calls.length, 2);
150
167
  assert.equal(calls[0].length, BULK_ACTION_CHUNK_SIZE);
@@ -159,7 +176,7 @@ describe("runChunkedAction", () => {
159
176
  input,
160
177
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
161
178
  noopProgress,
162
- neverCancelled,
179
+ neverAborted(),
163
180
  );
164
181
  assert.equal(outcome.done, input.length);
165
182
  assert.deepEqual(outcome.failedIds, []);
@@ -168,16 +185,16 @@ describe("runChunkedAction", () => {
168
185
  test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
169
186
  const input = targets(BULK_ACTION_CHUNK_SIZE * 3);
170
187
  let calls = 0;
171
- let cancelled = false;
188
+ const stop = new AbortController();
172
189
  const outcome = await runChunkedAction(
173
190
  input,
174
191
  async (chunk) => {
175
192
  calls++;
176
- if (calls === 1) cancelled = true; // cancel after the first chunk lands
193
+ if (calls === 1) stop.abort(); // cancel after the first chunk lands
177
194
  return { successCount: chunk.length, failureCount: 0 };
178
195
  },
179
196
  () => undefined,
180
- () => cancelled,
197
+ stop.signal,
181
198
  );
182
199
  assert.equal(outcome.cancelled, true);
183
200
  assert.equal(calls, 1);
@@ -195,7 +212,7 @@ describe("runChunkedAction", () => {
195
212
  throw boom;
196
213
  },
197
214
  noopProgress,
198
- neverCancelled,
215
+ neverAborted(),
199
216
  );
200
217
  assert.equal(outcome.error, boom);
201
218
  assert.equal(outcome.done, 0);
@@ -215,7 +232,7 @@ describe("runChunkedAction", () => {
215
232
  return { successCount: chunk.length, failureCount: 0 };
216
233
  },
217
234
  noopProgress,
218
- neverCancelled,
235
+ neverAborted(),
219
236
  );
220
237
  assert.deepEqual(calls, [["a1", "a2"], ["b1"]]);
221
238
  assert.equal(outcome.done, 3);
@@ -231,7 +248,7 @@ describe("runChunkedAction", () => {
231
248
  ],
232
249
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
233
250
  (p) => seen.push(p),
234
- neverCancelled,
251
+ neverAborted(),
235
252
  );
236
253
  assert.deepEqual(seen, [
237
254
  { done: 1, total: 2 },
@@ -240,7 +257,7 @@ describe("runChunkedAction", () => {
240
257
  });
241
258
 
242
259
  test("cancelling at an account boundary hands back the accounts never reached", async () => {
243
- let cancelled = false;
260
+ const stop = new AbortController();
244
261
  const outcome = await runChunkedAction(
245
262
  [
246
263
  { id: "a1", accountId: "acct-a" },
@@ -248,11 +265,11 @@ describe("runChunkedAction", () => {
248
265
  { id: "c1", accountId: "acct-c" },
249
266
  ],
250
267
  async (chunk) => {
251
- cancelled = true;
268
+ stop.abort();
252
269
  return { successCount: chunk.length, failureCount: 0 };
253
270
  },
254
271
  () => undefined,
255
- () => cancelled,
272
+ stop.signal,
256
273
  );
257
274
  assert.equal(outcome.cancelled, true);
258
275
  assert.equal(outcome.done, 1);
@@ -271,7 +288,7 @@ describe("runChunkedAction", () => {
271
288
  return { successCount: chunk.length, failureCount: 0 };
272
289
  },
273
290
  () => undefined,
274
- neverCancelled,
291
+ neverAborted(),
275
292
  );
276
293
  assert.equal(outcome.error, boom);
277
294
  assert.equal(outcome.done, 1);
@@ -291,7 +308,7 @@ describe("runChunkedAction", () => {
291
308
  throw new Error("auth expired");
292
309
  },
293
310
  () => undefined,
294
- neverCancelled,
311
+ neverAborted(),
295
312
  );
296
313
  // The run stops where it threw rather than trying the accounts behind it:
297
314
  // what it hands back is exactly what is still untouched, which is what a
@@ -308,7 +325,7 @@ describe("runChunkedAction", () => {
308
325
  input,
309
326
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
310
327
  (p) => progressCalls.push(p),
311
- neverCancelled,
328
+ neverAborted(),
312
329
  );
313
330
  assert.deepEqual(progressCalls, [
314
331
  { done: BULK_ACTION_CHUNK_SIZE, total: input.length },
@@ -318,7 +335,6 @@ describe("runChunkedAction", () => {
318
335
  });
319
336
 
320
337
  describe("runPredicateAction", () => {
321
- const neverCancelled = () => false;
322
338
  const noopProgress = () => undefined;
323
339
 
324
340
  /** Builds a paged fixture: `pages[i]` is what the i-th call returns. */
@@ -338,7 +354,7 @@ describe("runPredicateAction", () => {
338
354
  0,
339
355
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
340
356
  noopProgress,
341
- neverCancelled,
357
+ neverAborted(),
342
358
  );
343
359
  assert.deepEqual(outcome, { done: 0, failedIds: [], cancelled: false });
344
360
  });
@@ -354,7 +370,7 @@ describe("runPredicateAction", () => {
354
370
  return { successCount: chunk.length, failureCount: 0 };
355
371
  },
356
372
  noopProgress,
357
- neverCancelled,
373
+ neverAborted(),
358
374
  );
359
375
  assert.equal(deleteCalls.length, 1);
360
376
  assert.equal(outcome.done, BULK_ACTION_CHUNK_SIZE);
@@ -374,7 +390,7 @@ describe("runPredicateAction", () => {
374
390
  return { successCount: chunk.length, failureCount: 0 };
375
391
  },
376
392
  noopProgress,
377
- neverCancelled,
393
+ neverAborted(),
378
394
  );
379
395
  assert.equal(deleteCalls.length, 2);
380
396
  assert.equal(outcome.done, 150);
@@ -397,7 +413,7 @@ describe("runPredicateAction", () => {
397
413
  return { successCount: chunk.length, failureCount: 0 };
398
414
  },
399
415
  noopProgress,
400
- neverCancelled,
416
+ neverAborted(),
401
417
  );
402
418
  assert.deepEqual(deleteCalls, [
403
419
  ["a", "b"],
@@ -408,7 +424,7 @@ describe("runPredicateAction", () => {
408
424
 
409
425
  test("cancelling mid-delete stops paging without inventing failedIds for unfetched pages", async () => {
410
426
  let fetchCalls = 0;
411
- let cancelled = false;
427
+ const stop = new AbortController();
412
428
  const fetch = async (): Promise<FetchIdsPageResult> => {
413
429
  fetchCalls++;
414
430
  return { ids: ["a", "b"], continuationToken: "more" };
@@ -417,11 +433,11 @@ describe("runPredicateAction", () => {
417
433
  fetch,
418
434
  1000,
419
435
  async (chunk) => {
420
- cancelled = true; // cancel takes effect on the next loop iteration
436
+ stop.abort(); // cancel takes effect on the next loop iteration
421
437
  return { successCount: chunk.length, failureCount: 0 };
422
438
  },
423
439
  () => undefined,
424
- () => cancelled,
440
+ stop.signal,
425
441
  );
426
442
  assert.equal(outcome.cancelled, true);
427
443
  assert.equal(fetchCalls, 1);
@@ -439,7 +455,7 @@ describe("runPredicateAction", () => {
439
455
  100,
440
456
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
441
457
  noopProgress,
442
- neverCancelled,
458
+ neverAborted(),
443
459
  );
444
460
  assert.equal(outcome.error, boom);
445
461
  assert.equal(outcome.done, 0);
@@ -455,12 +471,85 @@ describe("runPredicateAction", () => {
455
471
  throw boom;
456
472
  },
457
473
  noopProgress,
458
- neverCancelled,
474
+ neverAborted(),
459
475
  );
460
476
  assert.equal(outcome.error, boom);
461
477
  });
462
478
  });
463
479
 
480
+ // Regression for #113: a stop used to be a flag read between pages, so the
481
+ // search or delete already on the wire ran to completion — up to a hundred
482
+ // messages deleted after the press. The run's signal is now the call's signal.
483
+ describe("stopping a run that has a request in flight", () => {
484
+ test("a bounded run reports the aborted batch as a clean stop, never an error", async () => {
485
+ const stop = new AbortController();
486
+ const calls: string[][] = [];
487
+ const outcome = await runChunkedAction(
488
+ targets(BULK_ACTION_CHUNK_SIZE * 3),
489
+ async (chunk, signal) => {
490
+ calls.push(chunk);
491
+ stop.abort();
492
+ return abortable(signal);
493
+ },
494
+ () => undefined,
495
+ stop.signal,
496
+ );
497
+ assert.equal(outcome.cancelled, true);
498
+ assert.equal(outcome.error, undefined);
499
+ assert.equal(calls.length, 1, "nothing further left after the stop");
500
+ assert.equal(outcome.done, 0);
501
+ // The aborted batch may or may not have been accepted server-side, so it
502
+ // comes back with the chunks never attempted rather than counted as done.
503
+ assert.equal(outcome.failedIds.length, BULK_ACTION_CHUNK_SIZE * 3);
504
+ });
505
+
506
+ test("a predicate run whose delete is aborted sends nothing more and reports a clean stop", async () => {
507
+ const stop = new AbortController();
508
+ let fetches = 0;
509
+ let deletes = 0;
510
+ const outcome = await runPredicateAction(
511
+ async () => {
512
+ fetches++;
513
+ return { ids: ["a", "b"], continuationToken: "more" };
514
+ },
515
+ 1000,
516
+ async (_chunk, signal) => {
517
+ deletes++;
518
+ stop.abort();
519
+ return abortable(signal);
520
+ },
521
+ () => undefined,
522
+ stop.signal,
523
+ );
524
+ assert.equal(outcome.cancelled, true);
525
+ assert.equal(outcome.error, undefined);
526
+ assert.equal(deletes, 1);
527
+ assert.equal(fetches, 1);
528
+ assert.equal(outcome.done, 0);
529
+ });
530
+
531
+ test("a predicate run whose search page is aborted deletes nothing", async () => {
532
+ const stop = new AbortController();
533
+ let deletes = 0;
534
+ const outcome = await runPredicateAction(
535
+ async (_token, signal) => {
536
+ stop.abort();
537
+ return abortable(signal);
538
+ },
539
+ 1000,
540
+ async (chunk) => {
541
+ deletes++;
542
+ return { successCount: chunk.length, failureCount: 0 };
543
+ },
544
+ () => undefined,
545
+ stop.signal,
546
+ );
547
+ assert.equal(outcome.cancelled, true);
548
+ assert.equal(outcome.error, undefined);
549
+ assert.equal(deletes, 0);
550
+ });
551
+ });
552
+
464
553
  describe("honestProgress", () => {
465
554
  // Regression for #109: the count and `runPredicateAction` resolve the same
466
555
  // predicate independently, so the delete can outrun the frozen `total` it
@@ -15,6 +15,18 @@
15
15
  * failure (auth, the write, or the enqueue) that takes out the whole batch and
16
16
  * stops the run.
17
17
  *
18
+ * Cancellation is an `AbortSignal` the caller owns, handed to every call the run
19
+ * makes (#113). Aborting ends the request in flight rather than waiting for it,
20
+ * so at most the batch already on the wire is at stake instead of a whole page;
21
+ * the run then stops before the next request leaves. A rejection raised while
22
+ * the signal is aborted is that abort, so it reports `cancelled` rather than an
23
+ * error — the user asked for it.
24
+ *
25
+ * That classification reads `signal.aborted`, deliberately, and not the shape of
26
+ * the rejection (`isAbortError`): a genuine failure that raced the user's Stop
27
+ * should read as a stop. The user pressed it and the run is over either way, and
28
+ * an error banner for a run they ended themselves is the wrong answer.
29
+ *
18
30
  * Pure, framework-agnostic, and independently testable — no React, no fetch.
19
31
  * `useEscalatedActions.ts` supplies the real `ApplyBatch`/`FetchIdsPage`
20
32
  * implementations (the generated SDK client) and owns the React state.
@@ -22,6 +34,9 @@
22
34
 
23
35
  export const BULK_ACTION_CHUNK_SIZE = 100;
24
36
 
37
+ /** For a run with no Stop control to wire a signal to. */
38
+ const NEVER_ABORTED = new AbortController().signal;
39
+
25
40
  /**
26
41
  * Resolves a promise to a discriminated result instead of throwing, so a
27
42
  * caller can branch on infrastructure failure as a value — the app's
@@ -104,7 +119,10 @@ export interface BatchResult {
104
119
  }
105
120
 
106
121
  /** Applies the action to one batch of ids (≤100) with a single bulk call. */
107
- export type ApplyBatch = (ids: string[]) => Promise<BatchResult>;
122
+ export type ApplyBatch = (
123
+ ids: string[],
124
+ signal: AbortSignal,
125
+ ) => Promise<BatchResult>;
108
126
 
109
127
  export interface BulkActionProgress {
110
128
  /** Ids the action has been applied to so far. */
@@ -122,6 +140,9 @@ export interface BulkActionOutcome {
122
140
  * cancellation or a thrown error, the chunks the bounded run never attempted
123
141
  * (see `runChunkedAction`). Empty in the predicate case, which re-resolves on
124
142
  * every run rather than handing back a remainder (see `runPredicateAction`).
143
+ * An aborted batch is counted here too: aborting the request does not
144
+ * un-send what the server already accepted, so its ids are "may not have
145
+ * landed" rather than "did not", and every action here is idempotent.
125
146
  * There is no per-id failure source: a returned batch call counts every id in
126
147
  * it as accepted (see the module header).
127
148
  */
@@ -150,7 +171,7 @@ export const runChunkedAction = async (
150
171
  targets: readonly BulkActionTarget[],
151
172
  applyBatch: ApplyBatch,
152
173
  onProgress: (progress: BulkActionProgress) => void,
153
- isCancelled: () => boolean,
174
+ signal: AbortSignal,
154
175
  ): Promise<BulkActionOutcome> => {
155
176
  const chunks = chunkTargets(targets);
156
177
  const total = targets.length;
@@ -158,14 +179,15 @@ export const runChunkedAction = async (
158
179
  const failedIds: string[] = [];
159
180
 
160
181
  for (let i = 0; i < chunks.length; i++) {
161
- if (isCancelled()) {
182
+ if (signal.aborted) {
162
183
  failedIds.push(...chunks.slice(i).flat());
163
184
  return { done, failedIds, cancelled: true };
164
185
  }
165
186
  const chunk = chunks[i];
166
- const attempted = await attempt(applyBatch(chunk));
187
+ const attempted = await attempt(applyBatch(chunk, signal));
167
188
  if (!attempted.ok) {
168
189
  failedIds.push(...chunks.slice(i).flat());
190
+ if (signal.aborted) return { done, failedIds, cancelled: true };
169
191
  onProgress({ done, total });
170
192
  return { done, failedIds, cancelled: false, error: attempted.error };
171
193
  }
@@ -194,6 +216,10 @@ export const runChunkedAction = async (
194
216
  * focused row, or a selection the surface already scoped — so nothing here
195
217
  * carries an account to split by. A caller with a cross-account selection
196
218
  * belongs on `runChunkedAction`, whose targets name one (#872).
219
+ *
220
+ * These callers have no Stop control to wire a signal to — a mutation over a
221
+ * handful of ids has no progress surface to stop from — so the run is handed a
222
+ * signal that never aborts.
197
223
  */
198
224
  export const runChunkedMutation = async (
199
225
  ids: readonly string[],
@@ -206,7 +232,7 @@ export const runChunkedMutation = async (
206
232
  return { successCount: chunk.length, failureCount: 0 };
207
233
  },
208
234
  () => {},
209
- () => false,
235
+ NEVER_ABORTED,
210
236
  );
211
237
  };
212
238
 
@@ -218,6 +244,7 @@ export interface FetchIdsPageResult {
218
244
  /** Fetches one page of matching message ids for the active predicate. */
219
245
  export type FetchIdsPage = (
220
246
  continuationToken: string | undefined,
247
+ signal: AbortSignal,
221
248
  ) => Promise<FetchIdsPageResult>;
222
249
 
223
250
  /**
@@ -230,33 +257,36 @@ export type FetchIdsPage = (
230
257
  * `failedIds` for the unreached remainder, because those ids were never
231
258
  * fetched — there is nothing to hand back. A predicate resolves fresh on
232
259
  * every run (D2), so resuming is re-invoking this same function with the same
233
- * predicate.
260
+ * predicate. Aborting also ends the page or batch on the wire, so the run gives
261
+ * up at most the one request rather than the whole page it was mid-way through.
234
262
  */
235
263
  export const runPredicateAction = async (
236
264
  fetchIdsPage: FetchIdsPage,
237
265
  total: number,
238
266
  applyBatch: ApplyBatch,
239
267
  onProgress: (progress: BulkActionProgress) => void,
240
- isCancelled: () => boolean,
268
+ signal: AbortSignal,
241
269
  ): Promise<BulkActionOutcome> => {
242
270
  let done = 0;
243
271
  const failedIds: string[] = [];
244
272
  let token: string | undefined;
245
273
 
246
274
  do {
247
- if (isCancelled()) {
275
+ if (signal.aborted) {
248
276
  return { done, failedIds, cancelled: true };
249
277
  }
250
278
 
251
- const fetched = await attempt(fetchIdsPage(token));
279
+ const fetched = await attempt(fetchIdsPage(token, signal));
252
280
  if (!fetched.ok) {
281
+ if (signal.aborted) return { done, failedIds, cancelled: true };
253
282
  return { done, failedIds, cancelled: false, error: fetched.error };
254
283
  }
255
284
  const page = fetched.value;
256
285
 
257
286
  if (page.ids.length > 0) {
258
- const attempted = await attempt(applyBatch(page.ids));
287
+ const attempted = await attempt(applyBatch(page.ids, signal));
259
288
  if (!attempted.ok) {
289
+ if (signal.aborted) return { done, failedIds, cancelled: true };
260
290
  return { done, failedIds, cancelled: false, error: attempted.error };
261
291
  }
262
292
  done += page.ids.length;
@@ -9,6 +9,7 @@ import {
9
9
  isNetworkError,
10
10
  isNotFound,
11
11
  isServerError,
12
+ restartExpectedMeta,
12
13
  shouldEscalate,
13
14
  softErrorStatuses,
14
15
  } from "./error-classifier";
@@ -366,6 +367,13 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
366
367
  );
367
368
  });
368
369
 
370
+ it("does not open the restart gap for a call site that never claimed one", () => {
371
+ assert.equal(
372
+ shouldEscalate(new ApiError("bad gateway", 502), ownsNotFound, "nobody"),
373
+ true,
374
+ );
375
+ });
376
+
369
377
  it("names the record's absence, whichever client raised it", () => {
370
378
  assert.equal(isNotFound(new ApiError("gone", 404)), true);
371
379
  assert.equal(isNotFound({ status: 404 }), true);
@@ -373,3 +381,54 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
373
381
  assert.equal(isNotFound(new TypeError("x is undefined")), false);
374
382
  });
375
383
  });
384
+
385
+ /**
386
+ * A self-update stops and starts the backend, so for those seconds the proxy in
387
+ * front of it has nothing to dial. That gap belongs to the surface that asked
388
+ * for the restart; every other 5xx, on that call site and on any other, is still
389
+ * the API reporting itself broken (#468).
390
+ */
391
+ describe("meta.restartExpected (the gap in a restart this call site asked for)", () => {
392
+ it("does NOT escalate the gateway statuses answered while the backend is down", () => {
393
+ for (const status of [502, 503, 504]) {
394
+ assert.equal(
395
+ shouldEscalate(
396
+ new ApiError("gateway", status),
397
+ restartExpectedMeta,
398
+ "nobody",
399
+ ),
400
+ false,
401
+ `a ${status} inside the restart window belongs to the update surface`,
402
+ );
403
+ }
404
+ });
405
+
406
+ it("escalates a 500 — a server that answered is up, and up and broken is not designed", () => {
407
+ assert.equal(
408
+ shouldEscalate(new ApiError("boom", 500), restartExpectedMeta, "nobody"),
409
+ true,
410
+ );
411
+ });
412
+
413
+ it("escalates the same 502 without the claim, however soft the call site is", () => {
414
+ assert.equal(
415
+ shouldEscalate(new ApiError("gateway", 502), undefined, "nobody"),
416
+ true,
417
+ );
418
+ assert.equal(
419
+ shouldEscalate(new ApiError("gateway", 502), { softError: true }, "user"),
420
+ true,
421
+ );
422
+ });
423
+
424
+ it("escalates a client bug raised inside the window", () => {
425
+ assert.equal(
426
+ shouldEscalate(
427
+ new TypeError("x is undefined"),
428
+ restartExpectedMeta,
429
+ "nobody",
430
+ ),
431
+ true,
432
+ );
433
+ });
434
+ });
@@ -143,6 +143,33 @@ const isSoftErrorMeta = (
143
143
  return status !== undefined && owned.includes(status);
144
144
  };
145
145
 
146
+ /**
147
+ * The statuses answered for the seconds a service is not there. Every `/api/*`
148
+ * request is proxied to apisix, which stays up while the backend behind it
149
+ * restarts and refuses in milliseconds — the 29ms 502 in #468, not a dial Caddy
150
+ * waited out. 503 and 504 are the same gap through a hop that answers
151
+ * differently.
152
+ */
153
+ const RESTART_GAP_STATUSES = [502, 503, 504];
154
+
155
+ /**
156
+ * The gap in a restart this call site asked for. A self-update stops and starts
157
+ * the backend on purpose, so the poll watching that run meets a proxy with
158
+ * nothing behind it — the designed shape of the operation the user pressed, not
159
+ * our API answering "I'm broken".
160
+ *
161
+ * A 500 is not in it: a server that answered is a server that is up, and up and
162
+ * broken is never designed.
163
+ */
164
+ const isDesignedRestartGap = (
165
+ error: unknown,
166
+ meta: Record<string, unknown> | undefined,
167
+ ): boolean => {
168
+ if (meta?.restartExpected !== true) return false;
169
+ const status = getErrorStatus(error);
170
+ return status !== undefined && RESTART_GAP_STATUSES.includes(status);
171
+ };
172
+
146
173
  /**
147
174
  * The single fail-fast decision: should this error escalate to the full-screen
148
175
  * fatal overlay? Default is YES — a non-2xx must never silently vanish.
@@ -151,7 +178,10 @@ const isSoftErrorMeta = (
151
178
  * 1. DEFAULT = escalate.
152
179
  * 2. A 5xx (500–599) ALWAYS escalates — no opt-out, even on a background
153
180
  * refetch, even when the call site marked `meta.softError`. Our API
154
- * answered "I'm broken"; that is never benign.
181
+ * answered "I'm broken"; that is never benign. The single exception is a
182
+ * 502/503/504 on a call site that declared it is polling across a restart it
183
+ * asked for (`meta.restartExpected`, #468): there the backend being gone is
184
+ * the operation working, and that call site renders the wait itself.
155
185
  * 3. A client-side exception ALWAYS escalates, on the same terms. It is our
156
186
  * bug; there is nothing for the user to retry and nothing to dismiss.
157
187
  * 4. A 401 on a request the user is waiting on ALWAYS escalates, on the same
@@ -173,7 +203,7 @@ export const shouldEscalate = (
173
203
  meta?: Record<string, unknown>,
174
204
  awaiting: Awaiting = "nobody",
175
205
  ): boolean => {
176
- if (isServerError(error)) return true;
206
+ if (isServerError(error)) return !isDesignedRestartGap(error, meta);
177
207
  if (isAbortError(error)) return false;
178
208
  if (isNetworkError(error)) return false;
179
209
  if (awaiting === "user" && isUnauthenticated(error)) return true;
@@ -209,6 +239,22 @@ export const softErrorStatuses = (
209
239
  ...statuses: number[]
210
240
  ): { softErrorStatuses: number[] } => ({ softErrorStatuses: statuses });
211
241
 
242
+ /**
243
+ * The one opt-out from rule 2, for a request watching a restart the run it is
244
+ * following performs. Set it only while a run is known to be in flight — the
245
+ * update poll carries it while it holds a run it started or the server's last
246
+ * answer reported one going — because a 502 outside that window is the API being
247
+ * broken, which is exactly what rule 2 is for.
248
+ *
249
+ * A call site that sets this owns the whole wait, so the window outlives the
250
+ * budget: the give-up screen keeps polling a server it has stopped expecting,
251
+ * and a fatal page thrown over that verdict would replace the one screen that
252
+ * names the run and the log to read. A 500 from that same poll still escalates.
253
+ */
254
+ export const restartExpectedMeta: { restartExpected: true } = {
255
+ restartExpected: true,
256
+ };
257
+
212
258
  /** The record is not there. */
213
259
  export const isNotFound = (error: unknown): boolean =>
214
260
  getErrorStatus(error) === 404;
@@ -72,6 +72,7 @@ function input(overrides: Partial<DeriveInput> = {}): DeriveInput {
72
72
  dismissedRunId: null,
73
73
  checkPress: null,
74
74
  checkFailure: null,
75
+ sighting: null,
75
76
  now: NOW,
76
77
  ...overrides,
77
78
  };