@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.
- package/README.md +176 -30
- package/dist/adapters/GeoPlaces.js +53 -17
- package/dist/auth/TokenProvider.d.ts +2 -0
- package/dist/auth/TokenProvider.js +39 -10
- package/dist/auth/tokenHold.d.ts +72 -0
- package/dist/auth/tokenHold.js +109 -0
- package/dist/cjs/adapters/GeoPlaces.js +53 -17
- package/dist/cjs/auth/TokenProvider.d.ts +2 -0
- package/dist/cjs/auth/TokenProvider.js +39 -10
- package/dist/cjs/auth/tokenHold.d.ts +72 -0
- package/dist/cjs/auth/tokenHold.js +115 -0
- package/dist/cjs/client/GeoPlacesClient.d.ts +6 -2
- package/dist/cjs/client/GeoPlacesClient.js +83 -21
- package/dist/cjs/client/commands.d.ts +2 -3
- package/dist/cjs/errors/LocationServiceException.d.ts +30 -2
- package/dist/cjs/errors/LocationServiceException.js +53 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/server/LocationServiceConnector.d.ts +2 -0
- package/dist/cjs/server/LocationServiceConnector.js +53 -14
- package/dist/cjs/server/getClientConfig.d.ts +3 -2
- package/dist/cjs/server/getClientConfig.js +44 -17
- package/dist/cjs/transport/errors.d.ts +14 -8
- package/dist/cjs/transport/errors.js +26 -18
- package/dist/client/GeoPlacesClient.d.ts +6 -2
- package/dist/client/GeoPlacesClient.js +83 -21
- package/dist/client/commands.d.ts +2 -3
- package/dist/errors/LocationServiceException.d.ts +30 -2
- package/dist/errors/LocationServiceException.js +52 -0
- package/dist/index.d.ts +1 -1
- package/dist/server/LocationServiceConnector.d.ts +2 -0
- package/dist/server/LocationServiceConnector.js +53 -14
- package/dist/server/getClientConfig.d.ts +3 -2
- package/dist/server/getClientConfig.js +44 -17
- package/dist/transport/errors.d.ts +14 -8
- package/dist/transport/errors.js +27 -19
- 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(
|
|
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 =
|
|
139
|
-
?
|
|
140
|
-
:
|
|
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.
|
|
150
|
-
// `Highlights
|
|
151
|
-
//
|
|
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
|
-
...(
|
|
200
|
+
...(bbox || countries
|
|
159
201
|
? {
|
|
160
202
|
Filter: {
|
|
161
|
-
...(
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
10
|
-
* request and response types are
|
|
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.
|
|
18
|
-
* request and response types are
|
|
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
|
-
//
|
|
58
|
-
// not a decision to send an empty one. With `??` it survived the
|
|
59
|
-
// skipped `refreshToken`, and then failed the check
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|