@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 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>;
@@ -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 = `(SELECT DISTINCT * FROM ${readList(files)})`;
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: readList(files), mask });
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.0",
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 = `(SELECT DISTINCT * FROM ${readList(files)})`
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: readList(files), mask })
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),