@chaosity/location-client 0.7.0 → 0.8.0

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.
@@ -19,7 +19,9 @@ export declare function parseErrorResponse(status: number, statusText: string, b
19
19
  * expired, and API Gateway turns that into a 401. Its other refusals — no
20
20
  * domain configured for the application, an Origin the application does not
21
21
  * allow — are a Deny policy or a service 403, and a fresh token changes
22
- * neither. Retrying those would send, and bill, the same doomed request twice.
22
+ * neither. Retrying those sends the same doomed request twice, for the same
23
+ * answer — which is the whole cost, since the service meters successful
24
+ * requests and no error response is billed whatever its status.
23
25
  *
24
26
  * Shared by both send paths so the browser client and the server connector
25
27
  * cannot come to different conclusions about the same response.
@@ -30,3 +32,18 @@ export declare function isTokenRejected(err: unknown): boolean;
30
32
  * API sends the first; a date is handled so a proxy or gateway cannot surprise us.
31
33
  */
32
34
  export declare function parseRetryAfter(value: string | null | undefined): number | undefined;
35
+ /**
36
+ * No token to send, so nothing is sent.
37
+ *
38
+ * Every send path resolves a token before it builds a request, and every one of
39
+ * them can come up empty — a provider that has not initialised, a server action
40
+ * that returned nothing, credentials that are not configured. Sending anyway
41
+ * puts the literal string `Bearer undefined` on the wire, which the API answers
42
+ * with a 401 the caller then has to work backwards from — a whole round trip,
43
+ * paid for out of the caller's own deadline, to be told what it already knew.
44
+ * The map fetches did exactly that until #37.
45
+ *
46
+ * `advice` says what to check, because that differs by path: a server connector
47
+ * wants its client credentials looked at, a browser map wants its token source.
48
+ */
49
+ export declare function noTokenAvailable(advice: string): LocationServiceException;
@@ -90,7 +90,9 @@ function statusCode(status) {
90
90
  * expired, and API Gateway turns that into a 401. Its other refusals — no
91
91
  * domain configured for the application, an Origin the application does not
92
92
  * allow — are a Deny policy or a service 403, and a fresh token changes
93
- * neither. Retrying those would send, and bill, the same doomed request twice.
93
+ * neither. Retrying those sends the same doomed request twice, for the same
94
+ * answer — which is the whole cost, since the service meters successful
95
+ * requests and no error response is billed whatever its status.
94
96
  *
95
97
  * Shared by both send paths so the browser client and the server connector
96
98
  * cannot come to different conclusions about the same response.
@@ -113,3 +115,24 @@ export function parseRetryAfter(value) {
113
115
  return Math.max(0, date - Date.now());
114
116
  return undefined;
115
117
  }
118
+ /**
119
+ * No token to send, so nothing is sent.
120
+ *
121
+ * Every send path resolves a token before it builds a request, and every one of
122
+ * them can come up empty — a provider that has not initialised, a server action
123
+ * that returned nothing, credentials that are not configured. Sending anyway
124
+ * puts the literal string `Bearer undefined` on the wire, which the API answers
125
+ * with a 401 the caller then has to work backwards from — a whole round trip,
126
+ * paid for out of the caller's own deadline, to be told what it already knew.
127
+ * The map fetches did exactly that until #37.
128
+ *
129
+ * `advice` says what to check, because that differs by path: a server connector
130
+ * wants its client credentials looked at, a browser map wants its token source.
131
+ */
132
+ export function noTokenAvailable(advice) {
133
+ return new LocationServiceException({
134
+ code: 'InvalidCredentialsException',
135
+ message: `No token available — ${advice}`,
136
+ details: { source: 'client' },
137
+ });
138
+ }
@@ -1,11 +1,38 @@
1
1
  /** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
2
2
  export declare const DEFAULT_TIMEOUT_MS = 10000;
3
+ /**
4
+ * Ceiling for the WHOLE call — every attempt plus every wait between them.
5
+ *
6
+ * `timeoutMs` bounds an attempt, not a call, and the gap between those two is
7
+ * where the caller's own deadline disappears. The API answers a spent quota
8
+ * with `Retry-After: 60`, which the retry loop honoured literally: two waits of
9
+ * a minute each, so one call could sit for ~120 s — past any Lambda budget,
10
+ * past any HTTP gateway, and until now uncancellable (#37).
11
+ *
12
+ * 30 s is picked to sit just under the old worst case: three default attempts
13
+ * that all time out, plus their backoff, came to ~30.75 s. The overlap is not
14
+ * quite nothing — when both earlier attempts burn their full 10 s, the third is
15
+ * clamped to the ~9.25 s that remain, so a response arriving in its final
16
+ * ~0.75 s used to succeed and now times out. That window is why this ships in a
17
+ * MINOR rather than a patch. What it buys is that no call can be made to sit
18
+ * out a retry hint longer than the caller has.
19
+ */
20
+ export declare const DEFAULT_OVERALL_TIMEOUT_MS = 30000;
3
21
  export declare const DEFAULT_MAX_ATTEMPTS = 3;
4
22
  export interface RequestOptions {
5
23
  /** Caller cancellation. Aborting rejects with code `AbortedException`. */
6
24
  signal?: AbortSignal;
7
25
  /** Per ATTEMPT, not for the whole call. Default 10 s. */
8
26
  timeoutMs?: number;
27
+ /**
28
+ * The whole call — attempts and the waits between them. Default 30 s.
29
+ *
30
+ * No attempt is given more than what is left of it, and a retry that would
31
+ * have to wait longer than what is left is not made at all: the API's own
32
+ * error comes back instead, `retryAfterMs` intact, so the caller can decide
33
+ * whether to queue the work or drop it.
34
+ */
35
+ overallTimeoutMs?: number;
9
36
  /** `false` disables retries entirely. Default 3 attempts = 2 retries. */
10
37
  retry?: false | {
11
38
  maxAttempts?: number;
@@ -22,3 +49,12 @@ export declare function backoffMs(attempt: number, random?: () => number): numbe
22
49
  * `details.source = 'client'` so it is distinguishable from the API's own 504.
23
50
  */
24
51
  export declare function requestJson<T>(url: string, init: RequestInit, options?: RequestOptions): Promise<T>;
52
+ /**
53
+ * One request answered as a Blob — the static map path.
54
+ *
55
+ * Exists so the two map fetches are not the only calls in the package without
56
+ * a timeout, a retry or a signal: they used their own bare `fetch`, so a
57
+ * network fault there escaped as a raw `TypeError` while the identical fault on
58
+ * any other call arrived as `NetworkException` (#37).
59
+ */
60
+ export declare function requestBlob(url: string, init: RequestInit, options?: RequestOptions): Promise<Blob>;
@@ -4,6 +4,24 @@ import { parseErrorResponse, parseRetryAfter } from './errors.js';
4
4
  const log = debug('location-client:transport');
5
5
  /** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
6
6
  export const DEFAULT_TIMEOUT_MS = 10000;
7
+ /**
8
+ * Ceiling for the WHOLE call — every attempt plus every wait between them.
9
+ *
10
+ * `timeoutMs` bounds an attempt, not a call, and the gap between those two is
11
+ * where the caller's own deadline disappears. The API answers a spent quota
12
+ * with `Retry-After: 60`, which the retry loop honoured literally: two waits of
13
+ * a minute each, so one call could sit for ~120 s — past any Lambda budget,
14
+ * past any HTTP gateway, and until now uncancellable (#37).
15
+ *
16
+ * 30 s is picked to sit just under the old worst case: three default attempts
17
+ * that all time out, plus their backoff, came to ~30.75 s. The overlap is not
18
+ * quite nothing — when both earlier attempts burn their full 10 s, the third is
19
+ * clamped to the ~9.25 s that remain, so a response arriving in its final
20
+ * ~0.75 s used to succeed and now times out. That window is why this ships in a
21
+ * MINOR rather than a patch. What it buys is that no call can be made to sit
22
+ * out a retry hint longer than the caller has.
23
+ */
24
+ export const DEFAULT_OVERALL_TIMEOUT_MS = 30000;
7
25
  export const DEFAULT_MAX_ATTEMPTS = 3;
8
26
  const BACKOFF_BASE_MS = 250;
9
27
  const BACKOFF_CAP_MS = 4000;
@@ -40,38 +58,86 @@ export function backoffMs(attempt, random = Math.random) {
40
58
  const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
41
59
  return Math.floor(random() * ceiling);
42
60
  }
43
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
44
61
  /**
45
- * One JSON request, with timeout, cancellation and retry.
62
+ * Sleep, unless the caller aborts first.
46
63
  *
47
- * Every failure leaves as a LocationServiceException — a fetch rejection
48
- * becomes `NetworkException` with the original as `cause`, an abort becomes
49
- * `AbortedException`, a timeout becomes `TimeoutException` with
50
- * `details.source = 'client'` so it is distinguishable from the API's own 504.
64
+ * The timer used to be uncancellable, so an `abort()` during backoff was
65
+ * ignored until it elapsed — up to a whole `Retry-After` — and the loop only
66
+ * noticed at the top of the next attempt (#37). Resolving early is all that is
67
+ * needed: the loop's own pre-attempt check is what raises `AbortedException`,
68
+ * so exactly one place decides what an abort means.
51
69
  */
52
- export async function requestJson(url, init, options = {}) {
70
+ function sleep(ms, signal) {
71
+ if (ms <= 0 || signal?.aborted)
72
+ return Promise.resolve();
73
+ return new Promise((resolve) => {
74
+ const done = () => {
75
+ clearTimeout(timer);
76
+ signal?.removeEventListener('abort', done);
77
+ resolve();
78
+ };
79
+ const timer = setTimeout(done, ms);
80
+ signal?.addEventListener('abort', done, { once: true });
81
+ });
82
+ }
83
+ /**
84
+ * One request, with timeout, cancellation and retry.
85
+ *
86
+ * The body is read INSIDE the attempt loop deliberately: a truncated or
87
+ * malformed body is a failed attempt like any other and earns the same
88
+ * wrapping and the same retry as a dropped socket. Handing the `Response` back
89
+ * for the caller to read would move that outside the loop, where a bare
90
+ * `SyntaxError` escapes as itself.
91
+ */
92
+ async function request(url, init, options, read) {
53
93
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
94
+ const overallTimeoutMs = options.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS;
54
95
  const maxAttempts = options.retry === false
55
96
  ? 1
56
97
  : (options.retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
98
+ // A request budget of zero attempts is a caller mistake, not a policy. It
99
+ // used to fall straight through the loop and raise `InternalException` for a
100
+ // request that was never made — an error about our own internals, for their
101
+ // typo (#37).
102
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
103
+ throw new LocationServiceException({
104
+ code: 'ValidationException',
105
+ message: `retry.maxAttempts must be a whole number of at least 1; received ${maxAttempts}`,
106
+ details: { source: 'client' },
107
+ });
108
+ }
109
+ const deadline = Date.now() + overallTimeoutMs;
57
110
  let lastError;
58
111
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
59
112
  // Checked before every attempt: a signal aborted during backoff must not fire one more.
60
113
  if (options.signal?.aborted)
61
114
  throw abortedException(options.signal);
62
- const { signal, cleanup } = attemptSignal(options.signal, timeoutMs);
115
+ const budget = deadline - Date.now();
116
+ if (budget <= 0)
117
+ throw lastError ?? overallTimeoutException(overallTimeoutMs);
118
+ // Never longer than what is left of the call, so the per-attempt timeout
119
+ // cannot overrun the budget it sits inside.
120
+ const { signal, cleanup } = attemptSignal(options.signal, Math.min(timeoutMs, budget));
63
121
  try {
64
122
  const response = await fetch(url, { ...init, signal });
65
123
  if (response.ok)
66
- return (await response.json());
124
+ return await read(response);
67
125
  const error = parseErrorResponse(response.status, response.statusText, await response.text(), response.headers);
68
126
  lastError = error;
69
127
  if (!error.isRetryable || attempt === maxAttempts - 1)
70
128
  throw error;
71
- log('attempt %d failed (%s), retrying', attempt + 1, error.code);
72
- await sleep(error.retryAfterMs ??
129
+ const wait = error.retryAfterMs ??
73
130
  parseRetryAfter(response.headers.get('retry-after')) ??
74
- backoffMs(attempt));
131
+ backoffMs(attempt);
132
+ // Sitting out a 60 s `Retry-After` inside a 30 s budget only delivers the
133
+ // same failure after the caller has already given up. Stop now and hand
134
+ // back what the API said, `retryAfterMs` and all.
135
+ if (wait >= deadline - Date.now()) {
136
+ log('not retrying: a %d ms wait outlasts the remaining budget', wait);
137
+ break;
138
+ }
139
+ log('attempt %d failed (%s), retrying', attempt + 1, error.code);
140
+ await sleep(wait, options.signal);
75
141
  }
76
142
  catch (err) {
77
143
  if (err instanceof LocationServiceException) {
@@ -85,20 +151,48 @@ export async function requestJson(url, init, options = {}) {
85
151
  lastError = wrapped;
86
152
  if (!wrapped.isRetryable || attempt === maxAttempts - 1)
87
153
  throw wrapped;
154
+ const wait = backoffMs(attempt);
155
+ if (wait >= deadline - Date.now()) {
156
+ log('not retrying: a %d ms wait outlasts the remaining budget', wait);
157
+ break;
158
+ }
88
159
  log('attempt %d failed (%s), retrying', attempt + 1, wrapped.code);
89
- await sleep(backoffMs(attempt));
160
+ await sleep(wait, options.signal);
90
161
  }
91
162
  finally {
92
163
  cleanup();
93
164
  }
94
165
  }
95
- /* c8 ignore next */
166
+ // Reached when the budget ran out before another attempt could be made, so
167
+ // `lastError` is the API's own answer and is the useful thing to throw.
96
168
  throw (lastError ??
97
169
  new LocationServiceException({
98
170
  code: 'InternalException',
99
171
  message: 'Request failed',
100
172
  }));
101
173
  }
174
+ /**
175
+ * One JSON request, with timeout, cancellation and retry.
176
+ *
177
+ * Every failure leaves as a LocationServiceException — a fetch rejection
178
+ * becomes `NetworkException` with the original as `cause`, an abort becomes
179
+ * `AbortedException`, a timeout becomes `TimeoutException` with
180
+ * `details.source = 'client'` so it is distinguishable from the API's own 504.
181
+ */
182
+ export function requestJson(url, init, options = {}) {
183
+ return request(url, init, options, (response) => response.json());
184
+ }
185
+ /**
186
+ * One request answered as a Blob — the static map path.
187
+ *
188
+ * Exists so the two map fetches are not the only calls in the package without
189
+ * a timeout, a retry or a signal: they used their own bare `fetch`, so a
190
+ * network fault there escaped as a raw `TypeError` while the identical fault on
191
+ * any other call arrived as `NetworkException` (#37).
192
+ */
193
+ export function requestBlob(url, init, options = {}) {
194
+ return request(url, init, options, (response) => response.blob());
195
+ }
102
196
  function abortedException(signal) {
103
197
  return new LocationServiceException({
104
198
  code: 'AbortedException',
@@ -107,6 +201,14 @@ function abortedException(signal) {
107
201
  cause: signal?.reason,
108
202
  });
109
203
  }
204
+ /** The call's own budget elapsed, rather than one attempt's timeout. */
205
+ function overallTimeoutException(overallTimeoutMs) {
206
+ return new LocationServiceException({
207
+ code: 'TimeoutException',
208
+ message: `Request exceeded its overall timeout of ${overallTimeoutMs} ms`,
209
+ details: { source: 'client' },
210
+ });
211
+ }
110
212
  /**
111
213
  * fetch rejects with a raw `TypeError` for a network fault and a `DOMException`
112
214
  * named AbortError for both cancellation and timeout — indistinguishable from
@@ -1,12 +1,29 @@
1
+ /**
2
+ * At least one of `token`, `getToken` or `refreshToken` must supply a token, or
3
+ * `send` refuses locally with `InvalidCredentialsException` rather than putting
4
+ * `Bearer undefined` on the wire (#37).
5
+ *
6
+ * `token` is optional because a client driven purely by a provider — the shape
7
+ * `@chaosity/location-client-react` uses — has nothing to put there at
8
+ * construction time, and was previously forced to invent a placeholder. It is
9
+ * still required on `ServerClientConfig`, which is a RESULT rather than a
10
+ * configuration: `getClientConfig` always resolves one.
11
+ */
1
12
  export interface ClientConfig {
2
13
  apiUrl: string;
3
- token: string;
14
+ token?: string;
4
15
  /** Optional callback to get the current token dynamically. When provided,
5
16
  * called on every request so token updates are reflected without recreating the client. */
6
17
  getToken?: () => string | undefined;
7
18
  /**
8
19
  * Asked for a replacement AFTER the API has rejected the current token with a
9
- * 401, so the request can be retried once instead of failing.
20
+ * 401, so the request can be retried once instead of failing — and, since
21
+ * #37, asked once BEFORE the first send when neither `getToken` nor `token`
22
+ * yields anything, so a client whose token has not arrived yet does not spend
23
+ * a round trip on `Bearer undefined` to learn that. Both calls mean the same
24
+ * thing to an implementor — "give me a usable token" — so a `refreshToken`
25
+ * that mints or awaits one needs no change; only one that assumes every
26
+ * invocation follows a 401 does.
10
27
  *
11
28
  * Separate from `getToken` because that one is synchronous by contract — it
12
29
  * is read while a request is being built and cannot await anything, so it can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",