@chaosity/location-client 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -4
- package/dist/cjs/client/GeoPlacesClient.d.ts +13 -2
- package/dist/cjs/client/GeoPlacesClient.js +59 -9
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/maps/createTransformRequest.d.ts +2 -0
- package/dist/cjs/maps/createTransformRequest.js +45 -1
- package/dist/cjs/maps/mapStyle.d.ts +3 -1
- package/dist/cjs/maps/mapStyle.js +24 -19
- package/dist/cjs/maps/staticMap.d.ts +3 -1
- package/dist/cjs/maps/staticMap.js +18 -12
- package/dist/cjs/server/LocationServiceConnector.d.ts +56 -7
- package/dist/cjs/server/LocationServiceConnector.js +210 -32
- package/dist/cjs/server/getClientConfig.d.ts +76 -3
- package/dist/cjs/server/getClientConfig.js +152 -74
- package/dist/cjs/transport/errors.d.ts +30 -0
- package/dist/cjs/transport/errors.js +40 -0
- package/dist/cjs/transport/http.d.ts +36 -0
- package/dist/cjs/transport/http.js +118 -15
- package/dist/cjs/types/index.d.ts +37 -1
- package/dist/client/GeoPlacesClient.d.ts +13 -2
- package/dist/client/GeoPlacesClient.js +59 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/maps/createTransformRequest.d.ts +2 -0
- package/dist/maps/createTransformRequest.js +45 -1
- package/dist/maps/mapStyle.d.ts +3 -1
- package/dist/maps/mapStyle.js +25 -20
- package/dist/maps/staticMap.d.ts +3 -1
- package/dist/maps/staticMap.js +19 -13
- package/dist/server/LocationServiceConnector.d.ts +56 -7
- package/dist/server/LocationServiceConnector.js +211 -33
- package/dist/server/getClientConfig.d.ts +76 -3
- package/dist/server/getClientConfig.js +149 -74
- package/dist/transport/errors.d.ts +30 -0
- package/dist/transport/errors.js +38 -0
- package/dist/transport/http.d.ts +36 -0
- package/dist/transport/http.js +116 -14
- package/dist/types/index.d.ts +37 -1
- package/package.json +1 -1
|
@@ -3,9 +3,32 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { TokenProvider } from '../auth/TokenProvider.js';
|
|
4
4
|
import { LocationServiceException } from '../errors/LocationServiceException.js';
|
|
5
5
|
const log = debug('location-client:clientConfig');
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/**
|
|
7
|
+
* How many applications one process keeps token providers for.
|
|
8
|
+
*
|
|
9
|
+
* A provider holds a URL, a client id, a secret and one cached JWT, so the
|
|
10
|
+
* ceiling is about memory containment rather than a tuned working set — 64 is
|
|
11
|
+
* far above what a single-tenant service needs and far below anything worth
|
|
12
|
+
* worrying about. An agency past it pays a re-mint for the least recently used
|
|
13
|
+
* application, which is exactly the behaviour this replaced, but only for the
|
|
14
|
+
* coldest one instead of for every alternation.
|
|
15
|
+
*/
|
|
16
|
+
export const MAX_CACHED_PROVIDERS = 64;
|
|
17
|
+
/**
|
|
18
|
+
* One provider per configuration, most-recently-used last.
|
|
19
|
+
*
|
|
20
|
+
* This used to be TWO module-level variables holding a single provider, and a
|
|
21
|
+
* process serving more than one application therefore evicted the cache on
|
|
22
|
+
* every alternation: A→B→A→B took a full `/auth/token` round trip per call,
|
|
23
|
+
* each writing a jti row, all against the one shared token-endpoint throttle.
|
|
24
|
+
* The hit rate under alternating load was 0% (#39). Nothing leaked between
|
|
25
|
+
* tenants — each caller closes over the provider it asked for — so what this
|
|
26
|
+
* fixes is availability and cost, not confidentiality.
|
|
27
|
+
*
|
|
28
|
+
* A `Map` iterates in insertion order, so re-inserting on a hit makes the first
|
|
29
|
+
* key the least recently used, and an LRU needs no other bookkeeping.
|
|
30
|
+
*/
|
|
31
|
+
const tokenProviders = new Map();
|
|
9
32
|
function getTokenProvider(apiUrl, clientId, clientSecret) {
|
|
10
33
|
// The SECRET is part of the key. Without it, rotating a client secret while
|
|
11
34
|
// keeping the same clientId left this process reusing a provider built on the
|
|
@@ -13,16 +36,112 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
|
|
|
13
36
|
// rather than concatenated so the key is never a secret in its own right, and
|
|
14
37
|
// never ends up in a log line (#5).
|
|
15
38
|
const configKey = `${apiUrl}:${clientId}:${createHash('sha256').update(clientSecret).digest('hex').slice(0, 16)}`;
|
|
16
|
-
|
|
17
|
-
if (
|
|
39
|
+
const cached = tokenProviders.get(configKey);
|
|
40
|
+
if (cached) {
|
|
18
41
|
log('[getTokenProvider] Reusing existing TokenProvider instance');
|
|
19
|
-
|
|
42
|
+
// Re-insert to mark it most recently used.
|
|
43
|
+
tokenProviders.delete(configKey);
|
|
44
|
+
tokenProviders.set(configKey, cached);
|
|
45
|
+
return cached;
|
|
20
46
|
}
|
|
21
|
-
// Create new instance if config changed
|
|
22
47
|
log('[getTokenProvider] Creating new TokenProvider instance');
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
48
|
+
const provider = new TokenProvider({ apiUrl, clientId, clientSecret });
|
|
49
|
+
tokenProviders.set(configKey, provider);
|
|
50
|
+
if (tokenProviders.size > MAX_CACHED_PROVIDERS) {
|
|
51
|
+
const leastRecentlyUsed = tokenProviders.keys().next().value;
|
|
52
|
+
/* c8 ignore next — size > 0 here, so the iterator always yields */
|
|
53
|
+
if (leastRecentlyUsed !== undefined) {
|
|
54
|
+
log('[getTokenProvider] Evicting the least recently used TokenProvider');
|
|
55
|
+
tokenProviders.delete(leastRecentlyUsed);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return provider;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Where the API lives, from the argument or the environment.
|
|
62
|
+
*
|
|
63
|
+
* Separate from the credentials because the two are independently overridable:
|
|
64
|
+
* a caller can hand `LocationServiceConnector` its own `token` and still expect
|
|
65
|
+
* `LOCATION_API_URL` to say where to send it.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveApiUrl(explicit) {
|
|
68
|
+
return (explicit ||
|
|
69
|
+
process.env.LOCATION_API_URL ||
|
|
70
|
+
process.env.LOCATION_SERVICE_API_URL);
|
|
71
|
+
}
|
|
72
|
+
export function serverTokenSource(config = {}) {
|
|
73
|
+
log('[serverTokenSource] Starting with config:', {
|
|
74
|
+
hasApiUrl: !!config.apiUrl,
|
|
75
|
+
hasClientId: !!config.clientId,
|
|
76
|
+
});
|
|
77
|
+
// Auto-detect from environment with fallbacks
|
|
78
|
+
const apiUrl = resolveApiUrl(config.apiUrl);
|
|
79
|
+
const clientId = config.clientId ||
|
|
80
|
+
process.env.LOCATION_CLIENT_ID ||
|
|
81
|
+
process.env.LOCATION_SERVICE_CLIENT_ID;
|
|
82
|
+
const clientSecret = config.clientSecret ||
|
|
83
|
+
process.env.LOCATION_CLIENT_SECRET ||
|
|
84
|
+
process.env.LOCATION_SERVICE_CLIENT_SECRET;
|
|
85
|
+
log('[serverTokenSource] Resolved config:', {
|
|
86
|
+
apiUrl,
|
|
87
|
+
clientId: clientId?.substring(0, 10) + '...',
|
|
88
|
+
});
|
|
89
|
+
// Validate required values. A LocationServiceException like everything else
|
|
90
|
+
// this package throws — it used to be a bare `Error`, which was survivable
|
|
91
|
+
// while only `getClientConfig` could raise it and is not now that the
|
|
92
|
+
// connector reaches this path too.
|
|
93
|
+
if (!apiUrl || !clientId || !clientSecret) {
|
|
94
|
+
console.error('[location-client] Missing required configuration');
|
|
95
|
+
throw new LocationServiceException({
|
|
96
|
+
code: 'ValidationException',
|
|
97
|
+
message: 'Missing required configuration. Set environment variables: ' +
|
|
98
|
+
'LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET',
|
|
99
|
+
details: { source: 'client' },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
log('[serverTokenSource] Getting TokenProvider instance');
|
|
103
|
+
const provider = getTokenProvider(apiUrl, clientId, clientSecret);
|
|
104
|
+
return {
|
|
105
|
+
apiUrl,
|
|
106
|
+
async getToken(forceRefresh = false) {
|
|
107
|
+
log('[serverTokenSource] Fetching token (forceRefresh=%s)', forceRefresh);
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await provider.getToken(forceRefresh);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// The provider now rejects rather than resolving with success:false, and
|
|
114
|
+
// the rejection is typed — so a store outage (503) can be reported as a
|
|
115
|
+
// store outage instead of as bad credentials.
|
|
116
|
+
if (error instanceof LocationServiceException) {
|
|
117
|
+
if (error.isAuth) {
|
|
118
|
+
throw new LocationServiceException({
|
|
119
|
+
code: 'InvalidCredentialsException',
|
|
120
|
+
message: `Authentication failed for client ID "${clientId}". ` +
|
|
121
|
+
`Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
|
|
122
|
+
statusCode: error.statusCode,
|
|
123
|
+
requestId: error.requestId,
|
|
124
|
+
cause: error,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
// TokenProvider rejects rather than returning a tokenless success, so this
|
|
132
|
+
// is unreachable in practice — it is here to keep the contract explicit at
|
|
133
|
+
// the type level rather than asserting non-null.
|
|
134
|
+
const token = result.token;
|
|
135
|
+
if (!token) {
|
|
136
|
+
throw new LocationServiceException({
|
|
137
|
+
code: 'InvalidCredentialsException',
|
|
138
|
+
message: 'Token provider returned no token',
|
|
139
|
+
details: { source: 'client' },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return { ...result, token };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
26
145
|
}
|
|
27
146
|
/**
|
|
28
147
|
* Get client configuration with OAuth2 authentication.
|
|
@@ -43,6 +162,21 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
|
|
|
43
162
|
* NEVER call from browser/client code as it exposes credentials.
|
|
44
163
|
* For SPA projects, create your own backend endpoint that calls this.
|
|
45
164
|
*
|
|
165
|
+
* ## The return value is PLAIN DATA, and has to stay that way
|
|
166
|
+
*
|
|
167
|
+
* `{ apiUrl, token, expiresAt }` — no methods, no closures. The reason is not
|
|
168
|
+
* style: the shape every sample uses is a Next.js Server Action that returns
|
|
169
|
+
* this straight to a Client Component (every `src/lib/actions/location.ts`
|
|
170
|
+
* under `location-service-samples/web`), and the RSC boundary
|
|
171
|
+
* serialises it. A function on this object is not serialisable and throws at
|
|
172
|
+
* the boundary, so "make getClientConfig return getToken" — which #36 proposed
|
|
173
|
+
* and this JSDoc used to promise two lines below — would break every Next.js
|
|
174
|
+
* consumer of the library.
|
|
175
|
+
*
|
|
176
|
+
* A caller that needs a token which REFRESHES wants one of:
|
|
177
|
+
* - `LocationServiceConnector`, which holds a live source internally (#36), or
|
|
178
|
+
* - `TokenProvider` directly, if it is managing its own lifecycle.
|
|
179
|
+
*
|
|
46
180
|
* @example
|
|
47
181
|
* // Auto-detect from environment
|
|
48
182
|
* const config = await getClientConfig()
|
|
@@ -50,74 +184,15 @@ function getTokenProvider(apiUrl, clientId, clientSecret) {
|
|
|
50
184
|
* // Or override specific values
|
|
51
185
|
* const config = await getClientConfig({ apiUrl: 'https://custom.api.com' })
|
|
52
186
|
*
|
|
53
|
-
* //
|
|
54
|
-
* const
|
|
55
|
-
* const connector = new LocationServiceConnector({ apiUrl: config.apiUrl, token })
|
|
187
|
+
* // The API rejected the token before its exp — revoked, or secret rotated
|
|
188
|
+
* const fresh = await getClientConfig({ forceRefresh: true })
|
|
56
189
|
*/
|
|
57
190
|
export async function getClientConfig(config = {}) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
hasClientId: !!config.clientId,
|
|
61
|
-
});
|
|
62
|
-
// Auto-detect from environment with fallbacks
|
|
63
|
-
const apiUrl = config.apiUrl ||
|
|
64
|
-
process.env.LOCATION_API_URL ||
|
|
65
|
-
process.env.LOCATION_SERVICE_API_URL;
|
|
66
|
-
const clientId = config.clientId ||
|
|
67
|
-
process.env.LOCATION_CLIENT_ID ||
|
|
68
|
-
process.env.LOCATION_SERVICE_CLIENT_ID;
|
|
69
|
-
const clientSecret = config.clientSecret ||
|
|
70
|
-
process.env.LOCATION_CLIENT_SECRET ||
|
|
71
|
-
process.env.LOCATION_SERVICE_CLIENT_SECRET;
|
|
72
|
-
log('[getClientConfig] Resolved config:', {
|
|
73
|
-
apiUrl,
|
|
74
|
-
clientId: clientId?.substring(0, 10) + '...',
|
|
75
|
-
});
|
|
76
|
-
// Validate required values
|
|
77
|
-
if (!apiUrl || !clientId || !clientSecret) {
|
|
78
|
-
console.error('[getClientConfig] Missing required configuration');
|
|
79
|
-
throw new Error('Missing required configuration. Set environment variables: ' +
|
|
80
|
-
'LOCATION_API_URL, LOCATION_CLIENT_ID, LOCATION_CLIENT_SECRET');
|
|
81
|
-
}
|
|
82
|
-
log('[getClientConfig] Getting TokenProvider instance');
|
|
83
|
-
const provider = getTokenProvider(apiUrl, clientId, clientSecret);
|
|
84
|
-
log('[getClientConfig] Fetching token');
|
|
85
|
-
let result;
|
|
86
|
-
try {
|
|
87
|
-
result = await provider.getToken();
|
|
88
|
-
}
|
|
89
|
-
catch (error) {
|
|
90
|
-
// The provider now rejects rather than resolving with success:false, and the
|
|
91
|
-
// rejection is typed — so a store outage (503) can be reported as a store
|
|
92
|
-
// outage instead of as bad credentials.
|
|
93
|
-
if (error instanceof LocationServiceException) {
|
|
94
|
-
if (error.isAuth) {
|
|
95
|
-
throw new LocationServiceException({
|
|
96
|
-
code: 'InvalidCredentialsException',
|
|
97
|
-
message: `Authentication failed for client ID "${clientId}". ` +
|
|
98
|
-
`Verify LOCATION_CLIENT_ID and LOCATION_CLIENT_SECRET match your application in the developer portal.`,
|
|
99
|
-
statusCode: error.statusCode,
|
|
100
|
-
requestId: error.requestId,
|
|
101
|
-
cause: error,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
throw error;
|
|
105
|
-
}
|
|
106
|
-
throw error;
|
|
107
|
-
}
|
|
108
|
-
// TokenProvider rejects rather than returning a tokenless success, so this is
|
|
109
|
-
// unreachable in practice — it is here to keep the contract explicit at the
|
|
110
|
-
// type level rather than asserting non-null.
|
|
111
|
-
if (!result.token) {
|
|
112
|
-
throw new LocationServiceException({
|
|
113
|
-
code: 'InvalidCredentialsException',
|
|
114
|
-
message: 'Token provider returned no token',
|
|
115
|
-
details: { source: 'client' },
|
|
116
|
-
});
|
|
117
|
-
}
|
|
191
|
+
const source = serverTokenSource(config);
|
|
192
|
+
const result = await source.getToken(config.forceRefresh);
|
|
118
193
|
log('[getClientConfig] Token fetched successfully, length:', result.token.length);
|
|
119
194
|
return {
|
|
120
|
-
apiUrl,
|
|
195
|
+
apiUrl: source.apiUrl,
|
|
121
196
|
token: result.token,
|
|
122
197
|
expiresAt: result.expiresAt,
|
|
123
198
|
};
|
|
@@ -12,8 +12,38 @@ import { LocationServiceException } from '../errors/LocationServiceException.js'
|
|
|
12
12
|
* { message: "Unauthorized" } API Gateway's own responses
|
|
13
13
|
*/
|
|
14
14
|
export declare function parseErrorResponse(status: number, statusText: string, body: string, headers?: Headers): LocationServiceException;
|
|
15
|
+
/**
|
|
16
|
+
* The API has rejected this token: a 401, and only a 401.
|
|
17
|
+
*
|
|
18
|
+
* The authorizer throws `Unauthorized` for a token it cannot verify or that has
|
|
19
|
+
* expired, and API Gateway turns that into a 401. Its other refusals — no
|
|
20
|
+
* domain configured for the application, an Origin the application does not
|
|
21
|
+
* allow — are a Deny policy or a service 403, and a fresh token changes
|
|
22
|
+
* neither. Retrying those sends the same doomed request twice, for the same
|
|
23
|
+
* answer — which is the whole cost, since the service meters successful
|
|
24
|
+
* requests and no error response is billed whatever its status.
|
|
25
|
+
*
|
|
26
|
+
* Shared by both send paths so the browser client and the server connector
|
|
27
|
+
* cannot come to different conclusions about the same response.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isTokenRejected(err: unknown): boolean;
|
|
15
30
|
/**
|
|
16
31
|
* `Retry-After` is either delta-seconds or an HTTP date. Both are legal and the
|
|
17
32
|
* API sends the first; a date is handled so a proxy or gateway cannot surprise us.
|
|
18
33
|
*/
|
|
19
34
|
export declare function parseRetryAfter(value: string | null | undefined): number | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* No token to send, so nothing is sent.
|
|
37
|
+
*
|
|
38
|
+
* Every send path resolves a token before it builds a request, and every one of
|
|
39
|
+
* them can come up empty — a provider that has not initialised, a server action
|
|
40
|
+
* that returned nothing, credentials that are not configured. Sending anyway
|
|
41
|
+
* puts the literal string `Bearer undefined` on the wire, which the API answers
|
|
42
|
+
* with a 401 the caller then has to work backwards from — a whole round trip,
|
|
43
|
+
* paid for out of the caller's own deadline, to be told what it already knew.
|
|
44
|
+
* The map fetches did exactly that until #37.
|
|
45
|
+
*
|
|
46
|
+
* `advice` says what to check, because that differs by path: a server connector
|
|
47
|
+
* wants its client credentials looked at, a browser map wants its token source.
|
|
48
|
+
*/
|
|
49
|
+
export declare function noTokenAvailable(advice: string): LocationServiceException;
|
package/dist/transport/errors.js
CHANGED
|
@@ -83,6 +83,23 @@ function statusCode(status) {
|
|
|
83
83
|
return status >= 500 ? 'InternalException' : 'ServiceException';
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* The API has rejected this token: a 401, and only a 401.
|
|
88
|
+
*
|
|
89
|
+
* The authorizer throws `Unauthorized` for a token it cannot verify or that has
|
|
90
|
+
* expired, and API Gateway turns that into a 401. Its other refusals — no
|
|
91
|
+
* domain configured for the application, an Origin the application does not
|
|
92
|
+
* allow — are a Deny policy or a service 403, and a fresh token changes
|
|
93
|
+
* neither. Retrying those sends the same doomed request twice, for the same
|
|
94
|
+
* answer — which is the whole cost, since the service meters successful
|
|
95
|
+
* requests and no error response is billed whatever its status.
|
|
96
|
+
*
|
|
97
|
+
* Shared by both send paths so the browser client and the server connector
|
|
98
|
+
* cannot come to different conclusions about the same response.
|
|
99
|
+
*/
|
|
100
|
+
export function isTokenRejected(err) {
|
|
101
|
+
return err instanceof LocationServiceException && err.statusCode === 401;
|
|
102
|
+
}
|
|
86
103
|
/**
|
|
87
104
|
* `Retry-After` is either delta-seconds or an HTTP date. Both are legal and the
|
|
88
105
|
* API sends the first; a date is handled so a proxy or gateway cannot surprise us.
|
|
@@ -98,3 +115,24 @@ export function parseRetryAfter(value) {
|
|
|
98
115
|
return Math.max(0, date - Date.now());
|
|
99
116
|
return undefined;
|
|
100
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* No token to send, so nothing is sent.
|
|
120
|
+
*
|
|
121
|
+
* Every send path resolves a token before it builds a request, and every one of
|
|
122
|
+
* them can come up empty — a provider that has not initialised, a server action
|
|
123
|
+
* that returned nothing, credentials that are not configured. Sending anyway
|
|
124
|
+
* puts the literal string `Bearer undefined` on the wire, which the API answers
|
|
125
|
+
* with a 401 the caller then has to work backwards from — a whole round trip,
|
|
126
|
+
* paid for out of the caller's own deadline, to be told what it already knew.
|
|
127
|
+
* The map fetches did exactly that until #37.
|
|
128
|
+
*
|
|
129
|
+
* `advice` says what to check, because that differs by path: a server connector
|
|
130
|
+
* wants its client credentials looked at, a browser map wants its token source.
|
|
131
|
+
*/
|
|
132
|
+
export function noTokenAvailable(advice) {
|
|
133
|
+
return new LocationServiceException({
|
|
134
|
+
code: 'InvalidCredentialsException',
|
|
135
|
+
message: `No token available — ${advice}`,
|
|
136
|
+
details: { source: 'client' },
|
|
137
|
+
});
|
|
138
|
+
}
|
package/dist/transport/http.d.ts
CHANGED
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
/** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
|
|
2
2
|
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
3
|
+
/**
|
|
4
|
+
* Ceiling for the WHOLE call — every attempt plus every wait between them.
|
|
5
|
+
*
|
|
6
|
+
* `timeoutMs` bounds an attempt, not a call, and the gap between those two is
|
|
7
|
+
* where the caller's own deadline disappears. The API answers a spent quota
|
|
8
|
+
* with `Retry-After: 60`, which the retry loop honoured literally: two waits of
|
|
9
|
+
* a minute each, so one call could sit for ~120 s — past any Lambda budget,
|
|
10
|
+
* past any HTTP gateway, and until now uncancellable (#37).
|
|
11
|
+
*
|
|
12
|
+
* 30 s is picked to sit just under the old worst case: three default attempts
|
|
13
|
+
* that all time out, plus their backoff, came to ~30.75 s. The overlap is not
|
|
14
|
+
* quite nothing — when both earlier attempts burn their full 10 s, the third is
|
|
15
|
+
* clamped to the ~9.25 s that remain, so a response arriving in its final
|
|
16
|
+
* ~0.75 s used to succeed and now times out. That window is why this ships in a
|
|
17
|
+
* MINOR rather than a patch. What it buys is that no call can be made to sit
|
|
18
|
+
* out a retry hint longer than the caller has.
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEFAULT_OVERALL_TIMEOUT_MS = 30000;
|
|
3
21
|
export declare const DEFAULT_MAX_ATTEMPTS = 3;
|
|
4
22
|
export interface RequestOptions {
|
|
5
23
|
/** Caller cancellation. Aborting rejects with code `AbortedException`. */
|
|
6
24
|
signal?: AbortSignal;
|
|
7
25
|
/** Per ATTEMPT, not for the whole call. Default 10 s. */
|
|
8
26
|
timeoutMs?: number;
|
|
27
|
+
/**
|
|
28
|
+
* The whole call — attempts and the waits between them. Default 30 s.
|
|
29
|
+
*
|
|
30
|
+
* No attempt is given more than what is left of it, and a retry that would
|
|
31
|
+
* have to wait longer than what is left is not made at all: the API's own
|
|
32
|
+
* error comes back instead, `retryAfterMs` intact, so the caller can decide
|
|
33
|
+
* whether to queue the work or drop it.
|
|
34
|
+
*/
|
|
35
|
+
overallTimeoutMs?: number;
|
|
9
36
|
/** `false` disables retries entirely. Default 3 attempts = 2 retries. */
|
|
10
37
|
retry?: false | {
|
|
11
38
|
maxAttempts?: number;
|
|
@@ -22,3 +49,12 @@ export declare function backoffMs(attempt: number, random?: () => number): numbe
|
|
|
22
49
|
* `details.source = 'client'` so it is distinguishable from the API's own 504.
|
|
23
50
|
*/
|
|
24
51
|
export declare function requestJson<T>(url: string, init: RequestInit, options?: RequestOptions): Promise<T>;
|
|
52
|
+
/**
|
|
53
|
+
* One request answered as a Blob — the static map path.
|
|
54
|
+
*
|
|
55
|
+
* Exists so the two map fetches are not the only calls in the package without
|
|
56
|
+
* a timeout, a retry or a signal: they used their own bare `fetch`, so a
|
|
57
|
+
* network fault there escaped as a raw `TypeError` while the identical fault on
|
|
58
|
+
* any other call arrived as `NetworkException` (#37).
|
|
59
|
+
*/
|
|
60
|
+
export declare function requestBlob(url: string, init: RequestInit, options?: RequestOptions): Promise<Blob>;
|
package/dist/transport/http.js
CHANGED
|
@@ -4,6 +4,24 @@ import { parseErrorResponse, parseRetryAfter } from './errors.js';
|
|
|
4
4
|
const log = debug('location-client:transport');
|
|
5
5
|
/** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
|
|
6
6
|
export const DEFAULT_TIMEOUT_MS = 10000;
|
|
7
|
+
/**
|
|
8
|
+
* Ceiling for the WHOLE call — every attempt plus every wait between them.
|
|
9
|
+
*
|
|
10
|
+
* `timeoutMs` bounds an attempt, not a call, and the gap between those two is
|
|
11
|
+
* where the caller's own deadline disappears. The API answers a spent quota
|
|
12
|
+
* with `Retry-After: 60`, which the retry loop honoured literally: two waits of
|
|
13
|
+
* a minute each, so one call could sit for ~120 s — past any Lambda budget,
|
|
14
|
+
* past any HTTP gateway, and until now uncancellable (#37).
|
|
15
|
+
*
|
|
16
|
+
* 30 s is picked to sit just under the old worst case: three default attempts
|
|
17
|
+
* that all time out, plus their backoff, came to ~30.75 s. The overlap is not
|
|
18
|
+
* quite nothing — when both earlier attempts burn their full 10 s, the third is
|
|
19
|
+
* clamped to the ~9.25 s that remain, so a response arriving in its final
|
|
20
|
+
* ~0.75 s used to succeed and now times out. That window is why this ships in a
|
|
21
|
+
* MINOR rather than a patch. What it buys is that no call can be made to sit
|
|
22
|
+
* out a retry hint longer than the caller has.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_OVERALL_TIMEOUT_MS = 30000;
|
|
7
25
|
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
8
26
|
const BACKOFF_BASE_MS = 250;
|
|
9
27
|
const BACKOFF_CAP_MS = 4000;
|
|
@@ -40,38 +58,86 @@ export function backoffMs(attempt, random = Math.random) {
|
|
|
40
58
|
const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
|
|
41
59
|
return Math.floor(random() * ceiling);
|
|
42
60
|
}
|
|
43
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
61
|
/**
|
|
45
|
-
*
|
|
62
|
+
* Sleep, unless the caller aborts first.
|
|
46
63
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
64
|
+
* The timer used to be uncancellable, so an `abort()` during backoff was
|
|
65
|
+
* ignored until it elapsed — up to a whole `Retry-After` — and the loop only
|
|
66
|
+
* noticed at the top of the next attempt (#37). Resolving early is all that is
|
|
67
|
+
* needed: the loop's own pre-attempt check is what raises `AbortedException`,
|
|
68
|
+
* so exactly one place decides what an abort means.
|
|
51
69
|
*/
|
|
52
|
-
|
|
70
|
+
function sleep(ms, signal) {
|
|
71
|
+
if (ms <= 0 || signal?.aborted)
|
|
72
|
+
return Promise.resolve();
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const done = () => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
signal?.removeEventListener('abort', done);
|
|
77
|
+
resolve();
|
|
78
|
+
};
|
|
79
|
+
const timer = setTimeout(done, ms);
|
|
80
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* One request, with timeout, cancellation and retry.
|
|
85
|
+
*
|
|
86
|
+
* The body is read INSIDE the attempt loop deliberately: a truncated or
|
|
87
|
+
* malformed body is a failed attempt like any other and earns the same
|
|
88
|
+
* wrapping and the same retry as a dropped socket. Handing the `Response` back
|
|
89
|
+
* for the caller to read would move that outside the loop, where a bare
|
|
90
|
+
* `SyntaxError` escapes as itself.
|
|
91
|
+
*/
|
|
92
|
+
async function request(url, init, options, read) {
|
|
53
93
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
94
|
+
const overallTimeoutMs = options.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS;
|
|
54
95
|
const maxAttempts = options.retry === false
|
|
55
96
|
? 1
|
|
56
97
|
: (options.retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
98
|
+
// A request budget of zero attempts is a caller mistake, not a policy. It
|
|
99
|
+
// used to fall straight through the loop and raise `InternalException` for a
|
|
100
|
+
// request that was never made — an error about our own internals, for their
|
|
101
|
+
// typo (#37).
|
|
102
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
103
|
+
throw new LocationServiceException({
|
|
104
|
+
code: 'ValidationException',
|
|
105
|
+
message: `retry.maxAttempts must be a whole number of at least 1; received ${maxAttempts}`,
|
|
106
|
+
details: { source: 'client' },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const deadline = Date.now() + overallTimeoutMs;
|
|
57
110
|
let lastError;
|
|
58
111
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
59
112
|
// Checked before every attempt: a signal aborted during backoff must not fire one more.
|
|
60
113
|
if (options.signal?.aborted)
|
|
61
114
|
throw abortedException(options.signal);
|
|
62
|
-
const
|
|
115
|
+
const budget = deadline - Date.now();
|
|
116
|
+
if (budget <= 0)
|
|
117
|
+
throw lastError ?? overallTimeoutException(overallTimeoutMs);
|
|
118
|
+
// Never longer than what is left of the call, so the per-attempt timeout
|
|
119
|
+
// cannot overrun the budget it sits inside.
|
|
120
|
+
const { signal, cleanup } = attemptSignal(options.signal, Math.min(timeoutMs, budget));
|
|
63
121
|
try {
|
|
64
122
|
const response = await fetch(url, { ...init, signal });
|
|
65
123
|
if (response.ok)
|
|
66
|
-
return
|
|
124
|
+
return await read(response);
|
|
67
125
|
const error = parseErrorResponse(response.status, response.statusText, await response.text(), response.headers);
|
|
68
126
|
lastError = error;
|
|
69
127
|
if (!error.isRetryable || attempt === maxAttempts - 1)
|
|
70
128
|
throw error;
|
|
71
|
-
|
|
72
|
-
await sleep(error.retryAfterMs ??
|
|
129
|
+
const wait = error.retryAfterMs ??
|
|
73
130
|
parseRetryAfter(response.headers.get('retry-after')) ??
|
|
74
|
-
backoffMs(attempt)
|
|
131
|
+
backoffMs(attempt);
|
|
132
|
+
// Sitting out a 60 s `Retry-After` inside a 30 s budget only delivers the
|
|
133
|
+
// same failure after the caller has already given up. Stop now and hand
|
|
134
|
+
// back what the API said, `retryAfterMs` and all.
|
|
135
|
+
if (wait >= deadline - Date.now()) {
|
|
136
|
+
log('not retrying: a %d ms wait outlasts the remaining budget', wait);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
log('attempt %d failed (%s), retrying', attempt + 1, error.code);
|
|
140
|
+
await sleep(wait, options.signal);
|
|
75
141
|
}
|
|
76
142
|
catch (err) {
|
|
77
143
|
if (err instanceof LocationServiceException) {
|
|
@@ -85,20 +151,48 @@ export async function requestJson(url, init, options = {}) {
|
|
|
85
151
|
lastError = wrapped;
|
|
86
152
|
if (!wrapped.isRetryable || attempt === maxAttempts - 1)
|
|
87
153
|
throw wrapped;
|
|
154
|
+
const wait = backoffMs(attempt);
|
|
155
|
+
if (wait >= deadline - Date.now()) {
|
|
156
|
+
log('not retrying: a %d ms wait outlasts the remaining budget', wait);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
88
159
|
log('attempt %d failed (%s), retrying', attempt + 1, wrapped.code);
|
|
89
|
-
await sleep(
|
|
160
|
+
await sleep(wait, options.signal);
|
|
90
161
|
}
|
|
91
162
|
finally {
|
|
92
163
|
cleanup();
|
|
93
164
|
}
|
|
94
165
|
}
|
|
95
|
-
|
|
166
|
+
// Reached when the budget ran out before another attempt could be made, so
|
|
167
|
+
// `lastError` is the API's own answer and is the useful thing to throw.
|
|
96
168
|
throw (lastError ??
|
|
97
169
|
new LocationServiceException({
|
|
98
170
|
code: 'InternalException',
|
|
99
171
|
message: 'Request failed',
|
|
100
172
|
}));
|
|
101
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* One JSON request, with timeout, cancellation and retry.
|
|
176
|
+
*
|
|
177
|
+
* Every failure leaves as a LocationServiceException — a fetch rejection
|
|
178
|
+
* becomes `NetworkException` with the original as `cause`, an abort becomes
|
|
179
|
+
* `AbortedException`, a timeout becomes `TimeoutException` with
|
|
180
|
+
* `details.source = 'client'` so it is distinguishable from the API's own 504.
|
|
181
|
+
*/
|
|
182
|
+
export function requestJson(url, init, options = {}) {
|
|
183
|
+
return request(url, init, options, (response) => response.json());
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* One request answered as a Blob — the static map path.
|
|
187
|
+
*
|
|
188
|
+
* Exists so the two map fetches are not the only calls in the package without
|
|
189
|
+
* a timeout, a retry or a signal: they used their own bare `fetch`, so a
|
|
190
|
+
* network fault there escaped as a raw `TypeError` while the identical fault on
|
|
191
|
+
* any other call arrived as `NetworkException` (#37).
|
|
192
|
+
*/
|
|
193
|
+
export function requestBlob(url, init, options = {}) {
|
|
194
|
+
return request(url, init, options, (response) => response.blob());
|
|
195
|
+
}
|
|
102
196
|
function abortedException(signal) {
|
|
103
197
|
return new LocationServiceException({
|
|
104
198
|
code: 'AbortedException',
|
|
@@ -107,6 +201,14 @@ function abortedException(signal) {
|
|
|
107
201
|
cause: signal?.reason,
|
|
108
202
|
});
|
|
109
203
|
}
|
|
204
|
+
/** The call's own budget elapsed, rather than one attempt's timeout. */
|
|
205
|
+
function overallTimeoutException(overallTimeoutMs) {
|
|
206
|
+
return new LocationServiceException({
|
|
207
|
+
code: 'TimeoutException',
|
|
208
|
+
message: `Request exceeded its overall timeout of ${overallTimeoutMs} ms`,
|
|
209
|
+
details: { source: 'client' },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
110
212
|
/**
|
|
111
213
|
* fetch rejects with a raw `TypeError` for a network fault and a `DOMException`
|
|
112
214
|
* named AbortError for both cancellation and timeout — indistinguishable from
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,9 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* At least one of `token`, `getToken` or `refreshToken` must supply a token, or
|
|
3
|
+
* `send` refuses locally with `InvalidCredentialsException` rather than putting
|
|
4
|
+
* `Bearer undefined` on the wire (#37).
|
|
5
|
+
*
|
|
6
|
+
* `token` is optional because a client driven purely by a provider — the shape
|
|
7
|
+
* `@chaosity/location-client-react` uses — has nothing to put there at
|
|
8
|
+
* construction time, and was previously forced to invent a placeholder. It is
|
|
9
|
+
* still required on `ServerClientConfig`, which is a RESULT rather than a
|
|
10
|
+
* configuration: `getClientConfig` always resolves one.
|
|
11
|
+
*/
|
|
1
12
|
export interface ClientConfig {
|
|
2
13
|
apiUrl: string;
|
|
3
|
-
token
|
|
14
|
+
token?: string;
|
|
4
15
|
/** Optional callback to get the current token dynamically. When provided,
|
|
5
16
|
* called on every request so token updates are reflected without recreating the client. */
|
|
6
17
|
getToken?: () => string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Asked for a replacement AFTER the API has rejected the current token with a
|
|
20
|
+
* 401, so the request can be retried once instead of failing — and, since
|
|
21
|
+
* #37, asked once BEFORE the first send when neither `getToken` nor `token`
|
|
22
|
+
* yields anything, so a client whose token has not arrived yet does not spend
|
|
23
|
+
* a round trip on `Bearer undefined` to learn that. Both calls mean the same
|
|
24
|
+
* thing to an implementor — "give me a usable token" — so a `refreshToken`
|
|
25
|
+
* that mints or awaits one needs no change; only one that assumes every
|
|
26
|
+
* invocation follows a 401 does.
|
|
27
|
+
*
|
|
28
|
+
* Separate from `getToken` because that one is synchronous by contract — it
|
|
29
|
+
* is read while a request is being built and cannot await anything, so it can
|
|
30
|
+
* only ever return the token already in hand. Nothing in this library could
|
|
31
|
+
* therefore recover from a token revoked, or a secret rotated, before its
|
|
32
|
+
* `exp`: every request failed until the refresh buffer elapsed on its own
|
|
33
|
+
* (#36).
|
|
34
|
+
*
|
|
35
|
+
* Returning `undefined` is not "no replacement": the client then re-reads
|
|
36
|
+
* `getToken`, because a provider refreshing in the background may have landed
|
|
37
|
+
* a new token while the failed request was in flight. What actually decides
|
|
38
|
+
* is the token that comes out of those two — if it is the one just rejected,
|
|
39
|
+
* or there is none, the retry is skipped rather than repeating a request that
|
|
40
|
+
* is going to fail again.
|
|
41
|
+
*/
|
|
42
|
+
refreshToken?: () => Promise<string | undefined>;
|
|
7
43
|
}
|
|
8
44
|
/**
|
|
9
45
|
* Minimal interface for AWS SDK command objects.
|
package/package.json
CHANGED