@oceanmetrics/engine 0.1.1 → 0.1.2
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/CHANGELOG.md +15 -0
- package/README.md +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/patient.d.ts +51 -0
- package/dist/patient.js +97 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/patient.ts +117 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2 (2026-10-23)
|
|
4
|
+
|
|
5
|
+
- New: `patientFetch(url, init?, { tries = 6, delayMs = 300, maxDelayMs = 4000, onRetry?, retryOn? })`, the one rule for
|
|
6
|
+
asking an ERDDAP request again when the server drops it (the places page and the indicators viewer each carried a copy).
|
|
7
|
+
Retried: a network error, a 404 whose body says "Currently unknown datasetID" (the mirror answers the same url 404 and
|
|
8
|
+
200 by turns), a 429 and a 5xx, after a pause of `delayMs` x the try, capped at `maxDelayMs`. Any other answer, a 4xx
|
|
9
|
+
included, is returned at once. `init.signal` aborts it at once, between tries too; `onRetry` reports each retry (try,
|
|
10
|
+
pause, reason, status). After `tries` failed tries it throws a `PatientFetchError` (an `ErddapUnreachable`, so the message
|
|
11
|
+
names the host and the last status: "erddap.oceanmetrics.io did not answer (HTTP 503 Service Unavailable, 6 tries)"),
|
|
12
|
+
with `status` and `cause`. Also exported: `isRetryable` (the default rule, for a `retryOn` that adds to it), `UNKNOWN_DATASET`.
|
|
13
|
+
`fetchRetry` is unchanged. To get a `typeof fetch` for `lobeAxes`, `fetchTimeExtent` and `sliceSource`:
|
|
14
|
+
`(u, init) => patientFetch(u as string, init)`.
|
|
15
|
+
- Differs from the two app copies on purpose: they returned the last response after the last try; this throws, and only a
|
|
16
|
+
404 that names the unknown dataset is retried (they retried every 404).
|
|
17
|
+
|
|
3
18
|
## 0.1.1 (2026-10-23)
|
|
4
19
|
|
|
5
20
|
- Fix: `sliceSource().slice()` read the lobes' slabs without the `SELECT DISTINCT` that `runPreview` applies, so where two
|
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ From `oceanmetrics/erddap-places` on 2026-10-23 (app 0.3.12, commit 3b1868a):
|
|
|
30
30
|
| `src/erddap.ts` | `app/src/lib/erddap.ts` | `.js` import specifiers only |
|
|
31
31
|
| `src/extent.ts` | `app/src/lib/extent.ts` | `.js` import specifiers only |
|
|
32
32
|
| `src/fetchRetry.ts` | `app/src/lib/fetchRetry.ts` | `.js` import specifiers only |
|
|
33
|
+
| `src/patient.ts` | new (0.1.2): the retry the places page and the indicators viewer each wrote | `patientFetch(url, init?, { tries = 6, delayMs, onRetry?, retryOn? })`: asks each request again on a network error, a 404 "Currently unknown datasetID", a 429 or a 5xx; abortable through `init.signal`; throws a `PatientFetchError` (an `ErddapUnreachable` with the last `status`) after `tries` |
|
|
33
34
|
| `src/cells.ts` | `app/src/lib/cells.ts` | nothing (`@oceanmetrics/map` `grid.ts` has its own port of `cellSquares`; `cellPoints` and `placeMapBounds` live here) |
|
|
34
35
|
| `src/dataset.ts` | `app/src/lib/catalog.ts` | the pure functions word for word (`toDataset`, `valueExpr`, `statsTemplate`, `lobeLonSpan`, `resolveDataset` ...); `loadDatasets` (reads the gazetteer's catalog.json) and `linkMismatch` (the app's wording) stay in the app |
|
|
35
36
|
| `src/chunks.ts` | `precompute/src/stats.ts` `window`, `chunks`, `chunkDaysFor`; `precompute/src/targets.ts` `MAX_CHUNK_DAYS`, `MAX_REQUEST_VALUES` | `chunks()` word for word; `chunkDaysFor()` takes the two limits as options (defaulting to 90 and 100,000); `window()` defaults to `'all'` (the precompute's `DAYS`) |
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { INFO_TIMEOUT_MS, addDays, clampWindow, day, daysBetween, defaultWindow,
|
|
|
9
9
|
export type { ClampResult, InfoTable, TimeExtent, Window, WindowOpts } from './extent.js';
|
|
10
10
|
export { ErddapUnreachable, fetchRetry, hostOf, mapFailMessage } from './fetchRetry.js';
|
|
11
11
|
export type { RetryOpts } from './fetchRetry.js';
|
|
12
|
+
export { PatientFetchError, UNKNOWN_DATASET, isRetryable, patientFetch } from './patient.js';
|
|
13
|
+
export type { PatientOpts, PatientRetry } from './patient.js';
|
|
12
14
|
export { axisSpacing, cellPoints, cellSquares, indexBounds, normalizeLon, pickCenter, placeMapBounds } from './cells.js';
|
|
13
15
|
export type { CellPoints, CellProps, CellSquares, CellSquaresOptions, LonCenter, ValueCell } from './cells.js';
|
|
14
16
|
export { isCategorical, lobeLonSpan, resolveDataset, statsTemplate, toDataset, toDatasetLon, toLongFormat, valueExpr, valueLabel, } from './dataset.js';
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { placeLobes } from './lobes.js';
|
|
|
7
7
|
export { AXIS_TIMEOUT_MS, SLICE_TIMEOUT_MS, constraint, fetchAxis, fetchJsonp, fetchSlab, griddapSliceUrl, griddapUrl, griddapUrls, isParquet, noonZ, parseCsvp, pickFormat, tableConstraint, tableToRows, tabledapPlaceConstraints, tabledapUrl, } from './erddap.js';
|
|
8
8
|
export { INFO_TIMEOUT_MS, addDays, clampWindow, day, daysBetween, defaultWindow, fetchTimeExtent, parseInfoExtent, parseSpacing, spanDays, stepLabel, timeInstant, } from './extent.js';
|
|
9
9
|
export { ErddapUnreachable, fetchRetry, hostOf, mapFailMessage } from './fetchRetry.js';
|
|
10
|
+
export { PatientFetchError, UNKNOWN_DATASET, isRetryable, patientFetch } from './patient.js';
|
|
10
11
|
export { axisSpacing, cellPoints, cellSquares, indexBounds, normalizeLon, pickCenter, placeMapBounds } from './cells.js';
|
|
11
12
|
export { isCategorical, lobeLonSpan, resolveDataset, statsTemplate, toDataset, toDatasetLon, toLongFormat, valueExpr, valueLabel, } from './dataset.js';
|
|
12
13
|
export { MAX_CHUNK_DAYS, MAX_REQUEST_VALUES, chunkDaysFor, chunks, window } from './chunks.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ErddapUnreachable } from './fetchRetry.js';
|
|
2
|
+
/** the body text ERDDAP sends with a 404 when the dataset is not (yet) on the server that answered */
|
|
3
|
+
export declare const UNKNOWN_DATASET = "Currently unknown datasetID";
|
|
4
|
+
/** one retry about to happen: reported through `onRetry` */
|
|
5
|
+
export interface PatientRetry {
|
|
6
|
+
/** the try that just failed (1 = the first) */
|
|
7
|
+
attempt: number;
|
|
8
|
+
/** tries in all */
|
|
9
|
+
tries: number;
|
|
10
|
+
/** the pause before the next try (ms) */
|
|
11
|
+
delayMs: number;
|
|
12
|
+
url: string;
|
|
13
|
+
/** why: "HTTP 503 Service Unavailable", "HTTP 404 (unknown datasetID)" or "network error: Failed to fetch" */
|
|
14
|
+
reason: string;
|
|
15
|
+
/** the status that failed the try; absent for a network error */
|
|
16
|
+
status?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface PatientOpts {
|
|
19
|
+
/** tries in all (1 = no retry) */
|
|
20
|
+
tries?: number;
|
|
21
|
+
/** the pause before the second try (ms); before try n + 1 it is `delayMs * n`, up to `maxDelayMs`. 0 = no pause */
|
|
22
|
+
delayMs?: number;
|
|
23
|
+
/** the longest pause (ms) */
|
|
24
|
+
maxDelayMs?: number;
|
|
25
|
+
/** called before each pause */
|
|
26
|
+
onRetry?: (r: PatientRetry) => void;
|
|
27
|
+
/** whether an answer is to be asked for again; the default is `isRetryable`. It replaces the default, so a caller who
|
|
28
|
+
* wants more can call `isRetryable(res)` itself. It reads `res.clone()` if it needs the body; the caller still gets `res` */
|
|
29
|
+
retryOn?: (res: Response) => boolean | Promise<boolean>;
|
|
30
|
+
/** for tests */
|
|
31
|
+
fetchFn?: typeof fetch;
|
|
32
|
+
wait?: (ms: number, signal?: AbortSignal) => Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
/** the last failure after every try: an ErddapUnreachable that also says which status it was (absent: a network error) */
|
|
35
|
+
export declare class PatientFetchError extends ErddapUnreachable {
|
|
36
|
+
readonly status?: number | undefined;
|
|
37
|
+
constructor(host: string, reason: string, attempts: number, url: string, status?: number | undefined, options?: {
|
|
38
|
+
cause?: unknown;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* the default rule: a 429 or a 5xx, and a 404 whose body is ERDDAP's "Currently unknown datasetID". Any other 404 is a real
|
|
43
|
+
* answer (a wrong dataset id, a coordinate out of range) and is not asked for again. The body is read from a clone.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isRetryable(res: Response): Promise<boolean>;
|
|
46
|
+
/**
|
|
47
|
+
* fetch `url`, asking again when the server drops the request (see the top of this file). Returns the first answer that is
|
|
48
|
+
* not to be retried (a 2xx, or a 4xx that is the server's own); throws a PatientFetchError after `tries` failed tries.
|
|
49
|
+
* `init.signal` aborts it at once, between tries too.
|
|
50
|
+
*/
|
|
51
|
+
export declare function patientFetch(url: string | URL, init?: RequestInit, opts?: PatientOpts): Promise<Response>;
|
package/dist/patient.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// patientFetch: ask each ERDDAP request again, on its own, when the server drops it.
|
|
2
|
+
//
|
|
3
|
+
// the mirror erddap.oceanmetrics.io answers the same url 404 ("Currently unknown datasetID", one backend does not hold the
|
|
4
|
+
// dataset yet) and 200 by turns under load (seen 2026-10-23), and any ERDDAP can answer 429 or a 5xx. retrying each request
|
|
5
|
+
// beats retrying the whole slice: with half the answers dropped, six tries of one request fail one time in 64. this is the
|
|
6
|
+
// one implementation of that rule (the places page and the indicators viewer carried a copy each). fetchRetry stays as it
|
|
7
|
+
// is (a timeout per attempt, a short retry for the engine's own requests); this composes with it by naming the failure the
|
|
8
|
+
// same way: the last failure is an ErddapUnreachable (a PatientFetchError, which also carries the status), so the toast
|
|
9
|
+
// says "host did not answer (HTTP 503 Service Unavailable, 6 tries)" for both.
|
|
10
|
+
//
|
|
11
|
+
// retried: a network error (the browser's TypeError "Failed to fetch"), a 404 whose body says "Currently unknown datasetID",
|
|
12
|
+
// a 429 and a 5xx. every other answer, a 4xx included, is returned at once for the caller to read. the caller's own abort
|
|
13
|
+
// (`init.signal`) stops it at once, between tries too, as the AbortError it is.
|
|
14
|
+
import { ErddapUnreachable, hostOf } from './fetchRetry.js';
|
|
15
|
+
/** the body text ERDDAP sends with a 404 when the dataset is not (yet) on the server that answered */
|
|
16
|
+
export const UNKNOWN_DATASET = 'Currently unknown datasetID';
|
|
17
|
+
/** the last failure after every try: an ErddapUnreachable that also says which status it was (absent: a network error) */
|
|
18
|
+
export class PatientFetchError extends ErddapUnreachable {
|
|
19
|
+
status;
|
|
20
|
+
constructor(host, reason, attempts, url, status, options) {
|
|
21
|
+
super(host, reason, attempts, url);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.name = 'PatientFetchError';
|
|
24
|
+
if (options?.cause !== undefined)
|
|
25
|
+
this.cause = options.cause;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* the default rule: a 429 or a 5xx, and a 404 whose body is ERDDAP's "Currently unknown datasetID". Any other 404 is a real
|
|
30
|
+
* answer (a wrong dataset id, a coordinate out of range) and is not asked for again. The body is read from a clone.
|
|
31
|
+
*/
|
|
32
|
+
export async function isRetryable(res) {
|
|
33
|
+
if (res.status === 429 || res.status >= 500)
|
|
34
|
+
return true;
|
|
35
|
+
if (res.status !== 404)
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
return (await res.clone().text()).includes(UNKNOWN_DATASET);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const abortReason = (signal) => signal.reason ?? new DOMException('aborted', 'AbortError');
|
|
45
|
+
/** a pause that an abort cuts short (rejecting with the abort reason) */
|
|
46
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
47
|
+
if (signal?.aborted)
|
|
48
|
+
return reject(abortReason(signal));
|
|
49
|
+
const done = () => { clearTimeout(t); signal?.removeEventListener('abort', onAbort); };
|
|
50
|
+
const onAbort = () => { done(); reject(abortReason(signal)); };
|
|
51
|
+
const t = setTimeout(() => { done(); resolve(); }, ms);
|
|
52
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* fetch `url`, asking again when the server drops the request (see the top of this file). Returns the first answer that is
|
|
56
|
+
* not to be retried (a 2xx, or a 4xx that is the server's own); throws a PatientFetchError after `tries` failed tries.
|
|
57
|
+
* `init.signal` aborts it at once, between tries too.
|
|
58
|
+
*/
|
|
59
|
+
export async function patientFetch(url, init, opts = {}) {
|
|
60
|
+
const { tries = 6, delayMs = 300, maxDelayMs = 4_000, onRetry, retryOn = isRetryable, fetchFn = fetch, wait = sleep } = opts;
|
|
61
|
+
const signal = init?.signal ?? undefined;
|
|
62
|
+
const href = String(url);
|
|
63
|
+
let reason = '';
|
|
64
|
+
let status;
|
|
65
|
+
let cause;
|
|
66
|
+
for (let i = 1; i <= Math.max(1, tries); i++) {
|
|
67
|
+
if (signal?.aborted)
|
|
68
|
+
throw abortReason(signal);
|
|
69
|
+
status = undefined;
|
|
70
|
+
cause = undefined;
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetchFn(href, init);
|
|
73
|
+
if (!(await retryOn(res)))
|
|
74
|
+
return res;
|
|
75
|
+
status = res.status;
|
|
76
|
+
reason = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
|
|
77
|
+
if (res.status === 404)
|
|
78
|
+
reason += ' (unknown datasetID)';
|
|
79
|
+
void res.body?.cancel().catch(() => { }); // free the connection of an answer we drop (not awaited: a cancel waits for a clone's reader)
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
if (signal?.aborted)
|
|
83
|
+
throw e; // superseded: not a failure
|
|
84
|
+
if (!(e instanceof TypeError))
|
|
85
|
+
throw e; // not the network: the caller's own error
|
|
86
|
+
reason = `network error: ${e.message}`;
|
|
87
|
+
cause = e;
|
|
88
|
+
}
|
|
89
|
+
if (i < tries) {
|
|
90
|
+
const pause = Math.min(delayMs * i, maxDelayMs);
|
|
91
|
+
onRetry?.({ attempt: i, tries, delayMs: pause, url: href, reason, ...(status !== undefined ? { status } : {}) });
|
|
92
|
+
if (pause > 0)
|
|
93
|
+
await wait(pause, signal);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw new PatientFetchError(hostOf(href), reason, Math.max(1, tries), href, status, { cause });
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oceanmetrics/engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "the browser engine of Ocean Metrics: the place mask, ERDDAP requests, the SQL shared with the Python engine, DuckDB-WASM statistics and the chunked live preview",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ocean",
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ export {
|
|
|
18
18
|
export type { ClampResult, InfoTable, TimeExtent, Window, WindowOpts } from './extent.js'
|
|
19
19
|
export { ErddapUnreachable, fetchRetry, hostOf, mapFailMessage } from './fetchRetry.js'
|
|
20
20
|
export type { RetryOpts } from './fetchRetry.js'
|
|
21
|
+
export { PatientFetchError, UNKNOWN_DATASET, isRetryable, patientFetch } from './patient.js'
|
|
22
|
+
export type { PatientOpts, PatientRetry } from './patient.js'
|
|
21
23
|
export { axisSpacing, cellPoints, cellSquares, indexBounds, normalizeLon, pickCenter, placeMapBounds } from './cells.js'
|
|
22
24
|
export type { CellPoints, CellProps, CellSquares, CellSquaresOptions, LonCenter, ValueCell } from './cells.js'
|
|
23
25
|
export {
|
package/src/patient.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// patientFetch: ask each ERDDAP request again, on its own, when the server drops it.
|
|
2
|
+
//
|
|
3
|
+
// the mirror erddap.oceanmetrics.io answers the same url 404 ("Currently unknown datasetID", one backend does not hold the
|
|
4
|
+
// dataset yet) and 200 by turns under load (seen 2026-10-23), and any ERDDAP can answer 429 or a 5xx. retrying each request
|
|
5
|
+
// beats retrying the whole slice: with half the answers dropped, six tries of one request fail one time in 64. this is the
|
|
6
|
+
// one implementation of that rule (the places page and the indicators viewer carried a copy each). fetchRetry stays as it
|
|
7
|
+
// is (a timeout per attempt, a short retry for the engine's own requests); this composes with it by naming the failure the
|
|
8
|
+
// same way: the last failure is an ErddapUnreachable (a PatientFetchError, which also carries the status), so the toast
|
|
9
|
+
// says "host did not answer (HTTP 503 Service Unavailable, 6 tries)" for both.
|
|
10
|
+
//
|
|
11
|
+
// retried: a network error (the browser's TypeError "Failed to fetch"), a 404 whose body says "Currently unknown datasetID",
|
|
12
|
+
// a 429 and a 5xx. every other answer, a 4xx included, is returned at once for the caller to read. the caller's own abort
|
|
13
|
+
// (`init.signal`) stops it at once, between tries too, as the AbortError it is.
|
|
14
|
+
import { ErddapUnreachable, hostOf } from './fetchRetry.js'
|
|
15
|
+
|
|
16
|
+
/** the body text ERDDAP sends with a 404 when the dataset is not (yet) on the server that answered */
|
|
17
|
+
export const UNKNOWN_DATASET = 'Currently unknown datasetID'
|
|
18
|
+
|
|
19
|
+
/** one retry about to happen: reported through `onRetry` */
|
|
20
|
+
export interface PatientRetry {
|
|
21
|
+
/** the try that just failed (1 = the first) */
|
|
22
|
+
attempt : number
|
|
23
|
+
/** tries in all */
|
|
24
|
+
tries : number
|
|
25
|
+
/** the pause before the next try (ms) */
|
|
26
|
+
delayMs : number
|
|
27
|
+
url : string
|
|
28
|
+
/** why: "HTTP 503 Service Unavailable", "HTTP 404 (unknown datasetID)" or "network error: Failed to fetch" */
|
|
29
|
+
reason : string
|
|
30
|
+
/** the status that failed the try; absent for a network error */
|
|
31
|
+
status ?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PatientOpts {
|
|
35
|
+
/** tries in all (1 = no retry) */
|
|
36
|
+
tries ?: number
|
|
37
|
+
/** the pause before the second try (ms); before try n + 1 it is `delayMs * n`, up to `maxDelayMs`. 0 = no pause */
|
|
38
|
+
delayMs ?: number
|
|
39
|
+
/** the longest pause (ms) */
|
|
40
|
+
maxDelayMs?: number
|
|
41
|
+
/** called before each pause */
|
|
42
|
+
onRetry ?: (r: PatientRetry) => void
|
|
43
|
+
/** whether an answer is to be asked for again; the default is `isRetryable`. It replaces the default, so a caller who
|
|
44
|
+
* wants more can call `isRetryable(res)` itself. It reads `res.clone()` if it needs the body; the caller still gets `res` */
|
|
45
|
+
retryOn ?: (res: Response) => boolean | Promise<boolean>
|
|
46
|
+
/** for tests */
|
|
47
|
+
fetchFn ?: typeof fetch
|
|
48
|
+
wait ?: (ms: number, signal?: AbortSignal) => Promise<unknown>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** the last failure after every try: an ErddapUnreachable that also says which status it was (absent: a network error) */
|
|
52
|
+
export class PatientFetchError extends ErddapUnreachable {
|
|
53
|
+
constructor(host: string, reason: string, attempts: number, url: string, readonly status?: number, options?: { cause?: unknown }) {
|
|
54
|
+
super(host, reason, attempts, url)
|
|
55
|
+
this.name = 'PatientFetchError'
|
|
56
|
+
if (options?.cause !== undefined) this.cause = options.cause
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* the default rule: a 429 or a 5xx, and a 404 whose body is ERDDAP's "Currently unknown datasetID". Any other 404 is a real
|
|
62
|
+
* answer (a wrong dataset id, a coordinate out of range) and is not asked for again. The body is read from a clone.
|
|
63
|
+
*/
|
|
64
|
+
export async function isRetryable(res: Response): Promise<boolean> {
|
|
65
|
+
if (res.status === 429 || res.status >= 500) return true
|
|
66
|
+
if (res.status !== 404) return false
|
|
67
|
+
try { return (await res.clone().text()).includes(UNKNOWN_DATASET) } catch { return false }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const abortReason = (signal: AbortSignal) => signal.reason ?? new DOMException('aborted', 'AbortError')
|
|
71
|
+
|
|
72
|
+
/** a pause that an abort cuts short (rejecting with the abort reason) */
|
|
73
|
+
const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve, reject) => {
|
|
74
|
+
if (signal?.aborted) return reject(abortReason(signal))
|
|
75
|
+
const done = () => { clearTimeout(t); signal?.removeEventListener('abort', onAbort) }
|
|
76
|
+
const onAbort = () => { done(); reject(abortReason(signal!)) }
|
|
77
|
+
const t = setTimeout(() => { done(); resolve() }, ms)
|
|
78
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* fetch `url`, asking again when the server drops the request (see the top of this file). Returns the first answer that is
|
|
83
|
+
* not to be retried (a 2xx, or a 4xx that is the server's own); throws a PatientFetchError after `tries` failed tries.
|
|
84
|
+
* `init.signal` aborts it at once, between tries too.
|
|
85
|
+
*/
|
|
86
|
+
export async function patientFetch(url: string | URL, init?: RequestInit, opts: PatientOpts = {}): Promise<Response> {
|
|
87
|
+
const { tries = 6, delayMs = 300, maxDelayMs = 4_000, onRetry, retryOn = isRetryable, fetchFn = fetch, wait = sleep } = opts
|
|
88
|
+
const signal = init?.signal ?? undefined
|
|
89
|
+
const href = String(url)
|
|
90
|
+
let reason = ''
|
|
91
|
+
let status: number | undefined
|
|
92
|
+
let cause: unknown
|
|
93
|
+
for (let i = 1; i <= Math.max(1, tries); i++) {
|
|
94
|
+
if (signal?.aborted) throw abortReason(signal)
|
|
95
|
+
status = undefined
|
|
96
|
+
cause = undefined
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetchFn(href, init)
|
|
99
|
+
if (!(await retryOn(res))) return res
|
|
100
|
+
status = res.status
|
|
101
|
+
reason = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`
|
|
102
|
+
if (res.status === 404) reason += ' (unknown datasetID)'
|
|
103
|
+
void res.body?.cancel().catch(() => {}) // free the connection of an answer we drop (not awaited: a cancel waits for a clone's reader)
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (signal?.aborted) throw e // superseded: not a failure
|
|
106
|
+
if (!(e instanceof TypeError)) throw e // not the network: the caller's own error
|
|
107
|
+
reason = `network error: ${e.message}`
|
|
108
|
+
cause = e
|
|
109
|
+
}
|
|
110
|
+
if (i < tries) {
|
|
111
|
+
const pause = Math.min(delayMs * i, maxDelayMs)
|
|
112
|
+
onRetry?.({ attempt: i, tries, delayMs: pause, url: href, reason, ...(status !== undefined ? { status } : {}) })
|
|
113
|
+
if (pause > 0) await wait(pause, signal)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw new PatientFetchError(hostOf(href), reason, Math.max(1, tries), href, status, { cause })
|
|
117
|
+
}
|