@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 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>;
@@ -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.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
+ }