@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
@@ -125,9 +125,8 @@ export interface VerifyAddressCommandInput {
125
125
  * Japan, which may not be stored at all. Every other Places result is for
126
126
  * display.
127
127
  *
128
- * Keep the PlaceId you sent beside it. The answer's own `PlaceId` can differ,
129
- * and for a unit it does: the service fails that one on every Places route,
130
- * while the one you sent verifies again.
128
+ * Its `PlaceId` is the one sent, for a unit as for a building, so a stored
129
+ * verification can be verified, or looked up, again by its own `PlaceId`.
131
130
  */
132
131
  export type VerifyAddressResponse = Omit<GetPlaceResponse, 'PricingBucket'> & {
133
132
  /**
@@ -12,9 +12,36 @@
12
12
  * caller hears of it; there is no check to make beforehand.
13
13
  */
14
14
  export declare const FEATURE_NOT_ENTITLED = "FeatureNotEntitledException";
15
+ /**
16
+ * Every `code` the API sends (#38): its error contract, published at
17
+ * https://docs.chaosity.cloud/api/errors, plus the Amazon Location exception
18
+ * names it passes through from upstream.
19
+ *
20
+ * A few are worth knowing apart. `RateLimitExceededException` is the
21
+ * application's own throttle; `ThrottlingException` is Amazon Location
22
+ * throttling the service; `IpRateLimitExceededException` is the per-address
23
+ * limit in front of `/auth/token`. `ApplicationNotActiveException` (403) is an
24
+ * application that is new, suspended or off its plan.
25
+ * `TokenExpiredException` is in the contract but never sent: an expired token
26
+ * arrives as `UnauthorizedException`.
27
+ */
28
+ export declare const API_ERROR_CODES: readonly ["AccessDeniedException", "ApplicationNotActiveException", "ClientException", "FeatureNotEntitledException", "ForbiddenException", "InternalException", "InternalServerException", "InvalidCredentialsException", "IpRateLimitExceededException", "NotAcceptableException", "NotFoundException", "OriginNotAllowedException", "RateLimitExceededException", "ResourceNotFoundException", "ServiceUnavailableException", "ThrottlingException", "TimeoutException", "TokenExpiredException", "UnauthorizedException", "UpstreamException", "ValidationException"];
29
+ /**
30
+ * The codes only this package raises, for failures that never reached the
31
+ * API. It raises some of the API's codes too — `TimeoutException` for its own
32
+ * timeout, `InvalidCredentialsException` for a missing token — and those are
33
+ * in the list above.
34
+ */
35
+ export declare const CLIENT_ERROR_CODES: readonly ["AbortedException", "NetworkException", "ServiceException", "UnknownCommandException"];
36
+ export declare const LOCATION_SERVICE_ERROR_CODES: readonly ["AccessDeniedException", "ApplicationNotActiveException", "ClientException", "FeatureNotEntitledException", "ForbiddenException", "InternalException", "InternalServerException", "InvalidCredentialsException", "IpRateLimitExceededException", "NotAcceptableException", "NotFoundException", "OriginNotAllowedException", "RateLimitExceededException", "ResourceNotFoundException", "ServiceUnavailableException", "ThrottlingException", "TimeoutException", "TokenExpiredException", "UnauthorizedException", "UpstreamException", "ValidationException", "AbortedException", "NetworkException", "ServiceException", "UnknownCommandException"];
37
+ /**
38
+ * A `LocationServiceException`'s `code`: one of these, or a code the API adds
39
+ * after this release, which arrives all the same.
40
+ */
41
+ export type LocationServiceErrorCode = (typeof LOCATION_SERVICE_ERROR_CODES)[number];
15
42
  export interface LocationServiceExceptionOptions {
16
43
  message: string;
17
- code: string;
44
+ code: LocationServiceErrorCode | (string & {});
18
45
  /** Absent for failures that never reached the server: network, timeout, abort. */
19
46
  statusCode?: number;
20
47
  requestId?: string;
@@ -32,7 +59,8 @@ export interface LocationServiceExceptionOptions {
32
59
  * rather than sniffing at `TypeError` vs `DOMException` vs a bare `Error`.
33
60
  */
34
61
  export declare class LocationServiceException extends Error {
35
- readonly code: string;
62
+ /** See `LocationServiceErrorCode`: typed, and open to a code added later. */
63
+ readonly code: LocationServiceErrorCode | (string & {});
36
64
  readonly statusCode?: number;
37
65
  readonly requestId?: string;
38
66
  readonly details?: Record<string, unknown>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LocationServiceException = exports.FEATURE_NOT_ENTITLED = void 0;
3
+ exports.LocationServiceException = exports.LOCATION_SERVICE_ERROR_CODES = exports.CLIENT_ERROR_CODES = exports.API_ERROR_CODES = exports.FEATURE_NOT_ENTITLED = void 0;
4
4
  /**
5
5
  * The code of a 403 for an option the application's plan does not include
6
6
  * (#55): a map feature such as `satellite` or `terrain`, or rich place data.
@@ -15,6 +15,58 @@ exports.LocationServiceException = exports.FEATURE_NOT_ENTITLED = void 0;
15
15
  * caller hears of it; there is no check to make beforehand.
16
16
  */
17
17
  exports.FEATURE_NOT_ENTITLED = 'FeatureNotEntitledException';
18
+ /**
19
+ * Every `code` the API sends (#38): its error contract, published at
20
+ * https://docs.chaosity.cloud/api/errors, plus the Amazon Location exception
21
+ * names it passes through from upstream.
22
+ *
23
+ * A few are worth knowing apart. `RateLimitExceededException` is the
24
+ * application's own throttle; `ThrottlingException` is Amazon Location
25
+ * throttling the service; `IpRateLimitExceededException` is the per-address
26
+ * limit in front of `/auth/token`. `ApplicationNotActiveException` (403) is an
27
+ * application that is new, suspended or off its plan.
28
+ * `TokenExpiredException` is in the contract but never sent: an expired token
29
+ * arrives as `UnauthorizedException`.
30
+ */
31
+ exports.API_ERROR_CODES = [
32
+ 'AccessDeniedException',
33
+ 'ApplicationNotActiveException',
34
+ 'ClientException',
35
+ 'FeatureNotEntitledException',
36
+ 'ForbiddenException',
37
+ 'InternalException',
38
+ 'InternalServerException',
39
+ 'InvalidCredentialsException',
40
+ 'IpRateLimitExceededException',
41
+ 'NotAcceptableException',
42
+ 'NotFoundException',
43
+ 'OriginNotAllowedException',
44
+ 'RateLimitExceededException',
45
+ 'ResourceNotFoundException',
46
+ 'ServiceUnavailableException',
47
+ 'ThrottlingException',
48
+ 'TimeoutException',
49
+ 'TokenExpiredException',
50
+ 'UnauthorizedException',
51
+ 'UpstreamException',
52
+ 'ValidationException',
53
+ ];
54
+ /**
55
+ * The codes only this package raises, for failures that never reached the
56
+ * API. It raises some of the API's codes too — `TimeoutException` for its own
57
+ * timeout, `InvalidCredentialsException` for a missing token — and those are
58
+ * in the list above.
59
+ */
60
+ exports.CLIENT_ERROR_CODES = [
61
+ 'AbortedException',
62
+ 'NetworkException',
63
+ 'ServiceException',
64
+ 'UnknownCommandException',
65
+ ];
66
+ exports.LOCATION_SERVICE_ERROR_CODES = [
67
+ ...exports.API_ERROR_CODES,
68
+ ...exports.CLIENT_ERROR_CODES,
69
+ ];
18
70
  /**
19
71
  * The single error type this package throws.
20
72
  *
@@ -4,7 +4,7 @@ export { DEFAULT_MAX_ATTEMPTS, DEFAULT_OVERALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, }
4
4
  export type { RequestOptions } from './transport/http.js';
5
5
  export { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './auth/tokenRefresh.js';
6
6
  export { FEATURE_NOT_ENTITLED, LocationServiceException, } from './errors/LocationServiceException.js';
7
- export type { LocationServiceExceptionOptions } from './errors/LocationServiceException.js';
7
+ export type { LocationServiceErrorCode, LocationServiceExceptionOptions, } from './errors/LocationServiceException.js';
8
8
  export * from '@aws-sdk/client-geo-places';
9
9
  export { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, VerifyAddressCommand, } from './client/commands.js';
10
10
  export type { AutocompleteCommandInput, AutocompleteRequest, GeocodeCommandInput, GeocodeRequest, GetPlaceCommandInput, GetPlaceRequest, NeverForwarded, ReverseGeocodeCommandInput, ReverseGeocodeRequest, SearchNearbyCommandInput, SearchNearbyRequest, SearchTextCommandInput, SearchTextRequest, SuggestCommandInput, SuggestRequest, VerifyAddressCommandInput, VerifyAddressResponse, } from './client/commands.js';
@@ -70,6 +70,8 @@ export declare class LocationServiceConnector {
70
70
  private readonly config;
71
71
  private tokenSource?;
72
72
  private readonly origin?;
73
+ /** A token the API refused, and why, until the hold lapses (#38). */
74
+ private readonly refused;
73
75
  readonly serviceId: string;
74
76
  constructor(config?: ConnectorConfig);
75
77
  /**
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.LocationServiceConnector = void 0;
7
7
  const debug_1 = __importDefault(require("debug"));
8
+ const tokenHold_js_1 = require("../auth/tokenHold.js");
8
9
  const commands_js_1 = require("../client/commands.js");
9
10
  const LocationServiceException_js_1 = require("../errors/LocationServiceException.js");
10
11
  const endpoints_js_1 = require("../transport/endpoints.js");
@@ -106,6 +107,8 @@ function explainMissingOrigin(err, sentOrigin) {
106
107
  */
107
108
  class LocationServiceConnector {
108
109
  constructor(config = {}) {
110
+ /** A token the API refused, and why, until the hold lapses (#38). */
111
+ this.refused = new tokenHold_js_1.TokenHold();
109
112
  this.serviceId = 'Geo Places';
110
113
  this.config = config;
111
114
  // `||`, not `??`, to match the other four env-completed fields: an empty
@@ -219,24 +222,60 @@ class LocationServiceConnector {
219
222
  const token = await source.get();
220
223
  if (!token)
221
224
  throw (0, errors_js_1.noTokenAvailable)(NO_TOKEN_ADVICE);
225
+ // This token was refused a moment ago and nothing has replaced it: answer
226
+ // with that refusal rather than send it, and force the source, again (#38).
227
+ // A different token from the source ends the hold. When the refresh that
228
+ // followed said nothing about when to ask again, it is asked now — but the
229
+ // refused token is still not sent.
230
+ const held = this.refused.check(token);
231
+ if (held && !held.askAgain)
232
+ throw held.error;
233
+ let rejected = held?.error;
234
+ if (!held) {
235
+ try {
236
+ return await this.dispatch(url, token, cmd, options);
237
+ }
238
+ catch (err) {
239
+ if (!(0, errors_js_1.isTokenRejected)(err))
240
+ throw err;
241
+ rejected = err;
242
+ }
243
+ }
244
+ // One retry, and only when the replacement is genuinely a different token.
245
+ // That single comparison covers every source: a fixed `token` string, a
246
+ // caller `getToken` that ignores `forceRefresh`, and a cached token the API
247
+ // has revoked before its `exp` all hand back what we already sent — and
248
+ // re-sending it would be a second doomed request for the same answer.
249
+ let fresh;
222
250
  try {
223
- return await this.dispatch(url, token, cmd, options);
251
+ fresh = await source.get(true);
224
252
  }
225
- catch (err) {
226
- if (!(0, errors_js_1.isTokenRejected)(err))
227
- throw err;
228
- // One retry, and only when the replacement is genuinely a different
229
- // token. That single comparison covers every source: a fixed `token`
230
- // string, a caller `getToken` that ignores `forceRefresh`, and a cached
231
- // token the API has revoked before its `exp` all hand back what we
232
- // already sent — and re-sending it would be a second doomed request for
233
- // the same answer.
234
- const fresh = await source.get(true);
235
- if (!fresh || fresh === token)
236
- throw err;
237
- log('401 on a token the API no longer accepts — retrying once, refreshed');
253
+ catch (refusal) {
254
+ // A suspended application's /auth/token refuses it as its data routes
255
+ // refuse its token. Without this, every send asked for another. A
256
+ // refusal that says nothing — a network fault — leaves the source to be
257
+ // asked again, but not the token sent.
258
+ if ((0, tokenHold_js_1.holdFor)(refusal) > 0)
259
+ this.refused.remember(refusal, token);
260
+ // Only when no hold stands: one already standing keeps its own end, so
261
+ // the refused token is tried again once per hold rather than never.
262
+ else if (!held)
263
+ this.refused.remember(rejected, token, { askAgain: true });
264
+ throw refusal;
265
+ }
266
+ if (!fresh || fresh === token) {
267
+ this.refused.remember(rejected, token);
268
+ throw rejected;
269
+ }
270
+ log('401 on a token the API no longer accepts — retrying once, refreshed');
271
+ try {
238
272
  return await this.dispatch(url, fresh, cmd, options);
239
273
  }
274
+ catch (again) {
275
+ if ((0, errors_js_1.isTokenRejected)(again))
276
+ this.refused.remember(again, fresh);
277
+ throw again;
278
+ }
240
279
  }
241
280
  dispatch(url, token, cmd, options) {
242
281
  // The caller's input goes out as the caller wrote it — nothing in the body
@@ -104,8 +104,9 @@ export declare function serverTokenSource(config?: ServerAuthConfig): ServerToke
104
104
  * // Auto-detect from environment
105
105
  * const config = await getClientConfig()
106
106
  *
107
- * // Or override specific values
108
- * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
107
+ * // Or override specific values — the API URL is the one on the
108
+ * // application's page in the developer portal
109
+ * const config = await getClientConfig({ apiUrl: 'https://your-api-url.example' })
109
110
  *
110
111
  * // The API rejected the token before its exp — revoked, or secret rotated
111
112
  * const fresh = await getClientConfig({ forceRefresh: true })
@@ -78,6 +78,33 @@ function resolveApiUrl(explicit) {
78
78
  process.env.LOCATION_API_URL ||
79
79
  process.env.LOCATION_SERVICE_API_URL);
80
80
  }
81
+ /**
82
+ * A refusal of the client credentials themselves, as opposed to a refusal
83
+ * whose sentence already names its cause.
84
+ *
85
+ * Two answers qualify. `/auth/token`'s own `Invalid credentials` is a secret
86
+ * that matched nothing. The gateway's 401 (`UnauthorizedException`) is the
87
+ * authorizer refusing the Basic pair before `/auth/token` runs, and it gives
88
+ * the same answer for a wrong secret and for an application that is not
89
+ * active, so the advice for it names both. `Application is not active`, and
90
+ * every 403, is left as the API wrote it.
91
+ */
92
+ function isCredentialsRefusal(error) {
93
+ if (!(error instanceof LocationServiceException_js_1.LocationServiceException))
94
+ return false;
95
+ if (error.statusCode !== 401)
96
+ return false;
97
+ return (error.code === 'UnauthorizedException' ||
98
+ error.message === 'Invalid credentials');
99
+ }
100
+ /** The API's words as a sentence, so the advice can follow them. */
101
+ const sentence = (text) => (/[.!?]$/.test(text) ? text : `${text}.`);
102
+ function credentialsAdvice(clientId, error) {
103
+ const check = `Check that LOCATION_CLIENT_ID ("${clientId}") and LOCATION_CLIENT_SECRET match your application in the developer portal`;
104
+ return error.code === 'UnauthorizedException'
105
+ ? `${check}, and that the application is active there.`
106
+ : `${check}.`;
107
+ }
81
108
  function serverTokenSource(config = {}) {
82
109
  log('[serverTokenSource] Starting with config:', {
83
110
  hasApiUrl: !!config.apiUrl,
@@ -119,21 +146,20 @@ function serverTokenSource(config = {}) {
119
146
  result = await provider.getToken(forceRefresh);
120
147
  }
121
148
  catch (error) {
122
- // The provider now rejects rather than resolving with success:false, and
123
- // the rejection is typed — so a store outage (503) can be reported as a
124
- // store outage instead of as bad credentials.
125
- if (error instanceof LocationServiceException_js_1.LocationServiceException) {
126
- if (error.isAuth) {
127
- throw new LocationServiceException_js_1.LocationServiceException({
128
- code: 'InvalidCredentialsException',
129
- message: `Authentication failed for client ID "${clientId}". ` +
130
- `Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
131
- statusCode: error.statusCode,
132
- requestId: error.requestId,
133
- cause: error,
134
- });
135
- }
136
- throw error;
149
+ // The API's code and sentence, passed through (#38). Every 401 and 403
150
+ // used to become "Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET",
151
+ // which sent a suspended application to check a secret that was fine.
152
+ // The advice is added only where the credentials are what was refused.
153
+ if (isCredentialsRefusal(error)) {
154
+ throw new LocationServiceException_js_1.LocationServiceException({
155
+ code: error.code,
156
+ message: `${sentence(error.message)} ${credentialsAdvice(clientId, error)}`,
157
+ statusCode: error.statusCode,
158
+ requestId: error.requestId,
159
+ details: error.details,
160
+ retryAfterMs: error.retryAfterMs,
161
+ cause: error,
162
+ });
137
163
  }
138
164
  throw error;
139
165
  }
@@ -190,8 +216,9 @@ function serverTokenSource(config = {}) {
190
216
  * // Auto-detect from environment
191
217
  * const config = await getClientConfig()
192
218
  *
193
- * // Or override specific values
194
- * const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
219
+ * // Or override specific values — the API URL is the one on the
220
+ * // application's page in the developer portal
221
+ * const config = await getClientConfig({ apiUrl: 'https://your-api-url.example' })
195
222
  *
196
223
  * // The API rejected the token before its exp — revoked, or secret rotated
197
224
  * const fresh = await getClientConfig({ forceRefresh: true })
@@ -2,14 +2,20 @@ import { LocationServiceException } from '../errors/LocationServiceException.js'
2
2
  /**
3
3
  * Turn a non-2xx response into a LocationServiceException.
4
4
  *
5
- * The API does not yet speak one error shape — that is api#29 (T23) — so this
6
- * tolerates the three it currently emits and synthesises a `code` for each.
7
- * RFC-0002 calls this legacy tolerance, and it is what lets the client ship
8
- * before the API contract lands. Delete the fallbacks once T23 is deployed.
9
- *
10
- * { message, code, requestId } service Lambdas — already correct
11
- * { error, error_description } /auth/token, OAuth shape
12
- * { message: "Unauthorized" } API Gateway's own responses
5
+ * The API answers every failure with a `code`, in one of three envelopes:
6
+ *
7
+ * { message, code, requestId } data routes; the gateway
8
+ * { error, error_description, code, requestId } /auth/token (OAuth 2.0)
9
+ * { code, message } the per-address limit
10
+ *
11
+ * The sentence is in `message`, or on /auth/token in `error_description`, and
12
+ * both are read whatever else the body carries (#38). The description used to
13
+ * be read only from a body with no `code`, and /auth/token has sent one since,
14
+ * so every refusal from it arrived as "Request failed: Unauthorized": a
15
+ * suspended application read exactly like a wrong secret.
16
+ *
17
+ * A body with no `code` — a proxy's, or an older deployment's — still gets
18
+ * one: from its OAuth `error`, else from the status.
13
19
  */
14
20
  export declare function parseErrorResponse(status: number, statusText: string, body: string, headers?: Headers): LocationServiceException;
15
21
  /**
@@ -8,14 +8,20 @@ const LocationServiceException_js_1 = require("../errors/LocationServiceExceptio
8
8
  /**
9
9
  * Turn a non-2xx response into a LocationServiceException.
10
10
  *
11
- * The API does not yet speak one error shape — that is api#29 (T23) — so this
12
- * tolerates the three it currently emits and synthesises a `code` for each.
13
- * RFC-0002 calls this legacy tolerance, and it is what lets the client ship
14
- * before the API contract lands. Delete the fallbacks once T23 is deployed.
11
+ * The API answers every failure with a `code`, in one of three envelopes:
15
12
  *
16
- * { message, code, requestId } service Lambdas — already correct
17
- * { error, error_description } /auth/token, OAuth shape
18
- * { message: "Unauthorized" } API Gateway's own responses
13
+ * { message, code, requestId } data routes; the gateway
14
+ * { error, error_description, code, requestId } /auth/token (OAuth 2.0)
15
+ * { code, message } the per-address limit
16
+ *
17
+ * The sentence is in `message`, or on /auth/token in `error_description`, and
18
+ * both are read whatever else the body carries (#38). The description used to
19
+ * be read only from a body with no `code`, and /auth/token has sent one since,
20
+ * so every refusal from it arrived as "Request failed: Unauthorized": a
21
+ * suspended application read exactly like a wrong secret.
22
+ *
23
+ * A body with no `code` — a proxy's, or an older deployment's — still gets
24
+ * one: from its OAuth `error`, else from the status.
19
25
  */
20
26
  function parseErrorResponse(status, statusText, body, headers) {
21
27
  let message = `Request failed: ${statusText || status}`;
@@ -24,17 +30,19 @@ function parseErrorResponse(status, statusText, body, headers) {
24
30
  let details;
25
31
  try {
26
32
  const data = JSON.parse(body);
27
- if (typeof data.message === 'string')
28
- message = data.message;
29
- if (typeof data.code === 'string')
30
- code = data.code;
31
- if (typeof data.requestId === 'string')
32
- requestId = data.requestId;
33
- // OAuth envelope from /auth/token
34
- if (!code && typeof data.error === 'string') {
35
- message = data.error_description ?? data.error;
36
- code = oauthCode(data.error, status);
37
- details = { oauthError: data.error };
33
+ const text = (value) => typeof value === 'string' ? value : undefined;
34
+ message =
35
+ text(data.error_description) ??
36
+ text(data.message) ??
37
+ text(data.error) ??
38
+ message;
39
+ code = text(data.code);
40
+ requestId = text(data.requestId);
41
+ // OAuth `error`, from /auth/token and the gateway's 401 on it
42
+ const oauthError = text(data.error);
43
+ if (oauthError) {
44
+ code ?? (code = oauthCode(oauthError, status));
45
+ details = { oauthError };
38
46
  }
39
47
  }
40
48
  catch {
@@ -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
  };
@@ -1,20 +1,30 @@
1
1
  import debug from 'debug';
2
+ import { TokenHold, holdFor } from '../auth/tokenHold.js';
2
3
  import { resolveEndpoint } from '../transport/endpoints.js';
3
4
  import { isTokenRejected, noTokenAvailable } from '../transport/errors.js';
4
5
  import { requestJson } from '../transport/http.js';
5
6
  import { readAppConfigClaims } from '../utils/tokenClaims.js';
6
7
  import { VerifyAddressCommand } from './commands.js';
7
8
  const log = debug('location-client:api');
9
+ /**
10
+ * What a hold is kept against when there was no token in hand to key it to.
11
+ * `ensureToken` treats an empty token as none, so no real token is this.
12
+ */
13
+ const NO_TOKEN = '';
8
14
  /**
9
15
  * GeoPlacesClient — AWS Location Service compatible client with custom auth.
10
16
  *
11
- * Uses AWS SDK command classes but replaces SigV4 with a Bearer token. All
12
- * request and response types are identical to AWS Location Service.
17
+ * Uses AWS SDK command classes but replaces SigV4 with a Bearer token. The
18
+ * request and response types are the AWS SDK's, except that the Places
19
+ * commands take neither `IntendedUse` nor `Key` (#40), and `VerifyAddressCommand`
20
+ * is this package's own (#54).
13
21
  *
14
22
  * Pass `getToken` in config for live refresh without recreating the client.
15
23
  */
16
24
  export class GeoPlacesClient {
17
25
  constructor(config) {
26
+ /** A token the API refused, and why, until the hold lapses (#38). */
27
+ this.refused = new TokenHold();
18
28
  this.clientConfig = config;
19
29
  this.config = { serviceId: 'Geo Places' };
20
30
  }
@@ -48,12 +58,28 @@ export class GeoPlacesClient {
48
58
  * client configured the ordinary way pays nothing for this.
49
59
  */
50
60
  async ensureToken() {
51
- // `||`, not `??`: an empty string is a token source with nothing to give,
52
- // not a decision to send an empty one. With `??` it survived the coalesce,
53
- // skipped `refreshToken`, and then failed the check two lines below — so
61
+ // Truthiness, not `??`: an empty string is a token source with nothing to
62
+ // give, not a decision to send an empty one. With `??` it survived the
63
+ // coalesce, skipped `refreshToken`, and then failed the check below — so
54
64
  // `getToken: () => undefined` got the refresh ask and `getToken: () => ''`
55
65
  // did not, which is a distinction no caller means to draw.
56
- const token = this.currentToken() || (await this.clientConfig.refreshToken?.());
66
+ const inHand = this.currentToken();
67
+ if (inHand)
68
+ return inHand;
69
+ // `refreshToken` refused a moment ago, and there is still nothing in hand:
70
+ // answer with that rather than ask it again on every send (#38). The hold
71
+ // is kept against "no token", so one arriving from `getToken` ends it.
72
+ const held = this.refused.check(NO_TOKEN)?.error;
73
+ if (held)
74
+ throw held;
75
+ let token;
76
+ try {
77
+ token = await this.clientConfig.refreshToken?.();
78
+ }
79
+ catch (refusal) {
80
+ this.refused.remember(refusal, NO_TOKEN);
81
+ throw refusal;
82
+ }
57
83
  if (!token) {
58
84
  throw noTokenAvailable('the client has no token yet. Pass `token`, or a `getToken`/`refreshToken` that has one.');
59
85
  }
@@ -75,25 +101,61 @@ export class GeoPlacesClient {
75
101
  // something it already knew. Ask the refresh source instead, and refuse if
76
102
  // there is still nothing.
77
103
  const token = await this.ensureToken();
104
+ // This token was refused a moment ago and nothing has replaced it: answer
105
+ // with that refusal rather than send it, and ask `refreshToken`, again
106
+ // (#38). A different token from `getToken` ends the hold. When the refresh
107
+ // that followed said nothing about when to ask again, it is asked now —
108
+ // but the refused token is still not sent.
109
+ const held = this.refused.check(token);
110
+ if (held && !held.askAgain)
111
+ throw held.error;
112
+ let rejected = held?.error;
113
+ if (!held) {
114
+ try {
115
+ return await this.dispatch(url, token, cmd, options);
116
+ }
117
+ catch (err) {
118
+ if (!isTokenRejected(err))
119
+ throw err;
120
+ rejected = err;
121
+ }
122
+ }
123
+ // One shot. `refreshToken` is the only way to actually obtain a new token
124
+ // here — `getToken` is synchronous and returns the one already in hand —
125
+ // but it is re-read as a fallback because a provider that refreshes in the
126
+ // background may have landed a new one while this request was in flight.
127
+ let fresh;
78
128
  try {
79
- return await this.dispatch(url, token, cmd, options);
129
+ fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
130
+ }
131
+ catch (refusal) {
132
+ // A suspended application's token route refuses it as its data routes
133
+ // refuse its token. Without this, every send asked again. A refusal that
134
+ // says nothing — a network fault, or a Server Action's error without its
135
+ // fields — leaves the source to be asked again, but not the token sent.
136
+ if (holdFor(refusal) > 0)
137
+ this.refused.remember(refusal, token);
138
+ // Only when no hold stands: one already standing keeps its own end, so
139
+ // the refused token is tried again once per hold rather than never.
140
+ else if (!held)
141
+ this.refused.remember(rejected, token, { askAgain: true });
142
+ throw refusal;
143
+ }
144
+ // Nothing new to send. Repeating the request would fail identically — a
145
+ // second round trip for the same 401.
146
+ if (!fresh || fresh === token) {
147
+ this.refused.remember(rejected, token);
148
+ throw rejected;
80
149
  }
81
- catch (err) {
82
- if (!isTokenRejected(err))
83
- throw err;
84
- // One shot. `refreshToken` is the only way to actually obtain a new
85
- // token here — `getToken` is synchronous and returns the one already in
86
- // hand — but it is re-read as a fallback because a provider that
87
- // refreshes in the background may have landed a new one while this
88
- // request was in flight.
89
- const fresh = (await this.clientConfig.refreshToken?.()) ?? this.currentToken();
90
- // Nothing new to send. Repeating the request would fail identically — a
91
- // second round trip for the same 401.
92
- if (!fresh || fresh === token)
93
- throw err;
94
- log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
150
+ log('401 — retrying %s once with a refreshed token', cmd.constructor?.name);
151
+ try {
95
152
  return await this.dispatch(url, fresh, cmd, options);
96
153
  }
154
+ catch (again) {
155
+ if (isTokenRejected(again))
156
+ this.refused.remember(again, fresh);
157
+ throw again;
158
+ }
97
159
  }
98
160
  /**
99
161
  * Verify a PlaceId: `send(new VerifyAddressCommand({ PlaceId }))`, typed
@@ -125,9 +125,8 @@ export interface VerifyAddressCommandInput {
125
125
  * Japan, which may not be stored at all. Every other Places result is for
126
126
  * display.
127
127
  *
128
- * Keep the PlaceId you sent beside it. The answer's own `PlaceId` can differ,
129
- * and for a unit it does: the service fails that one on every Places route,
130
- * while the one you sent verifies again.
128
+ * Its `PlaceId` is the one sent, for a unit as for a building, so a stored
129
+ * verification can be verified, or looked up, again by its own `PlaceId`.
131
130
  */
132
131
  export type VerifyAddressResponse = Omit<GetPlaceResponse, 'PricingBucket'> & {
133
132
  /**