@chaosity/location-client 0.10.0 → 0.12.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.
Files changed (36) hide show
  1. package/README.md +176 -30
  2. package/dist/adapters/GeoPlaces.js +53 -17
  3. package/dist/auth/TokenProvider.d.ts +2 -0
  4. package/dist/auth/TokenProvider.js +39 -10
  5. package/dist/auth/tokenHold.d.ts +72 -0
  6. package/dist/auth/tokenHold.js +109 -0
  7. package/dist/cjs/adapters/GeoPlaces.js +53 -17
  8. package/dist/cjs/auth/TokenProvider.d.ts +2 -0
  9. package/dist/cjs/auth/TokenProvider.js +39 -10
  10. package/dist/cjs/auth/tokenHold.d.ts +72 -0
  11. package/dist/cjs/auth/tokenHold.js +115 -0
  12. package/dist/cjs/client/GeoPlacesClient.d.ts +6 -2
  13. package/dist/cjs/client/GeoPlacesClient.js +83 -21
  14. package/dist/cjs/client/commands.d.ts +2 -3
  15. package/dist/cjs/errors/LocationServiceException.d.ts +30 -2
  16. package/dist/cjs/errors/LocationServiceException.js +53 -1
  17. package/dist/cjs/index.d.ts +1 -1
  18. package/dist/cjs/server/LocationServiceConnector.d.ts +2 -0
  19. package/dist/cjs/server/LocationServiceConnector.js +53 -14
  20. package/dist/cjs/server/getClientConfig.d.ts +3 -2
  21. package/dist/cjs/server/getClientConfig.js +44 -17
  22. package/dist/cjs/transport/errors.d.ts +14 -8
  23. package/dist/cjs/transport/errors.js +26 -18
  24. package/dist/client/GeoPlacesClient.d.ts +6 -2
  25. package/dist/client/GeoPlacesClient.js +83 -21
  26. package/dist/client/commands.d.ts +2 -3
  27. package/dist/errors/LocationServiceException.d.ts +30 -2
  28. package/dist/errors/LocationServiceException.js +52 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/server/LocationServiceConnector.d.ts +2 -0
  31. package/dist/server/LocationServiceConnector.js +53 -14
  32. package/dist/server/getClientConfig.d.ts +3 -2
  33. package/dist/server/getClientConfig.js +44 -17
  34. package/dist/transport/errors.d.ts +14 -8
  35. package/dist/transport/errors.js +27 -19
  36. package/package.json +3 -3
