@oceanmetrics/engine 0.1.0 → 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 +21 -0
- package/README.md +4 -1
- 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/dist/preview.js +5 -2
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/patient.ts +117 -0
- package/src/preview.ts +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
18
|
+
## 0.1.1 (2026-10-23)
|
|
19
|
+
|
|
20
|
+
- Fix: `sliceSource().slice()` read the lobes' slabs without the `SELECT DISTINCT` that `runPreview` applies, so where two
|
|
21
|
+
places' boxes overlap (Channel Islands and Chumash) every cell of the overlap came back twice. Regression test with two
|
|
22
|
+
overlapping synthetic slabs.
|
|
23
|
+
|
|
3
24
|
## 0.1.0 (2026-10-23)
|
|
4
25
|
|
|
5
26
|
The browser engine, build week 2 step 2b: erddap-places' engine as a platform package, so the indicators Map's `grid`
|
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`) |
|
|
@@ -112,7 +113,9 @@ const run = await runPreview({
|
|
|
112
113
|
`@oceanmetrics/map`'s `gridSlot(spec, source)`: `slice(t)` fetches the one step `t` (a day or an ISO instant;
|
|
113
114
|
`null` is the extent's end, or ERDDAP's `last` without an extent) of every lobe and returns `last_step`'s rows as
|
|
114
115
|
`{ lat, lon, value, weight }`, the mask's own centres, a cell without data kept with `value: null`. The mask goes into
|
|
115
|
-
the engine once, at the first slice.
|
|
116
|
+
the engine once, at the first slice. The slabs of the lobes are read as `SELECT DISTINCT *` (as `runPreview` does), so a
|
|
117
|
+
cell inside two overlapping boxes (Channel Islands and Chumash) comes back once, not twice (0.1.1); the mask you pass is
|
|
118
|
+
the places' union and must hold each cell once.
|
|
116
119
|
|
|
117
120
|
## DuckDB-WASM: the peer and the worker setup
|
|
118
121
|
|
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/dist/preview.js
CHANGED
|
@@ -91,6 +91,9 @@ async function loadSlabs(engine, urls, format, signal, retry) {
|
|
|
91
91
|
return files;
|
|
92
92
|
}
|
|
93
93
|
const readList = (files) => `read_parquet([${files.map((f) => `'${f}'`).join(', ')}])`;
|
|
94
|
+
// the lobes' slabs as one relation without a row twice: where two places' boxes overlap (Channel Islands and Chumash)
|
|
95
|
+
// both slabs hold the cells of the overlap, and the join over the mask would return each of them twice
|
|
96
|
+
const distinctSlab = (files) => `(SELECT DISTINCT * FROM ${readList(files)})`;
|
|
94
97
|
/**
|
|
95
98
|
* The statistics of `variable` over the place for [t0, t1], chunk by chunk. Each chunk's slab (one Parquet request per
|
|
96
99
|
* lobe) is registered, aggregated with the template over the mask and dropped; `onChunk` gets its rows as soon as they
|
|
@@ -142,7 +145,7 @@ export async function runPreview(o) {
|
|
|
142
145
|
files.push(f);
|
|
143
146
|
}
|
|
144
147
|
// DISTINCT: a coarse axis (the 8-day grid) can snap a chunk's two endpoints onto one step
|
|
145
|
-
const slab =
|
|
148
|
+
const slab = distinctSlab(files);
|
|
146
149
|
const out = await engine.runTemplate(name, { expr, slab, mask });
|
|
147
150
|
aborted(signal);
|
|
148
151
|
// two chunks snapped onto the same step of a coarse axis give that step twice, identical: keep the first
|
|
@@ -187,7 +190,7 @@ export function sliceSource(o) {
|
|
|
187
190
|
try {
|
|
188
191
|
await inserted;
|
|
189
192
|
aborted(signal);
|
|
190
|
-
const rows = await o.engine.runTemplate('last_step', { expr: valueExpr(o.variable), slab:
|
|
193
|
+
const rows = await o.engine.runTemplate('last_step', { expr: valueExpr(o.variable), slab: distinctSlab(files), mask });
|
|
191
194
|
return rows.map((r) => ({
|
|
192
195
|
lat: Number(r.latitude), lon: Number(r.longitude), weight: Number(r.weight),
|
|
193
196
|
value: r.value === null || r.value === undefined ? null : Number(r.value),
|
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
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -109,6 +109,9 @@ async function loadSlabs(engine: Engine, urls: string[], format: Format, signal:
|
|
|
109
109
|
return files
|
|
110
110
|
}
|
|
111
111
|
const readList = (files: string[]) => `read_parquet([${files.map((f) => `'${f}'`).join(', ')}])`
|
|
112
|
+
// the lobes' slabs as one relation without a row twice: where two places' boxes overlap (Channel Islands and Chumash)
|
|
113
|
+
// both slabs hold the cells of the overlap, and the join over the mask would return each of them twice
|
|
114
|
+
const distinctSlab = (files: string[]) => `(SELECT DISTINCT * FROM ${readList(files)})`
|
|
112
115
|
|
|
113
116
|
// ---- the preview ----
|
|
114
117
|
|
|
@@ -205,7 +208,7 @@ export async function runPreview(o: PreviewOptions): Promise<PreviewResult> {
|
|
|
205
208
|
try {
|
|
206
209
|
for (const s of slabs) { const f = `${uid('slab')}.parquet`; await engine.registerBuffer(f, s.buffer!); files.push(f) }
|
|
207
210
|
// DISTINCT: a coarse axis (the 8-day grid) can snap a chunk's two endpoints onto one step
|
|
208
|
-
const slab =
|
|
211
|
+
const slab = distinctSlab(files)
|
|
209
212
|
const out = await engine.runTemplate(name, { expr, slab, mask })
|
|
210
213
|
aborted(signal)
|
|
211
214
|
// two chunks snapped onto the same step of a coarse axis give that step twice, identical: keep the first
|
|
@@ -266,7 +269,7 @@ export function sliceSource(o: SliceSourceOptions): SliceSource {
|
|
|
266
269
|
try {
|
|
267
270
|
await inserted
|
|
268
271
|
aborted(signal)
|
|
269
|
-
const rows = await o.engine.runTemplate('last_step', { expr: valueExpr(o.variable), slab:
|
|
272
|
+
const rows = await o.engine.runTemplate('last_step', { expr: valueExpr(o.variable), slab: distinctSlab(files), mask })
|
|
270
273
|
return rows.map((r) => ({
|
|
271
274
|
lat: Number(r.latitude), lon: Number(r.longitude), weight: Number(r.weight),
|
|
272
275
|
value: r.value === null || r.value === undefined ? null : Number(r.value),
|