@@ -0,0 +1,109 @@
1
+ import { LocationServiceException } from '../errors/LocationServiceException.js';
2
+ /**
3
+ * How long a refusal is remembered: a token the API refused, or a token
4
+ * request `/auth/token` refused (#38).
5
+ *
6
+ * A refusal — a 401 or a 403 — is not something asking again can change. A
7
+ * suspended application is refused on every data route and on `/auth/token`
8
+ * alike, and nothing used to remember that, so every request a busy server
9
+ * served paid for a doomed data request and a doomed token request, all
10
+ * against the application's own token-route throttle.
11
+ *
12
+ * Thirty seconds bounds how long an application that has just been made
13
+ * active again waits for this side to notice: long enough to turn a request
14
+ * rate into a trickle, short enough to be a pause rather than an outage. It is
15
+ * also about how long a newly created application is refused while it goes
16
+ * live, which is the one refusal that clears itself.
17
+ */
18
+ export const TOKEN_REFUSAL_HOLD_MS = 30000;
19
+ /**
20
+ * A 401 or a 403: the server refused, and a retry gets the same answer.
21
+ *
22
+ * Wider than `isTokenRejected`, which is the 401 a new token can fix. This is
23
+ * about the token SOURCE: `/auth/token` refusing the credentials (401), or
24
+ * the application's key not live on its plan (403), and neither changes by
25
+ * asking again.
26
+ */
27
+ export function isTokenRefusal(err) {
28
+ return (err instanceof LocationServiceException &&
29
+ (err.statusCode === 401 || err.statusCode === 403));
30
+ }
31
+ /**
32
+ * How long `err` says asking again cannot help, in milliseconds; 0 when it
33
+ * says nothing, and the next call may ask at once.
34
+ *
35
+ * Only the server's own word counts: a refusal, or a `Retry-After`. A network
36
+ * fault, a timeout or a 500 carries neither, so it is not remembered, and the
37
+ * next call tries again as it always has — the transport has already retried
38
+ * it with backoff inside the call that failed.
39
+ */
40
+ export function holdFor(err) {
41
+ if (!(err instanceof LocationServiceException))
42
+ return 0;
43
+ return Math.max(isTokenRefusal(err) ? TOKEN_REFUSAL_HOLD_MS : 0, err.retryAfterMs ?? 0);
44
+ }
45
+ /**
46
+ * One remembered failure, re-thrown instead of asking again until it lapses.
47
+ *
48
+ * Shared by every place that used to ask again on every call: the server
49
+ * `TokenProvider` (a refused or throttled token request), and the two send
50
+ * paths (a token the API refused, and the refresh that could not replace it,
51
+ * or — in `GeoPlacesClient` — could not supply a first one). A send path
52
+ * remembers the failure against the token it concerns, because a DIFFERENT
53
+ * token is a new situation — a background refresh that landed, or a caller's
54
+ * own source that moved on — and ends the hold at once.
55
+ *
56
+ * `askAgain` is the one case where the source may still be asked: the API
57
+ * refused the token, and the refresh that followed failed with nothing to say
58
+ * about when to try again — a network fault, or a rejection that lost its
59
+ * fields crossing a Server Action boundary, as `@chaosity/location-client-react`
60
+ * delivers one. The token is still refused, so it is not sent again; the
61
+ * source is asked on the next send, as it always was.
62
+ */
63
+ export class TokenHold {
64
+ /** Remember `err` for as long as it says; a failure that says nothing is not remembered. */
65
+ remember(err, token, { askAgain = false } = {}) {
66
+ const ms = holdFor(err);
67
+ if (ms > 0) {
68
+ this.held = {
69
+ error: err,
70
+ until: Date.now() + ms,
71
+ token,
72
+ askAgain,
73
+ };
74
+ }
75
+ }
76
+ /**
77
+ * The remembered failure while it stands, as a new exception to throw — for
78
+ * `token`, if one was remembered with it — and whether the source may still
79
+ * be asked. Anything else ends the hold.
80
+ */
81
+ check(token) {
82
+ const held = this.held;
83
+ if (!held)
84
+ return undefined;
85
+ const remaining = held.until - Date.now();
86
+ if (remaining <= 0 || (held.token !== undefined && held.token !== token)) {
87
+ this.held = undefined;
88
+ return undefined;
89
+ }
90
+ const { error, askAgain } = held;
91
+ return {
92
+ askAgain,
93
+ error: new LocationServiceException({
94
+ code: error.code,
95
+ message: error.message,
96
+ statusCode: error.statusCode,
97
+ requestId: error.requestId,
98
+ details: error.details,
99
+ // What is left of a Retry-After, so a caller that schedules on it waits
100
+ // the right amount. A refusal carries none, and gains none here.
101
+ retryAfterMs: error.retryAfterMs === undefined ? undefined : remaining,
102
+ cause: error,
103
+ }),
104
+ };
105
+ }
106
+ forget() {
107
+ this.held = undefined;
108
+ }
109
+ }
@@ -50,6 +50,26 @@ function toCarmenFeatures(features) {
50
50
  };
51
51
  });
52
52
  }
53
+ /**
54
+ * The geocoder's `bbox` — `[minX, minY, maxX, maxY]`, the order Amazon's
55
+ * `BoundingBox` takes too — or undefined when it has none, or not four
56
+ * numbers.
57
+ */
58
+ function boundingBox(bbox) {
59
+ return bbox?.length === 4 && bbox.every((v) => typeof v === 'number')
60
+ ? bbox
61
+ : undefined;
62
+ }
63
+ /**
64
+ * Whether `[x, y]` lies in `bbox`, edges included. A box whose `minX` is
65
+ * greater than its `maxX` crosses the antimeridian.
66
+ */
67
+ function insideBox([x, y], bbox) {
68
+ const [minX, minY, maxX, maxY] = bbox;
69
+ if (y < minY || y > maxY)
70
+ return false;
71
+ return minX <= maxX ? x >= minX && x <= maxX : x >= minX || x <= maxX;
72
+ }
53
73
  class GeoPlaces {
54
74
  constructor(client, map, options = {}) {
55
75
  this.client = client;
@@ -104,9 +124,16 @@ class GeoPlaces {
104
124
  const converted = (0, amazon_location_utilities_datatypes_1.geocodeResponseToFeatureCollection)(response, {
105
125
  flattenProperties: true,
106
126
  });
127
+ // Geocode takes no box, so the geocoder's `bbox` is applied here, to what
128
+ // comes back (#59). It used to be ignored, and a result picked with Enter
129
+ // could land outside the box the integrator set.
130
+ const bbox = boundingBox(config.bbox);
131
+ const features = bbox
132
+ ? converted.features.filter((f) => insideBox(f.geometry.coordinates, bbox))
133
+ : converted.features;
107
134
  const result = {
108
135
  type: 'FeatureCollection',
109
- features: toCarmenFeatures(converted.features),
136
+ features: toCarmenFeatures(features),
110
137
  };
111
138
  log('forwardGeocode returned %d results', result.features.length);
112
139
  return result;
@@ -134,38 +161,47 @@ class GeoPlaces {
134
161
  }
135
162
  async getSuggestions(config) {
136
163
  log('getSuggestions query=%s', config.query);
164
+ // Suggest takes exactly ONE of BiasPosition, Filter.BoundingBox and
165
+ // Filter.Circle, and refuses a request with two: 400 "Exactly one of the
166
+ // following fields must be set". This sent a bias always, and the box
167
+ // beside it whenever the geocoder carried one, so a `bbox` made every
168
+ // suggestion fail (#59). The box, when there is one, is the bias.
169
+ const bbox = boundingBox(config.bbox);
137
170
  const center = this.map.getCenter();
138
- const biasPosition = config.proximity && config.proximity.length >= 2
139
- ? [config.proximity[0], config.proximity[1]]
140
- : [center.lng, center.lat];
171
+ const biasPosition = bbox
172
+ ? undefined
173
+ : config.proximity && config.proximity.length >= 2
174
+ ? [config.proximity[0], config.proximity[1]]
175
+ : [center.lng, center.lat];
176
+ const countries = config.countries
177
+ ? Array.isArray(config.countries)
178
+ ? config.countries
179
+ : config.countries.split(',')
180
+ : undefined;
141
181
  const commandInput = {
142
182
  QueryText: config.query,
143
- BiasPosition: biasPosition,
183
+ ...(biasPosition ? { BiasPosition: biasPosition } : {}),
144
184
  MaxResults: config.limit || 5,
145
185
  Language: this.normalizeLanguage(config.language),
146
186
  // No AdditionalFeatures (#3 / T19).
147
187
  //
148
188
  // This used to send `[Core]`, which put every keystroke in the Core
149
- // bucket at $0.50/1k. The only thing Core adds to a Suggest response is
150
- // `Highlights`, and this adapter reads `Title` and `Place.PlaceId` —
151
- // nothing else. Verified against Amazon Location on 2026-08-25:
189
+ // bucket at $0.50/1k. What Core adds to a Suggest response is
190
+ // `Highlights` and the place's `Position` (without it,
191
+ // `suggestResponseToFeatureCollection` finds no feature: measured
192
+ // 2026-09-29), and this adapter reads neither — only `Title` and
193
+ // `Place.PlaceId`. Verified against Amazon Location on 2026-08-25:
152
194
  //
153
195
  // with [Core] -> bucket Core keys: Title, ..., Place, Highlights
154
196
  // without -> bucket Label keys: Title, ..., Place
155
197
  //
156
198
  // Same two fields, $0.20/1k instead of $0.50. Suggest fires per
157
199
  // keystroke, so it is the highest-volume call the library makes.
158
- ...(config.countries || config.bbox
200
+ ...(bbox || countries
159
201
  ? {
160
202
  Filter: {
161
- ...(config.countries
162
- ? {
163
- IncludeCountries: Array.isArray(config.countries)
164
- ? config.countries
165
- : config.countries.split(','),
166
- }
167
- : {}),
168
- ...(config.bbox ? { BoundingBox: config.bbox } : {}),
203
+ ...(bbox ? { BoundingBox: bbox } : {}),
204
+ ...(countries ? { IncludeCountries: countries } : {}),
169
205
  },
170
206
  }
171
207
  : {}),
@@ -42,6 +42,8 @@ export declare class TokenProvider {
42
42
  private cachedToken?;
43
43
  private cachedExpiresAt?;
44
44
  private tokenPromise?;
45
+ /** A refusal or a Retry-After from `/auth/token`, until it lapses (#38). */
46
+ private readonly hold;
45
47
  constructor(config: TokenProviderConfig);
46
48
  getToken(forceRefresh?: boolean): Promise<TokenResponse>;
47
49
  /**
@@ -7,6 +7,7 @@ exports.TokenProvider = void 0;
7
7
  const debug_1 = __importDefault(require("debug"));
8
8
  const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
9
9
  const http_js_1 = require("../transport/http.js");
10
+ const tokenHold_js_1 = require("./tokenHold.js");
10
11
  const tokenRefresh_js_1 = require("./tokenRefresh.js");
11
12
  const log = (0, debug_1.default)('location-client:auth');
12
13
  /**
@@ -39,6 +40,8 @@ const log = (0, debug_1.default)('location-client:auth');
39
40
  */
40
41
  class TokenProvider {
41
42
  constructor(config) {
43
+ /** A refusal or a Retry-After from `/auth/token`, until it lapses (#38). */
44
+ this.hold = new tokenHold_js_1.TokenHold();
42
45
  // Runtime check: prevent usage in browser
43
46
  if (typeof window !== 'undefined') {
44
47
  throw new Error('TokenProvider cannot be used in browser environments. ' +
@@ -60,6 +63,15 @@ class TokenProvider {
60
63
  expiresAt: this.cachedExpiresAt,
61
64
  };
62
65
  }
66
+ // The endpoint refused these credentials, or asked us to wait, a moment
67
+ // ago: answer with that rather than ask again (#38). Forced or not — a
68
+ // forced refresh asks for a different token, and these credentials will
69
+ // not get one until the hold lapses.
70
+ const held = this.hold.check()?.error;
71
+ if (held) {
72
+ log('Token request held: %s', held.message);
73
+ throw held;
74
+ }
63
75
  // If token fetch is already in progress, wait for it
64
76
  if (this.tokenPromise) {
65
77
  log('Token fetch in progress, waiting for existing request...');
@@ -99,16 +111,32 @@ class TokenProvider {
99
111
  async fetchToken() {
100
112
  const { clientId, clientSecret, apiUrl } = this.config;
101
113
  const credentials = btoa(`${clientId}:${clientSecret}`);
102
- const data = await (0, http_js_1.requestJson)(`${apiUrl}/auth/token`, {
103
- method: 'POST',
104
- headers: {
105
- Authorization: `Basic ${credentials}`,
106
- 'Content-Type': 'application/x-www-form-urlencoded',
107
- },
108
- body: new URLSearchParams({
109
- grant_type: 'client_credentials',
110
- }).toString(),
111
- }, { retry: { maxAttempts: 3 } });
114
+ let data;
115
+ try {
116
+ data = await (0, http_js_1.requestJson)(`${apiUrl}/auth/token`, {
117
+ method: 'POST',
118
+ headers: {
119
+ Authorization: `Basic ${credentials}`,
120
+ 'Content-Type': 'application/x-www-form-urlencoded',
121
+ },
122
+ body: new URLSearchParams({
123
+ grant_type: 'client_credentials',
124
+ }).toString(),
125
+ }, { retry: { maxAttempts: 3 } });
126
+ }
127
+ catch (error) {
128
+ // Remembered, so the next call is answered without a request (#38), and
129
+ // a Retry-After is honoured across calls rather than only within this
130
+ // one (#63).
131
+ this.hold.remember(error);
132
+ // A refusal is about the credentials, so every token they minted is
133
+ // refused too — a suspended application's on its next use, a rotated
134
+ // secret's at once. Never hand the cached one out again. A Retry-After
135
+ // says nothing about it, and keeps it.
136
+ if ((0, tokenHold_js_1.isTokenRefusal)(error))
137
+ this.clearCache();
138
+ throw error;
139
+ }
112
140
  if (!data.access_token) {
113
141
  throw new LocationServiceException_js_1.LocationServiceException({
114
142
  code: 'InvalidCredentialsException',
@@ -116,6 +144,7 @@ class TokenProvider {
116
144
  details: { source: 'client' },
117
145
  });
118
146
  }
147
+ this.hold.forget();
119
148
  this.cachedToken = data.access_token;
120
149
  // The token's own `exp` claim first — it is the only value that cannot
121
150
  // disagree with what the API will actually accept. `expires_at` and
@@ -0,0 +1,72 @@
1
+ import { LocationServiceException } from '../errors/LocationServiceException.js';
2
+ /**
3
+ * How long a refusal is remembered: a token the API refused, or a token
4
+ * request `/auth/token` refused (#38).
5
+ *
6
+ * A refusal — a 401 or a 403 — is not something asking again can change. A
7
+ * suspended application is refused on every data route and on `/auth/token`
8
+ * alike, and nothing used to remember that, so every request a busy server
9
+ * served paid for a doomed data request and a doomed token request, all
10
+ * against the application's own token-route throttle.
11
+ *
12
+ * Thirty seconds bounds how long an application that has just been made
13
+ * active again waits for this side to notice: long enough to turn a request
14
+ * rate into a trickle, short enough to be a pause rather than an outage. It is
15
+ * also about how long a newly created application is refused while it goes
16
+ * live, which is the one refusal that clears itself.
17
+ */
18
+ export declare const TOKEN_REFUSAL_HOLD_MS = 30000;
19
+ /**
20
+ * A 401 or a 403: the server refused, and a retry gets the same answer.
21
+ *
22
+ * Wider than `isTokenRejected`, which is the 401 a new token can fix. This is
23
+ * about the token SOURCE: `/auth/token` refusing the credentials (401), or
24
+ * the application's key not live on its plan (403), and neither changes by
25
+ * asking again.
26
+ */
27
+ export declare function isTokenRefusal(err: unknown): boolean;
28
+ /**
29
+ * How long `err` says asking again cannot help, in milliseconds; 0 when it
30
+ * says nothing, and the next call may ask at once.
31
+ *
32
+ * Only the server's own word counts: a refusal, or a `Retry-After`. A network
33
+ * fault, a timeout or a 500 carries neither, so it is not remembered, and the
34
+ * next call tries again as it always has — the transport has already retried
35
+ * it with backoff inside the call that failed.
36
+ */
37
+ export declare function holdFor(err: unknown): number;
38
+ /**
39
+ * One remembered failure, re-thrown instead of asking again until it lapses.
40
+ *
41
+ * Shared by every place that used to ask again on every call: the server
42
+ * `TokenProvider` (a refused or throttled token request), and the two send
43
+ * paths (a token the API refused, and the refresh that could not replace it,
44
+ * or — in `GeoPlacesClient` — could not supply a first one). A send path
45
+ * remembers the failure against the token it concerns, because a DIFFERENT
46
+ * token is a new situation — a background refresh that landed, or a caller's
47
+ * own source that moved on — and ends the hold at once.
48
+ *
49
+ * `askAgain` is the one case where the source may still be asked: the API
50
+ * refused the token, and the refresh that followed failed with nothing to say
51
+ * about when to try again — a network fault, or a rejection that lost its
52
+ * fields crossing a Server Action boundary, as `@chaosity/location-client-react`
53
+ * delivers one. The token is still refused, so it is not sent again; the
54
+ * source is asked on the next send, as it always was.
55
+ */
56
+ export declare class TokenHold {
57
+ private held?;
58
+ /** Remember `err` for as long as it says; a failure that says nothing is not remembered. */
59
+ remember(err: unknown, token?: string, { askAgain }?: {
60
+ askAgain?: boolean | undefined;
61
+ }): void;
62
+ /**
63
+ * The remembered failure while it stands, as a new exception to throw — for
64
+ * `token`, if one was remembered with it — and whether the source may still
65
+ * be asked. Anything else ends the hold.
66
+ */
67
+ check(token?: string): {
68
+ error: LocationServiceException;
69
+ askAgain: boolean;
70
+ } | undefined;
71
+ forget(): void;
72
+ }
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TokenHold = exports.TOKEN_REFUSAL_HOLD_MS = void 0;
4
+ exports.isTokenRefusal = isTokenRefusal;
5
+ exports.holdFor = holdFor;
6
+ const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
7
+ /**
8
+ * How long a refusal is remembered: a token the API refused, or a token
9
+ * request `/auth/token` refused (#38).
10
+ *
11
+ * A refusal — a 401 or a 403 — is not something asking again can change. A
12
+ * suspended application is refused on every data route and on `/auth/token`
13
+ * alike, and nothing used to remember that, so every request a busy server
14
+ * served paid for a doomed data request and a doomed token request, all
15
+ * against the application's own token-route throttle.
16
+ *
17
+ * Thirty seconds bounds how long an application that has just been made
18
+ * active again waits for this side to notice: long enough to turn a request
19
+ * rate into a trickle, short enough to be a pause rather than an outage. It is
20
+ * also about how long a newly created application is refused while it goes
21
+ * live, which is the one refusal that clears itself.
22
+ */
23
+ exports.TOKEN_REFUSAL_HOLD_MS = 30000;
24
+ /**
25
+ * A 401 or a 403: the server refused, and a retry gets the same answer.
26
+ *
27
+ * Wider than `isTokenRejected`, which is the 401 a new token can fix. This is
28
+ * about the token SOURCE: `/auth/token` refusing the credentials (401), or
29
+ * the application's key not live on its plan (403), and neither changes by
30
+ * asking again.
31
+ */
32
+ function isTokenRefusal(err) {
33
+ return (err instanceof LocationServiceException_js_1.LocationServiceException &&
34
+ (err.statusCode === 401 || err.statusCode === 403));
35
+ }
36
+ /**
37
+ * How long `err` says asking again cannot help, in milliseconds; 0 when it
38
+ * says nothing, and the next call may ask at once.
39
+ *
40
+ * Only the server's own word counts: a refusal, or a `Retry-After`. A network
41
+ * fault, a timeout or a 500 carries neither, so it is not remembered, and the
42
+ * next call tries again as it always has — the transport has already retried
43
+ * it with backoff inside the call that failed.
44
+ */
45
+ function holdFor(err) {
46
+ if (!(err instanceof LocationServiceException_js_1.LocationServiceException))
47
+ return 0;
48
+ return Math.max(isTokenRefusal(err) ? exports.TOKEN_REFUSAL_HOLD_MS : 0, err.retryAfterMs ?? 0);
49
+ }
50
+ /**
51
+ * One remembered failure, re-thrown instead of asking again until it lapses.
52
+ *
53
+ * Shared by every place that used to ask again on every call: the server
54
+ * `TokenProvider` (a refused or throttled token request), and the two send
55
+ * paths (a token the API refused, and the refresh that could not replace it,
56
+ * or — in `GeoPlacesClient` — could not supply a first one). A send path
57
+ * remembers the failure against the token it concerns, because a DIFFERENT
58
+ * token is a new situation — a background refresh that landed, or a caller's
59
+ * own source that moved on — and ends the hold at once.
60
+ *
61
+ * `askAgain` is the one case where the source may still be asked: the API
62
+ * refused the token, and the refresh that followed failed with nothing to say
63
+ * about when to try again — a network fault, or a rejection that lost its
64
+ * fields crossing a Server Action boundary, as `@chaosity/location-client-react`
65
+ * delivers one. The token is still refused, so it is not sent again; the
66
+ * source is asked on the next send, as it always was.
67
+ */
68
+ class TokenHold {
69
+ /** Remember `err` for as long as it says; a failure that says nothing is not remembered. */
70
+ remember(err, token, { askAgain = false } = {}) {
71
+ const ms = holdFor(err);
72
+ if (ms > 0) {
73
+ this.held = {
74
+ error: err,
75
+ until: Date.now() + ms,
76
+ token,
77
+ askAgain,
78
+ };
79
+ }
80
+ }
81
+ /**
82
+ * The remembered failure while it stands, as a new exception to throw — for
83
+ * `token`, if one was remembered with it — and whether the source may still
84
+ * be asked. Anything else ends the hold.
85
+ */
86
+ check(token) {
87
+ const held = this.held;
88
+ if (!held)
89
+ return undefined;
90
+ const remaining = held.until - Date.now();
91
+ if (remaining <= 0 || (held.token !== undefined && held.token !== token)) {
92
+ this.held = undefined;
93
+ return undefined;
94
+ }
95
+ const { error, askAgain } = held;
96
+ return {
97
+ askAgain,
98
+ error: new LocationServiceException_js_1.LocationServiceException({
99
+ code: error.code,
100
+ message: error.message,
101
+ statusCode: error.statusCode,
102
+ requestId: error.requestId,
103
+ details: error.details,
104
+ // What is left of a Retry-After, so a caller that schedules on it waits
105
+ // the right amount. A refusal carries none, and gains none here.
106
+ retryAfterMs: error.retryAfterMs === undefined ? undefined : remaining,
107
+ cause: error,
108
+ }),
109
+ };
110
+ }
111
+ forget() {
112
+ this.held = undefined;
113
+ }
114
+ }
115
+ exports.TokenHold = TokenHold;
@@ -6,13 +6,17 @@ export type SendOptions = RequestOptions;
6
6
  /**
7
7
  * GeoPlacesClient — AWS Location Service compatible client with custom auth.
8
8
  *
9
- * Uses AWS SDK command classes but replaces SigV4 with a Bearer token. All
10
- * request and response types are identical to AWS Location Service.
9
+ * Uses AWS SDK command classes but replaces SigV4 with a Bearer token. The
10
+ * request and response types are the AWS SDK's, except that the Places
11
+ * commands take neither `IntendedUse` nor `Key` (#40), and `VerifyAddressCommand`
12
+ * is this package's own (#54).
11
13
  *
12
14
  * Pass `getToken` in config for live refresh without recreating the client.
13
15
  */
14
16
  export declare class GeoPlacesClient {
15
17
  private clientConfig;
18
+ /** A token the API refused, and why, until the hold lapses (#38). */
19
+ private readonly refused;
16
20
  readonly config: {
17
21
  serviceId: string;
18
22
  };
@@ -5,22 +5,32 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.GeoPlacesClient = void 0;
7
7
  const debug_1 = __importDefault(require("debug"));
8
+ const tokenHold_js_1 = require("../auth/tokenHold.js");
8
9
  const endpoints_js_1 = require("../transport/endpoints.js");
9
10
  const errors_js_1 = require("../transport/errors.js");
10
11
  const http_js_1 = require("../transport/http.js");
11
12
  const tokenClaims_js_1 = require("../utils/tokenClaims.js");
12
13
  const commands_js_1 = require("./commands.js");
13
14
  const log = (0, debug_1.default)('location-client:api');
15
+ /**
16
+ * What a hold is kept against when there was no token in hand to key it to.
17
+ * `ensureToken` treats an empty token as none, so no real token is this.
18
+ */
19
+ const NO_TOKEN = '';
14
20
  /**
15
21
  * GeoPlacesClient — AWS Location Service compatible client with custom auth.
16
22
  *
17
- * Uses AWS SDK command classes but replaces SigV4 with a Bearer token. All
18
- * request and response types are identical to AWS Location Service.
23
+ * Uses AWS SDK command classes but replaces SigV4 with a Bearer token. The
24
+ * request and response types are the AWS SDK's, except that the Places
25
+ * commands take neither `IntendedUse` nor `Key` (#40), and `VerifyAddressCommand`
26
+ * is this package's own (#54).
19
27
  *
20
28
  * Pass `getToken` in config for live refresh without recreating the client.
21
29
  */
22
30
  class GeoPlacesClient {
23
31
  constructor(config) {
32
+ /** A token the API refused, and why, until the hold lapses (#38). */
33
+ this.refused = new tokenHold_js_1.TokenHold();
24
34
  this.clientConfig = config;
25
35
  this.config = { serviceId: 'Geo Places' };
26
36
  }
@@ -54,12 +64,28 @@ class GeoPlacesClient {
54
64
  * client configured the ordinary way pays nothing for this.
55
65
  */
56
66
  async ensureToken() {
57
- // `||`, not `??`: an empty string is a token source with nothing to give,
58
- // not a decision to send an empty one. With `??` it survived the coalesce,
59
- // skipped `refreshToken`, and then failed the check two lines below — so
67
+ // Truthiness, not `??`: an empty string is a token source with nothing to
68
+ // give, not a decision to send an empty one. With `??` it survived the
69
+ // coalesce, skipped `refreshToken`, and then failed the check below — so
60
70
  // `getToken: () => undefined` got the refresh ask and `getToken: () => ''`
61
71
  // did not, which is a distinction no caller means to draw.
62
- const token = this.currentToken() || (await this.clientConfig.refreshToken?.());
72
+ const inHand = this.currentToken();
73
+ if (inHand)
74
+ return inHand;
75
+ // `refreshToken` refused a moment ago, and there is still nothing in hand:
76
+ // answer with that rather than ask it again on every send (#38). The hold
77
+ // is kept against "no token", so one arriving from `getToken` ends it.
78
+ const held = this.refused.check(NO_TOKEN)?.error;
79
+ if (held)
80
+ throw held;
81
+ let token;
82
+ try {
83
+ token = await this.clientConfig.refreshToken?.();
84
+ }
85
+ catch (refusal) {
86
+ this.refused.remember(refusal, NO_TOKEN);
87
+ throw refusal;
88
+ }
63
89
  if (!token) {
64
90
  throw (0, errors_js_1.noTokenAvailable)('the client has no token yet. Pass `token`, or a `getToken`/`refreshToken` that has one.');
65
91
  }
@@ -81,25 +107,61 @@ class GeoPlacesClient {
81
107
  // something it already knew. Ask the refresh source instead, and refuse if
82
108
  // there is still nothing.
83
109
  const token = await this.ensureToken();
110
+ // This token was refused a moment ago and nothing has replaced it: answer
111
+ // with that refusal rather than send it, and ask `refreshToken`, again
112
+ // (#38). A different token from `getToken` ends the hold. When the refresh
113
+ // that followed said nothing about when to ask again, it is asked now —
114
+ // but the refused token is still not sent.
115
+ const held = this.refused.check(token);
116
+ if (held && !held.askAgain)
117
+ throw held.error;
118
+ let rejected = held?.error;
119
+ if (!held) {
120
+ try {
121
+ return await this.dispatch(url, token, cmd, options);
122
+ }
123
+ catch (err) {
124
+ if (!(0, errors_js_1.isTokenRejected)(err))
125
+ throw err;
126
+ rejected = err;
127
+ }
128
+ }
129
+ // One shot. `refreshToken` is the only way to actually obtain a new token
130
+ // here — `getToken` is synchronous and returns the one already in hand —
131
+ // but it is re-read as a fallback because a provider that refreshes in the
132
+ // background may have landed a new one while this request was in flight.
133
+ let fresh;
84
134
  try {
85
- return await this.dispatch(url, token, cmd, options);
135
+ fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
136
+ }
137
+ catch (refusal) {
138
+ // A suspended application's token route refuses it as its data routes
139
+ // refuse its token. Without this, every send asked again. A refusal that
140
+ // says nothing — a network fault, or a Server Action's error without its
141
+ // fields — leaves the source to be asked again, but not the token sent.
142
+ if ((0, tokenHold_js_1.holdFor)(refusal) > 0)
143
+ this.refused.remember(refusal, token);
144
+ // Only when no hold stands: one already standing keeps its own end, so
145
+ // the refused token is tried again once per hold rather than never.
146
+ else if (!held)
147
+ this.refused.remember(rejected, token, { askAgain: true });
148
+ throw refusal;
149
+ }
150
+ // Nothing new to send. Repeating the request would fail identically — a
151
+ // second round trip for the same 401.
152
+ if (!fresh || fresh === token) {
153
+ this.refused.remember(rejected, token);
154
+ throw rejected;
86
155
  }
87
- catch (err) {
88
- if (!(0, errors_js_1.isTokenRejected)(err))
89
- throw err;
90
- // One shot. `refreshToken` is the only way to actually obtain a new
91
- // token here — `getToken` is synchronous and returns the one already in
92
- // hand — but it is re-read as a fallback because a provider that
93
- // refreshes in the background may have landed a new one while this
94
- // request was in flight.
95
- const fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
96
- // Nothing new to send. Repeating the request would fail identically — a
97
- // second round trip for the same 401.
98
- if (!fresh || fresh === token)
99
- throw err;
100
- log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
156
+ log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
157
+ try {
101
158
  return await this.dispatch(url, fresh, cmd, options);
102
159
  }
160
+ catch (again) {
161
+ if ((0, errors_js_1.isTokenRejected)(again))
162
+ this.refused.remember(again, fresh);
163
+ throw again;
164
+ }
103
165
  }
104
166
  /**
105
167
  * Verify a PlaceId: `send(new VerifyAddressCommand({ PlaceId }))`, typed