@chaosity/location-client 0.1.14 → 0.2.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 +7 -3
- package/dist/auth/TokenProvider.d.ts +14 -0
- package/dist/auth/TokenProvider.js +43 -46
- package/dist/client/GeoPlacesClient.d.ts +11 -6
- package/dist/client/GeoPlacesClient.js +16 -63
- package/dist/errors/LocationServiceException.d.ts +30 -2
- package/dist/errors/LocationServiceException.js +34 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/server/LocationServiceConnector.d.ts +15 -24
- package/dist/server/LocationServiceConnector.js +21 -84
- package/dist/server/getClientConfig.js +40 -10
- package/dist/transport/endpoints.d.ts +2 -0
- package/dist/transport/endpoints.js +31 -0
- package/dist/transport/errors.d.ts +19 -0
- package/dist/transport/errors.js +100 -0
- package/dist/transport/http.d.ts +24 -0
- package/dist/transport/http.js +134 -0
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -73,14 +73,18 @@ const config = await getClientConfig({
|
|
|
73
73
|
### Client-Side: Using the GeoPlacesClient
|
|
74
74
|
|
|
75
75
|
```typescript
|
|
76
|
-
import {
|
|
76
|
+
import {
|
|
77
|
+
GeoPlacesClient,
|
|
78
|
+
SuggestCommand,
|
|
79
|
+
type SuggestCommandOutput,
|
|
80
|
+
} from '@chaosity/location-client'
|
|
77
81
|
|
|
78
82
|
const client = new GeoPlacesClient({
|
|
79
83
|
apiUrl: 'https://api.chaosity.cloud',
|
|
80
84
|
token: 'your-bearer-token',
|
|
81
85
|
})
|
|
82
86
|
|
|
83
|
-
const response = await client.send(
|
|
87
|
+
const response: SuggestCommandOutput = await client.send(
|
|
84
88
|
new SuggestCommand({ QueryText: 'Vancouver', MaxResults: 5 }),
|
|
85
89
|
)
|
|
86
90
|
```
|
|
@@ -396,7 +400,7 @@ DEBUG=location-client:api npm run dev
|
|
|
396
400
|
Full TypeScript support with types from AWS SDK:
|
|
397
401
|
|
|
398
402
|
```typescript
|
|
399
|
-
import type { SuggestCommandOutput } from '@
|
|
403
|
+
import type { SuggestCommandOutput } from '@chaosity/location-client'
|
|
400
404
|
|
|
401
405
|
const response: SuggestCommandOutput = await client.send(
|
|
402
406
|
new SuggestCommand({ QueryText: 'Vancouver' }),
|
|
@@ -44,6 +44,20 @@ export declare class TokenProvider {
|
|
|
44
44
|
private tokenPromise?;
|
|
45
45
|
constructor(config: TokenProviderConfig);
|
|
46
46
|
getToken(forceRefresh?: boolean): Promise<TokenResponse>;
|
|
47
|
+
/**
|
|
48
|
+
* Fetch a token, distinguishing transient failure from terminal (#9).
|
|
49
|
+
*
|
|
50
|
+
* This used to collapse every non-200 into `new Error(message)`: no status,
|
|
51
|
+
* no OAuth `error`, no `Retry-After`. So `503 temporarily_unavailable` — which
|
|
52
|
+
* the API returns when its config store is unreachable — was indistinguishable
|
|
53
|
+
* from `401 unauthorized`, and a customer whose credentials were perfectly
|
|
54
|
+
* good was told to go and check them.
|
|
55
|
+
*
|
|
56
|
+
* The shared transport now does the classifying: it retries 503/429/network
|
|
57
|
+
* honouring `Retry-After`, never retries 401/400, and throws a typed
|
|
58
|
+
* LocationServiceException either way. A failure REJECTS rather than resolving
|
|
59
|
+
* with `success: false`, so a stale token can never be used by accident.
|
|
60
|
+
*/
|
|
47
61
|
private fetchToken;
|
|
48
62
|
private isExpired;
|
|
49
63
|
clearCache(): void;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
|
+
import { LocationServiceException } from '../errors/LocationServiceException';
|
|
3
|
+
import { requestJson } from '../transport/http';
|
|
2
4
|
const log = debug('location-client:auth');
|
|
3
5
|
/**
|
|
4
6
|
* TokenProvider - SERVER-SIDE ONLY
|
|
@@ -73,55 +75,50 @@ export class TokenProvider {
|
|
|
73
75
|
this.tokenPromise = undefined;
|
|
74
76
|
}
|
|
75
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Fetch a token, distinguishing transient failure from terminal (#9).
|
|
80
|
+
*
|
|
81
|
+
* This used to collapse every non-200 into `new Error(message)`: no status,
|
|
82
|
+
* no OAuth `error`, no `Retry-After`. So `503 temporarily_unavailable` — which
|
|
83
|
+
* the API returns when its config store is unreachable — was indistinguishable
|
|
84
|
+
* from `401 unauthorized`, and a customer whose credentials were perfectly
|
|
85
|
+
* good was told to go and check them.
|
|
86
|
+
*
|
|
87
|
+
* The shared transport now does the classifying: it retries 503/429/network
|
|
88
|
+
* honouring `Retry-After`, never retries 401/400, and throws a typed
|
|
89
|
+
* LocationServiceException either way. A failure REJECTS rather than resolving
|
|
90
|
+
* with `success: false`, so a stale token can never be used by accident.
|
|
91
|
+
*/
|
|
76
92
|
async fetchToken() {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
const { clientId, clientSecret, apiUrl } = this.config;
|
|
94
|
+
const credentials = btoa(`${clientId}:${clientSecret}`);
|
|
95
|
+
const data = await requestJson(`${apiUrl}/auth/token`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Basic ${credentials}`,
|
|
99
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
100
|
+
},
|
|
101
|
+
body: new URLSearchParams({
|
|
102
|
+
grant_type: 'client_credentials',
|
|
103
|
+
}).toString(),
|
|
104
|
+
}, { retry: { maxAttempts: 3 } });
|
|
105
|
+
if (!data.access_token) {
|
|
106
|
+
throw new LocationServiceException({
|
|
107
|
+
code: 'InvalidCredentialsException',
|
|
108
|
+
message: 'Token endpoint returned no access_token',
|
|
109
|
+
details: { source: 'client' },
|
|
89
110
|
});
|
|
90
|
-
if (!response.ok) {
|
|
91
|
-
const errorText = await response.text();
|
|
92
|
-
let errorMessage = `Token request failed: ${response.statusText}`;
|
|
93
|
-
try {
|
|
94
|
-
const errorData = JSON.parse(errorText);
|
|
95
|
-
if (errorData.error_description)
|
|
96
|
-
errorMessage = errorData.error_description;
|
|
97
|
-
else if (errorData.error)
|
|
98
|
-
errorMessage = errorData.error;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
/* use statusText */
|
|
102
|
-
}
|
|
103
|
-
throw new Error(errorMessage);
|
|
104
|
-
}
|
|
105
|
-
const data = await response.json();
|
|
106
|
-
this.cachedToken = data.access_token;
|
|
107
|
-
// Prefer absolute expires_at (ms) from response, fall back to expires_in (seconds)
|
|
108
|
-
this.cachedExpiresAt =
|
|
109
|
-
data.expires_at ?? Date.now() + (data.expires_in ?? 900) * 1000;
|
|
110
|
-
const expiresInSec = Math.floor((this.cachedExpiresAt - Date.now()) / 1000);
|
|
111
|
-
log('Token acquired successfully (expires in %ds)', expiresInSec);
|
|
112
|
-
return {
|
|
113
|
-
success: true,
|
|
114
|
-
token: this.cachedToken,
|
|
115
|
-
expiresAt: this.cachedExpiresAt,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
catch (error) {
|
|
119
|
-
log('Token acquisition failed: %s', error instanceof Error ? error.message : 'Unknown error');
|
|
120
|
-
return {
|
|
121
|
-
success: false,
|
|
122
|
-
error: error instanceof Error ? error.message : 'Failed to get token',
|
|
123
|
-
};
|
|
124
111
|
}
|
|
112
|
+
this.cachedToken = data.access_token;
|
|
113
|
+
// Prefer the absolute expires_at (ms); fall back to expires_in (seconds).
|
|
114
|
+
this.cachedExpiresAt =
|
|
115
|
+
data.expires_at ?? Date.now() + (data.expires_in ?? 900) * 1000;
|
|
116
|
+
log('Token acquired successfully (expires in %ds)', Math.floor((this.cachedExpiresAt - Date.now()) / 1000));
|
|
117
|
+
return {
|
|
118
|
+
success: true,
|
|
119
|
+
token: this.cachedToken,
|
|
120
|
+
expiresAt: this.cachedExpiresAt,
|
|
121
|
+
};
|
|
125
122
|
}
|
|
126
123
|
isExpired(bufferSeconds = 60) {
|
|
127
124
|
if (!this.cachedExpiresAt)
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import type { RequestOptions } from '../transport/http';
|
|
1
2
|
import type { ClientConfig } from '../types';
|
|
3
|
+
export type SendOptions = RequestOptions;
|
|
2
4
|
/**
|
|
3
|
-
* GeoPlacesClient
|
|
5
|
+
* GeoPlacesClient — AWS Location Service compatible client with custom auth.
|
|
4
6
|
*
|
|
5
|
-
* Uses AWS SDK command classes but replaces SigV4
|
|
6
|
-
*
|
|
7
|
+
* Uses AWS SDK command classes but replaces SigV4 with a Bearer token. All
|
|
8
|
+
* request and response types are identical to AWS Location Service.
|
|
7
9
|
*
|
|
8
|
-
* Pass `getToken` in config
|
|
10
|
+
* Pass `getToken` in config for live refresh without recreating the client.
|
|
9
11
|
*/
|
|
10
12
|
export declare class GeoPlacesClient {
|
|
11
13
|
private clientConfig;
|
|
@@ -13,6 +15,9 @@ export declare class GeoPlacesClient {
|
|
|
13
15
|
serviceId: string;
|
|
14
16
|
};
|
|
15
17
|
constructor(config: ClientConfig);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
|
|
20
|
+
* to disable the retry loop. Every failure throws LocationServiceException.
|
|
21
|
+
*/
|
|
22
|
+
send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
|
|
18
23
|
}
|
|
@@ -1,86 +1,39 @@
|
|
|
1
|
-
import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
|
|
2
1
|
import debug from 'debug';
|
|
3
|
-
import {
|
|
2
|
+
import { resolveEndpoint } from '../transport/endpoints';
|
|
3
|
+
import { requestJson } from '../transport/http';
|
|
4
4
|
import { roundPositionFields } from '../utils/roundPosition';
|
|
5
5
|
const log = debug('location-client:api');
|
|
6
|
-
const logError = debug('location-client:api:error');
|
|
7
|
-
// Minification-safe endpoint map — uses constructor identity, not class name strings
|
|
8
|
-
const ENDPOINT_MAP = new Map([
|
|
9
|
-
[AutocompleteCommand, '/address/autocomplete'],
|
|
10
|
-
[GeocodeCommand, '/address/geocode'],
|
|
11
|
-
[GetPlaceCommand, '/address/place'],
|
|
12
|
-
[ReverseGeocodeCommand, '/address/search/reverse-geocode'],
|
|
13
|
-
[SearchNearbyCommand, '/address/search/nearby'],
|
|
14
|
-
[SearchTextCommand, '/address/search/text'],
|
|
15
|
-
[SuggestCommand, '/address/suggestion'],
|
|
16
|
-
]);
|
|
17
6
|
/**
|
|
18
|
-
* GeoPlacesClient
|
|
7
|
+
* GeoPlacesClient — AWS Location Service compatible client with custom auth.
|
|
19
8
|
*
|
|
20
|
-
* Uses AWS SDK command classes but replaces SigV4
|
|
21
|
-
*
|
|
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.
|
|
22
11
|
*
|
|
23
|
-
* Pass `getToken` in config
|
|
12
|
+
* Pass `getToken` in config for live refresh without recreating the client.
|
|
24
13
|
*/
|
|
25
14
|
export class GeoPlacesClient {
|
|
26
15
|
constructor(config) {
|
|
27
16
|
this.clientConfig = config;
|
|
28
17
|
this.config = { serviceId: 'Geo Places' };
|
|
29
18
|
}
|
|
30
|
-
|
|
19
|
+
/**
|
|
20
|
+
* @param options `signal` to cancel, `timeoutMs` per attempt, `retry: false`
|
|
21
|
+
* to disable the retry loop. Every failure throws LocationServiceException.
|
|
22
|
+
*/
|
|
23
|
+
async send(command, options) {
|
|
31
24
|
const cmd = command;
|
|
32
|
-
const endpoint =
|
|
33
|
-
const url = `${this.clientConfig.apiUrl}${endpoint}`;
|
|
34
|
-
const commandName = cmd.constructor.name;
|
|
25
|
+
const endpoint = resolveEndpoint(cmd);
|
|
35
26
|
const input = roundPositionFields(cmd.input);
|
|
36
|
-
// Prefer getToken callback (live ref) over static token string
|
|
27
|
+
// Prefer the getToken callback (live ref) over a static token string.
|
|
37
28
|
const token = this.clientConfig.getToken?.() ?? this.clientConfig.token;
|
|
38
|
-
log('Sending %s to %s',
|
|
39
|
-
|
|
40
|
-
const response = await fetch(url, {
|
|
29
|
+
log('Sending %s to %s', cmd.constructor?.name, endpoint);
|
|
30
|
+
return requestJson(`${this.clientConfig.apiUrl}${endpoint}`, {
|
|
41
31
|
method: 'POST',
|
|
42
32
|
headers: {
|
|
43
33
|
'Content-Type': 'application/json',
|
|
44
34
|
Authorization: `Bearer ${token}`,
|
|
45
35
|
},
|
|
46
36
|
body: JSON.stringify(input),
|
|
47
|
-
});
|
|
48
|
-
const duration = Date.now() - startTime;
|
|
49
|
-
if (!response.ok) {
|
|
50
|
-
const errorText = await response.text();
|
|
51
|
-
logError('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
|
|
52
|
-
let errorMessage = `API request failed: ${response.statusText}`;
|
|
53
|
-
let errorCode = 'ServiceException';
|
|
54
|
-
let requestId;
|
|
55
|
-
try {
|
|
56
|
-
const errorData = JSON.parse(errorText);
|
|
57
|
-
if (errorData.message)
|
|
58
|
-
errorMessage = errorData.message;
|
|
59
|
-
if (errorData.code)
|
|
60
|
-
errorCode = errorData.code;
|
|
61
|
-
if (errorData.requestId)
|
|
62
|
-
requestId = errorData.requestId;
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
if (errorText)
|
|
66
|
-
errorMessage = errorText;
|
|
67
|
-
}
|
|
68
|
-
throw new LocationServiceException({
|
|
69
|
-
message: errorMessage,
|
|
70
|
-
code: errorCode,
|
|
71
|
-
statusCode: response.status,
|
|
72
|
-
requestId,
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
const result = await response.json();
|
|
76
|
-
log('Request successful: %s (%dms)', response.status, duration);
|
|
77
|
-
return result;
|
|
78
|
-
}
|
|
79
|
-
getEndpoint(command) {
|
|
80
|
-
for (const [CommandClass, endpoint] of ENDPOINT_MAP) {
|
|
81
|
-
if (command instanceof CommandClass)
|
|
82
|
-
return endpoint;
|
|
83
|
-
}
|
|
84
|
-
throw new Error(`Unknown command type: ${command.constructor?.name ?? typeof command}`);
|
|
37
|
+
}, options);
|
|
85
38
|
}
|
|
86
39
|
}
|
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
export interface LocationServiceExceptionOptions {
|
|
2
2
|
message: string;
|
|
3
3
|
code: string;
|
|
4
|
-
|
|
4
|
+
/** Absent for failures that never reached the server: network, timeout, abort. */
|
|
5
|
+
statusCode?: number;
|
|
5
6
|
requestId?: string;
|
|
7
|
+
/** Structured extras — `source: 'client'` marks a locally-raised failure. */
|
|
8
|
+
details?: Record<string, unknown>;
|
|
9
|
+
cause?: unknown;
|
|
10
|
+
/** Parsed from the `Retry-After` header, in milliseconds. */
|
|
11
|
+
retryAfterMs?: number;
|
|
6
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The single error type this package throws.
|
|
15
|
+
*
|
|
16
|
+
* Every failure — an API error envelope, a network fault, a timeout, an abort —
|
|
17
|
+
* arrives as one of these, so a caller writes one `catch` and asks the getters
|
|
18
|
+
* rather than sniffing at `TypeError` vs `DOMException` vs a bare `Error`.
|
|
19
|
+
*/
|
|
7
20
|
export declare class LocationServiceException extends Error {
|
|
8
21
|
readonly code: string;
|
|
9
|
-
readonly statusCode
|
|
22
|
+
readonly statusCode?: number;
|
|
10
23
|
readonly requestId?: string;
|
|
24
|
+
readonly details?: Record<string, unknown>;
|
|
25
|
+
readonly retryAfterMs?: number;
|
|
11
26
|
constructor(options: LocationServiceExceptionOptions);
|
|
27
|
+
/**
|
|
28
|
+
* Worth another attempt.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately a explicit list rather than `statusCode >= 500`: a 500 or 501
|
|
31
|
+
* means the server broke on this request and will break again, while 502/503/
|
|
32
|
+
* 504 mean it never got there or gave up waiting. Retrying the first kind just
|
|
33
|
+
* multiplies the damage. Network failures and timeouts are retryable because
|
|
34
|
+
* nothing was necessarily processed.
|
|
35
|
+
*/
|
|
12
36
|
get isRetryable(): boolean;
|
|
13
37
|
get isThrottling(): boolean;
|
|
14
38
|
get isValidation(): boolean;
|
|
39
|
+
/** Credentials or entitlements — the caller must act, retrying will not help. */
|
|
40
|
+
get isAuth(): boolean;
|
|
41
|
+
get isAborted(): boolean;
|
|
42
|
+
get isTimeout(): boolean;
|
|
15
43
|
toString(): string;
|
|
16
44
|
}
|
|
@@ -1,13 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single error type this package throws.
|
|
3
|
+
*
|
|
4
|
+
* Every failure — an API error envelope, a network fault, a timeout, an abort —
|
|
5
|
+
* arrives as one of these, so a caller writes one `catch` and asks the getters
|
|
6
|
+
* rather than sniffing at `TypeError` vs `DOMException` vs a bare `Error`.
|
|
7
|
+
*/
|
|
1
8
|
export class LocationServiceException extends Error {
|
|
2
9
|
constructor(options) {
|
|
3
|
-
super(options.message);
|
|
10
|
+
super(options.message, { cause: options.cause });
|
|
4
11
|
this.name = 'LocationServiceException';
|
|
5
12
|
this.code = options.code;
|
|
6
13
|
this.statusCode = options.statusCode;
|
|
7
14
|
this.requestId = options.requestId;
|
|
15
|
+
this.details = options.details;
|
|
16
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
8
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Worth another attempt.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately a explicit list rather than `statusCode >= 500`: a 500 or 501
|
|
22
|
+
* means the server broke on this request and will break again, while 502/503/
|
|
23
|
+
* 504 mean it never got there or gave up waiting. Retrying the first kind just
|
|
24
|
+
* multiplies the damage. Network failures and timeouts are retryable because
|
|
25
|
+
* nothing was necessarily processed.
|
|
26
|
+
*/
|
|
9
27
|
get isRetryable() {
|
|
10
|
-
|
|
28
|
+
if (this.code === 'AbortedException')
|
|
29
|
+
return false;
|
|
30
|
+
if (this.code === 'NetworkException' || this.code === 'TimeoutException')
|
|
31
|
+
return true;
|
|
32
|
+
return (this.statusCode === 429 || [502, 503, 504].includes(this.statusCode ?? 0));
|
|
11
33
|
}
|
|
12
34
|
get isThrottling() {
|
|
13
35
|
return this.code === 'ThrottlingException' || this.statusCode === 429;
|
|
@@ -15,6 +37,16 @@ export class LocationServiceException extends Error {
|
|
|
15
37
|
get isValidation() {
|
|
16
38
|
return this.code === 'ValidationException' || this.statusCode === 400;
|
|
17
39
|
}
|
|
40
|
+
/** Credentials or entitlements — the caller must act, retrying will not help. */
|
|
41
|
+
get isAuth() {
|
|
42
|
+
return this.statusCode === 401 || this.statusCode === 403;
|
|
43
|
+
}
|
|
44
|
+
get isAborted() {
|
|
45
|
+
return this.code === 'AbortedException';
|
|
46
|
+
}
|
|
47
|
+
get isTimeout() {
|
|
48
|
+
return this.code === 'TimeoutException';
|
|
49
|
+
}
|
|
18
50
|
toString() {
|
|
19
51
|
const parts = [`LocationServiceException: [${this.code}] ${this.message}`];
|
|
20
52
|
if (this.requestId)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { GeoPlacesClient } from './client/GeoPlacesClient';
|
|
2
|
+
export type { SendOptions } from './client/GeoPlacesClient';
|
|
3
|
+
export { DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT_MS } from './transport/http';
|
|
4
|
+
export type { RequestOptions } from './transport/http';
|
|
2
5
|
export { LocationServiceException } from './errors/LocationServiceException';
|
|
3
6
|
export type { LocationServiceExceptionOptions } from './errors/LocationServiceException';
|
|
4
7
|
export * from '@aws-sdk/client-geo-places';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Client (Custom - uses our auth instead of AWS SigV4)
|
|
2
2
|
export { GeoPlacesClient } from './client/GeoPlacesClient';
|
|
3
|
+
// Transport options — cancellation, per-attempt timeout, retry policy
|
|
4
|
+
export { DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT_MS } from './transport/http';
|
|
3
5
|
// Errors
|
|
4
6
|
export { LocationServiceException } from './errors/LocationServiceException';
|
|
5
7
|
// Re-export AWS SDK commands and types
|
|
@@ -1,46 +1,37 @@
|
|
|
1
|
+
import type { RequestOptions } from '../transport/http';
|
|
1
2
|
export interface ConnectorConfig {
|
|
2
3
|
apiUrl?: string;
|
|
3
4
|
token?: string;
|
|
4
5
|
getToken?: () => Promise<{
|
|
5
6
|
token: string;
|
|
6
7
|
}>;
|
|
8
|
+
/**
|
|
9
|
+
* Sent as the `Origin` header on every request. The API requires an Origin it
|
|
10
|
+
* recognises on every data request and answers 403 without one, so every
|
|
11
|
+
* caller was setting it by hand on each `send`; this does it once.
|
|
12
|
+
*/
|
|
13
|
+
origin?: string;
|
|
7
14
|
}
|
|
8
|
-
export interface SendOptions {
|
|
15
|
+
export interface SendOptions extends RequestOptions {
|
|
9
16
|
headers?: Record<string, string>;
|
|
10
17
|
}
|
|
11
18
|
/**
|
|
12
|
-
* LocationServiceConnector
|
|
19
|
+
* LocationServiceConnector — server-side connector for the Location Service API.
|
|
13
20
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* - Server-side token management
|
|
18
|
-
* - Enhanced error handling
|
|
19
|
-
*
|
|
20
|
-
* Uses AWS SDK command classes with Bearer token authentication.
|
|
21
|
+
* Backend-to-backend: automatic configuration from the environment, server-side
|
|
22
|
+
* token management, and the same transport (timeout, cancellation, retry) as the
|
|
23
|
+
* browser client.
|
|
21
24
|
*
|
|
22
25
|
* @example
|
|
23
26
|
* ```typescript
|
|
24
|
-
*
|
|
25
|
-
* const
|
|
26
|
-
*
|
|
27
|
-
* // Or provide explicit config
|
|
28
|
-
* const connector = new LocationServiceConnector({
|
|
29
|
-
* apiUrl: 'https://api.example.com',
|
|
30
|
-
* token: 'your-token'
|
|
31
|
-
* })
|
|
32
|
-
*
|
|
33
|
-
* // Send with custom headers (including Origin)
|
|
34
|
-
* const result = await connector.send(
|
|
35
|
-
* new SearchTextCommand({ QueryText: 'Space Needle' }),
|
|
36
|
-
* { headers: { 'Origin': req.headers.origin } }
|
|
37
|
-
* )
|
|
27
|
+
* const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
|
|
28
|
+
* const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
|
|
38
29
|
* ```
|
|
39
30
|
*/
|
|
40
31
|
export declare class LocationServiceConnector {
|
|
41
32
|
private configPromise;
|
|
33
|
+
private origin?;
|
|
42
34
|
readonly serviceId: string;
|
|
43
35
|
constructor(config?: ConnectorConfig);
|
|
44
36
|
send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
|
|
45
|
-
private getEndpoint;
|
|
46
37
|
}
|
|
@@ -1,46 +1,31 @@
|
|
|
1
|
-
import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
|
|
2
1
|
import debug from 'debug';
|
|
3
2
|
import { LocationServiceException } from '../errors/LocationServiceException';
|
|
3
|
+
import { resolveEndpoint } from '../transport/endpoints';
|
|
4
|
+
import { requestJson } from '../transport/http';
|
|
4
5
|
import { roundPositionFields } from '../utils/roundPosition';
|
|
5
6
|
import { getClientConfig } from './getClientConfig';
|
|
6
7
|
const log = debug('location-client:connector');
|
|
7
8
|
/**
|
|
8
|
-
* LocationServiceConnector
|
|
9
|
+
* LocationServiceConnector — server-side connector for the Location Service API.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - Server-side token management
|
|
14
|
-
* - Enhanced error handling
|
|
15
|
-
*
|
|
16
|
-
* Uses AWS SDK command classes with Bearer token authentication.
|
|
11
|
+
* Backend-to-backend: automatic configuration from the environment, server-side
|
|
12
|
+
* token management, and the same transport (timeout, cancellation, retry) as the
|
|
13
|
+
* browser client.
|
|
17
14
|
*
|
|
18
15
|
* @example
|
|
19
16
|
* ```typescript
|
|
20
|
-
*
|
|
21
|
-
* const
|
|
22
|
-
*
|
|
23
|
-
* // Or provide explicit config
|
|
24
|
-
* const connector = new LocationServiceConnector({
|
|
25
|
-
* apiUrl: 'https://api.example.com',
|
|
26
|
-
* token: 'your-token'
|
|
27
|
-
* })
|
|
28
|
-
*
|
|
29
|
-
* // Send with custom headers (including Origin)
|
|
30
|
-
* const result = await connector.send(
|
|
31
|
-
* new SearchTextCommand({ QueryText: 'Space Needle' }),
|
|
32
|
-
* { headers: { 'Origin': req.headers.origin } }
|
|
33
|
-
* )
|
|
17
|
+
* const connector = new LocationServiceConnector({ origin: 'https://app.example.com' })
|
|
18
|
+
* const result = await connector.send(new SearchTextCommand({ QueryText: 'Space Needle' }))
|
|
34
19
|
* ```
|
|
35
20
|
*/
|
|
36
21
|
export class LocationServiceConnector {
|
|
37
22
|
constructor(config) {
|
|
38
23
|
this.serviceId = 'Geo Places';
|
|
39
24
|
this.configPromise = config ? Promise.resolve(config) : getClientConfig();
|
|
25
|
+
this.origin = config?.origin;
|
|
40
26
|
}
|
|
41
27
|
async send(command, options) {
|
|
42
28
|
const config = await this.configPromise;
|
|
43
|
-
// Get token - ConnectorConfig may have getToken, ServerClientConfig has static token
|
|
44
29
|
let token;
|
|
45
30
|
if ('getToken' in config && typeof config.getToken === 'function') {
|
|
46
31
|
const result = await config.getToken();
|
|
@@ -54,72 +39,24 @@ export class LocationServiceConnector {
|
|
|
54
39
|
token = config.token;
|
|
55
40
|
}
|
|
56
41
|
if (!token) {
|
|
57
|
-
throw new
|
|
42
|
+
throw new LocationServiceException({
|
|
43
|
+
code: 'InvalidCredentialsException',
|
|
44
|
+
message: 'No token available — check clientId/clientSecret configuration',
|
|
45
|
+
details: { source: 'client' },
|
|
46
|
+
});
|
|
58
47
|
}
|
|
59
48
|
const cmd = command;
|
|
60
|
-
const endpoint =
|
|
61
|
-
const url = `${config.apiUrl}${endpoint}`;
|
|
62
|
-
const commandName = cmd.constructor.name;
|
|
49
|
+
const endpoint = resolveEndpoint(cmd);
|
|
63
50
|
const input = roundPositionFields(cmd.input);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// Merge headers: user headers, then system headers override
|
|
51
|
+
// Caller headers first so the system ones below cannot be overridden, but an
|
|
52
|
+
// explicit per-call Origin still beats the connector-wide default.
|
|
67
53
|
const headers = {
|
|
68
|
-
...(
|
|
54
|
+
...(this.origin ? { Origin: this.origin } : {}),
|
|
55
|
+
...(options?.headers ?? {}),
|
|
69
56
|
'Content-Type': 'application/json',
|
|
70
57
|
Authorization: `Bearer ${token}`,
|
|
71
58
|
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
headers,
|
|
75
|
-
body: JSON.stringify(input),
|
|
76
|
-
});
|
|
77
|
-
const duration = Date.now() - startTime;
|
|
78
|
-
if (!response.ok) {
|
|
79
|
-
const errorText = await response.text();
|
|
80
|
-
log('Request failed: %s %s (%dms)', response.status, response.statusText, duration);
|
|
81
|
-
let errorMessage = `API request failed: ${response.statusText}`;
|
|
82
|
-
let errorCode = 'ServiceException';
|
|
83
|
-
let requestId;
|
|
84
|
-
try {
|
|
85
|
-
const errorData = JSON.parse(errorText);
|
|
86
|
-
if (errorData.message)
|
|
87
|
-
errorMessage = errorData.message;
|
|
88
|
-
if (errorData.code)
|
|
89
|
-
errorCode = errorData.code;
|
|
90
|
-
if (errorData.requestId)
|
|
91
|
-
requestId = errorData.requestId;
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
if (errorText)
|
|
95
|
-
errorMessage = errorText;
|
|
96
|
-
}
|
|
97
|
-
throw new LocationServiceException({
|
|
98
|
-
message: errorMessage,
|
|
99
|
-
code: errorCode,
|
|
100
|
-
statusCode: response.status,
|
|
101
|
-
requestId,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
const result = await response.json();
|
|
105
|
-
log('Request successful: %s (%dms)', response.status, duration);
|
|
106
|
-
return result;
|
|
107
|
-
}
|
|
108
|
-
getEndpoint(command) {
|
|
109
|
-
if (command instanceof AutocompleteCommand)
|
|
110
|
-
return '/address/autocomplete';
|
|
111
|
-
if (command instanceof GeocodeCommand)
|
|
112
|
-
return '/address/geocode';
|
|
113
|
-
if (command instanceof GetPlaceCommand)
|
|
114
|
-
return '/address/place';
|
|
115
|
-
if (command instanceof ReverseGeocodeCommand)
|
|
116
|
-
return '/address/search/reverse-geocode';
|
|
117
|
-
if (command instanceof SearchNearbyCommand)
|
|
118
|
-
return '/address/search/nearby';
|
|
119
|
-
if (command instanceof SearchTextCommand)
|
|
120
|
-
return '/address/search/text';
|
|
121
|
-
if (command instanceof SuggestCommand)
|
|
122
|
-
return '/address/suggestion';
|
|
123
|
-
throw new Error(`Unknown command type: ${command.constructor?.name ?? typeof command}`);
|
|
59
|
+
log('Sending %s request to %s', cmd.constructor?.name, endpoint);
|
|
60
|
+
return requestJson(`${config.apiUrl}${endpoint}`, { method: 'POST', headers, body: JSON.stringify(input) }, options);
|
|
124
61
|
}
|
|
125
62
|
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { TokenProvider } from '../auth/TokenProvider';
|
|
4
|
+
import { LocationServiceException } from '../errors/LocationServiceException';
|
|
3
5
|
const log = debug('location-client:clientConfig');
|
|
4
6
|
// Singleton instance to prevent race conditions
|
|
5
7
|
let tokenProviderInstance = null;
|
|
6
8
|
let currentConfig = null;
|
|
7
9
|
function getTokenProvider(apiUrl, clientId, clientSecret) {
|
|
8
|
-
|
|
10
|
+
// The SECRET is part of the key. Without it, rotating a client secret while
|
|
11
|
+
// keeping the same clientId left this process reusing a provider built on the
|
|
12
|
+
// old secret — so the rotation appeared to do nothing until a restart. Hashed
|
|
13
|
+
// rather than concatenated so the key is never a secret in its own right, and
|
|
14
|
+
// never ends up in a log line (#5).
|
|
15
|
+
const configKey = `${apiUrl}:${clientId}:${createHash('sha256').update(clientSecret).digest('hex').slice(0, 16)}`;
|
|
9
16
|
// Reuse existing instance if config matches
|
|
10
17
|
if (tokenProviderInstance && currentConfig === configKey) {
|
|
11
18
|
log('[getTokenProvider] Reusing existing TokenProvider instance');
|
|
@@ -75,15 +82,38 @@ export async function getClientConfig(config = {}) {
|
|
|
75
82
|
log('[getClientConfig] Getting TokenProvider instance');
|
|
76
83
|
const provider = getTokenProvider(apiUrl, clientId, clientSecret);
|
|
77
84
|
log('[getClientConfig] Fetching token');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
});
|
|
87
117
|
}
|
|
88
118
|
log('[getClientConfig] Token fetched successfully, length:', result.token.length);
|
|
89
119
|
return {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AutocompleteCommand, GeocodeCommand, GetPlaceCommand, ReverseGeocodeCommand, SearchNearbyCommand, SearchTextCommand, SuggestCommand, } from '@aws-sdk/client-geo-places';
|
|
2
|
+
import { LocationServiceException } from '../errors/LocationServiceException';
|
|
3
|
+
/**
|
|
4
|
+
* Command class -> API path.
|
|
5
|
+
*
|
|
6
|
+
* Keyed on constructor IDENTITY, never on `constructor.name`: a minifier
|
|
7
|
+
* rewrites class names and a string-keyed map silently stops matching in a
|
|
8
|
+
* production bundle while working perfectly in dev. The server connector used
|
|
9
|
+
* to carry its own `if (cmd instanceof X)` chain saying the same thing in a
|
|
10
|
+
* different order — one of them was always going to drift.
|
|
11
|
+
*/
|
|
12
|
+
const ENDPOINTS = new Map([
|
|
13
|
+
[AutocompleteCommand, '/address/autocomplete'],
|
|
14
|
+
[GeocodeCommand, '/address/geocode'],
|
|
15
|
+
[GetPlaceCommand, '/address/place'],
|
|
16
|
+
[ReverseGeocodeCommand, '/address/search/reverse-geocode'],
|
|
17
|
+
[SearchNearbyCommand, '/address/search/nearby'],
|
|
18
|
+
[SearchTextCommand, '/address/search/text'],
|
|
19
|
+
[SuggestCommand, '/address/suggestion'],
|
|
20
|
+
]);
|
|
21
|
+
export function resolveEndpoint(command) {
|
|
22
|
+
for (const [CommandClass, endpoint] of ENDPOINTS) {
|
|
23
|
+
if (command instanceof CommandClass)
|
|
24
|
+
return endpoint;
|
|
25
|
+
}
|
|
26
|
+
throw new LocationServiceException({
|
|
27
|
+
code: 'UnknownCommandException',
|
|
28
|
+
message: `Unknown command type: ${command?.constructor?.name ?? typeof command}`,
|
|
29
|
+
details: { source: 'client' },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { LocationServiceException } from '../errors/LocationServiceException';
|
|
2
|
+
/**
|
|
3
|
+
* Turn a non-2xx response into a LocationServiceException.
|
|
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
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseErrorResponse(status: number, statusText: string, body: string, headers?: Headers): LocationServiceException;
|
|
15
|
+
/**
|
|
16
|
+
* `Retry-After` is either delta-seconds or an HTTP date. Both are legal and the
|
|
17
|
+
* API sends the first; a date is handled so a proxy or gateway cannot surprise us.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseRetryAfter(value: string | null | undefined): number | undefined;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { LocationServiceException } from '../errors/LocationServiceException';
|
|
2
|
+
/**
|
|
3
|
+
* Turn a non-2xx response into a LocationServiceException.
|
|
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
|
|
13
|
+
*/
|
|
14
|
+
export function parseErrorResponse(status, statusText, body, headers) {
|
|
15
|
+
let message = `Request failed: ${statusText || status}`;
|
|
16
|
+
let code;
|
|
17
|
+
let requestId;
|
|
18
|
+
let details;
|
|
19
|
+
try {
|
|
20
|
+
const data = JSON.parse(body);
|
|
21
|
+
if (typeof data.message === 'string')
|
|
22
|
+
message = data.message;
|
|
23
|
+
if (typeof data.code === 'string')
|
|
24
|
+
code = data.code;
|
|
25
|
+
if (typeof data.requestId === 'string')
|
|
26
|
+
requestId = data.requestId;
|
|
27
|
+
// OAuth envelope from /auth/token
|
|
28
|
+
if (!code && typeof data.error === 'string') {
|
|
29
|
+
message = data.error_description ?? data.error;
|
|
30
|
+
code = oauthCode(data.error, status);
|
|
31
|
+
details = { oauthError: data.error };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
if (body)
|
|
36
|
+
message = body;
|
|
37
|
+
}
|
|
38
|
+
return new LocationServiceException({
|
|
39
|
+
message,
|
|
40
|
+
code: code ?? statusCode(status),
|
|
41
|
+
statusCode: status,
|
|
42
|
+
requestId,
|
|
43
|
+
details,
|
|
44
|
+
retryAfterMs: parseRetryAfter(headers?.get('retry-after')),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** OAuth `error` values the token endpoint emits, mapped to our codes. */
|
|
48
|
+
function oauthCode(error, status) {
|
|
49
|
+
switch (error) {
|
|
50
|
+
case 'temporarily_unavailable':
|
|
51
|
+
return 'ServiceUnavailableException';
|
|
52
|
+
case 'invalid_client':
|
|
53
|
+
case 'unauthorized':
|
|
54
|
+
return 'InvalidCredentialsException';
|
|
55
|
+
case 'invalid_request':
|
|
56
|
+
return 'ValidationException';
|
|
57
|
+
case 'unsupported_grant_type':
|
|
58
|
+
return 'ValidationException';
|
|
59
|
+
default:
|
|
60
|
+
return statusCode(status);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Last resort when the body carried no code at all (bare gateway responses). */
|
|
64
|
+
function statusCode(status) {
|
|
65
|
+
switch (status) {
|
|
66
|
+
case 400:
|
|
67
|
+
return 'ValidationException';
|
|
68
|
+
case 401:
|
|
69
|
+
return 'UnauthorizedException';
|
|
70
|
+
case 403:
|
|
71
|
+
return 'ForbiddenException';
|
|
72
|
+
case 404:
|
|
73
|
+
return 'NotFoundException';
|
|
74
|
+
case 429:
|
|
75
|
+
return 'ThrottlingException';
|
|
76
|
+
case 502:
|
|
77
|
+
return 'UpstreamException';
|
|
78
|
+
case 503:
|
|
79
|
+
return 'ServiceUnavailableException';
|
|
80
|
+
case 504:
|
|
81
|
+
return 'TimeoutException';
|
|
82
|
+
default:
|
|
83
|
+
return status >= 500 ? 'InternalException' : 'ServiceException';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* `Retry-After` is either delta-seconds or an HTTP date. Both are legal and the
|
|
88
|
+
* API sends the first; a date is handled so a proxy or gateway cannot surprise us.
|
|
89
|
+
*/
|
|
90
|
+
export function parseRetryAfter(value) {
|
|
91
|
+
if (!value)
|
|
92
|
+
return undefined;
|
|
93
|
+
const seconds = Number(value);
|
|
94
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
95
|
+
return seconds * 1000;
|
|
96
|
+
const date = Date.parse(value);
|
|
97
|
+
if (!Number.isNaN(date))
|
|
98
|
+
return Math.max(0, date - Date.now());
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
|
|
2
|
+
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
3
|
+
export declare const DEFAULT_MAX_ATTEMPTS = 3;
|
|
4
|
+
export interface RequestOptions {
|
|
5
|
+
/** Caller cancellation. Aborting rejects with code `AbortedException`. */
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
/** Per ATTEMPT, not for the whole call. Default 10 s. */
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
/** `false` disables retries entirely. Default 3 attempts = 2 retries. */
|
|
10
|
+
retry?: false | {
|
|
11
|
+
maxAttempts?: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Exponential backoff with FULL jitter, so retries never march in lockstep. */
|
|
15
|
+
export declare function backoffMs(attempt: number, random?: () => number): number;
|
|
16
|
+
/**
|
|
17
|
+
* One JSON request, with timeout, cancellation and retry.
|
|
18
|
+
*
|
|
19
|
+
* Every failure leaves as a LocationServiceException — a fetch rejection
|
|
20
|
+
* becomes `NetworkException` with the original as `cause`, an abort becomes
|
|
21
|
+
* `AbortedException`, a timeout becomes `TimeoutException` with
|
|
22
|
+
* `details.source = 'client'` so it is distinguishable from the API's own 504.
|
|
23
|
+
*/
|
|
24
|
+
export declare function requestJson<T>(url: string, init: RequestInit, options?: RequestOptions): Promise<T>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { LocationServiceException } from '../errors/LocationServiceException';
|
|
3
|
+
import { parseErrorResponse, parseRetryAfter } from './errors';
|
|
4
|
+
const log = debug('location-client:transport');
|
|
5
|
+
/** Per-attempt timeout. Sits well under the API's own 25 s Lambda ceiling. */
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 10000;
|
|
7
|
+
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
8
|
+
const BACKOFF_BASE_MS = 250;
|
|
9
|
+
const BACKOFF_CAP_MS = 4000;
|
|
10
|
+
/**
|
|
11
|
+
* Combine the caller's signal with a per-attempt timeout.
|
|
12
|
+
*
|
|
13
|
+
* `AbortSignal.any` is the clean way and exists in Node 20+ and current
|
|
14
|
+
* browsers; the manual fan-in keeps older runtimes working rather than
|
|
15
|
+
* throwing at import time.
|
|
16
|
+
*/
|
|
17
|
+
function attemptSignal(caller, timeoutMs) {
|
|
18
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
19
|
+
if (!caller)
|
|
20
|
+
return { signal: timeout, cleanup: () => { } };
|
|
21
|
+
if (typeof AbortSignal.any === 'function') {
|
|
22
|
+
return { signal: AbortSignal.any([caller, timeout]), cleanup: () => { } };
|
|
23
|
+
}
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const abort = () => controller.abort();
|
|
26
|
+
if (caller.aborted || timeout.aborted)
|
|
27
|
+
controller.abort();
|
|
28
|
+
caller.addEventListener('abort', abort);
|
|
29
|
+
timeout.addEventListener('abort', abort);
|
|
30
|
+
return {
|
|
31
|
+
signal: controller.signal,
|
|
32
|
+
cleanup: () => {
|
|
33
|
+
caller.removeEventListener('abort', abort);
|
|
34
|
+
timeout.removeEventListener('abort', abort);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Exponential backoff with FULL jitter, so retries never march in lockstep. */
|
|
39
|
+
export function backoffMs(attempt, random = Math.random) {
|
|
40
|
+
const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
|
|
41
|
+
return Math.floor(random() * ceiling);
|
|
42
|
+
}
|
|
43
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
|
+
/**
|
|
45
|
+
* One JSON request, with timeout, cancellation and retry.
|
|
46
|
+
*
|
|
47
|
+
* Every failure leaves as a LocationServiceException — a fetch rejection
|
|
48
|
+
* becomes `NetworkException` with the original as `cause`, an abort becomes
|
|
49
|
+
* `AbortedException`, a timeout becomes `TimeoutException` with
|
|
50
|
+
* `details.source = 'client'` so it is distinguishable from the API's own 504.
|
|
51
|
+
*/
|
|
52
|
+
export async function requestJson(url, init, options = {}) {
|
|
53
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
54
|
+
const maxAttempts = options.retry === false
|
|
55
|
+
? 1
|
|
56
|
+
: (options.retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
57
|
+
let lastError;
|
|
58
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
59
|
+
// Checked before every attempt: a signal aborted during backoff must not fire one more.
|
|
60
|
+
if (options.signal?.aborted)
|
|
61
|
+
throw abortedException(options.signal);
|
|
62
|
+
const { signal, cleanup } = attemptSignal(options.signal, timeoutMs);
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(url, { ...init, signal });
|
|
65
|
+
if (response.ok)
|
|
66
|
+
return (await response.json());
|
|
67
|
+
const error = parseErrorResponse(response.status, response.statusText, await response.text(), response.headers);
|
|
68
|
+
lastError = error;
|
|
69
|
+
if (!error.isRetryable || attempt === maxAttempts - 1)
|
|
70
|
+
throw error;
|
|
71
|
+
log('attempt %d failed (%s), retrying', attempt + 1, error.code);
|
|
72
|
+
await sleep(error.retryAfterMs ??
|
|
73
|
+
parseRetryAfter(response.headers.get('retry-after')) ??
|
|
74
|
+
backoffMs(attempt));
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (err instanceof LocationServiceException) {
|
|
78
|
+
// Already classified above, or thrown on the final attempt.
|
|
79
|
+
if (!err.isRetryable || attempt === maxAttempts - 1)
|
|
80
|
+
throw err;
|
|
81
|
+
lastError = err;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const wrapped = wrapFetchError(err, options.signal);
|
|
85
|
+
lastError = wrapped;
|
|
86
|
+
if (!wrapped.isRetryable || attempt === maxAttempts - 1)
|
|
87
|
+
throw wrapped;
|
|
88
|
+
log('attempt %d failed (%s), retrying', attempt + 1, wrapped.code);
|
|
89
|
+
await sleep(backoffMs(attempt));
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
cleanup();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/* c8 ignore next */
|
|
96
|
+
throw (lastError ??
|
|
97
|
+
new LocationServiceException({
|
|
98
|
+
code: 'InternalException',
|
|
99
|
+
message: 'Request failed',
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
function abortedException(signal) {
|
|
103
|
+
return new LocationServiceException({
|
|
104
|
+
code: 'AbortedException',
|
|
105
|
+
message: 'Request was aborted by the caller',
|
|
106
|
+
details: { source: 'client' },
|
|
107
|
+
cause: signal?.reason,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* fetch rejects with a raw `TypeError` for a network fault and a `DOMException`
|
|
112
|
+
* named AbortError for both cancellation and timeout — indistinguishable from
|
|
113
|
+
* each other unless the caller's own signal is checked, which is why that is
|
|
114
|
+
* checked first.
|
|
115
|
+
*/
|
|
116
|
+
function wrapFetchError(err, callerSignal) {
|
|
117
|
+
const name = err?.name;
|
|
118
|
+
if (callerSignal?.aborted)
|
|
119
|
+
return abortedException(callerSignal);
|
|
120
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
121
|
+
return new LocationServiceException({
|
|
122
|
+
code: 'TimeoutException',
|
|
123
|
+
message: 'Request timed out',
|
|
124
|
+
details: { source: 'client' },
|
|
125
|
+
cause: err,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return new LocationServiceException({
|
|
129
|
+
code: 'NetworkException',
|
|
130
|
+
message: err instanceof Error ? err.message : 'Network request failed',
|
|
131
|
+
details: { source: 'client' },
|
|
132
|
+
cause: err,
|
|
133
|
+
});
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chaosity/location-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Client library for Chaosity Location Service with AWS Location Service compatibility",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,11 +21,10 @@
|
|
|
21
21
|
"lint": "eslint . && prettier --check .",
|
|
22
22
|
"lint:fix": "eslint --fix . && prettier --write .",
|
|
23
23
|
"format": "prettier --write .",
|
|
24
|
-
"test": "vitest run
|
|
24
|
+
"test": "vitest run",
|
|
25
25
|
"test:watch": "vitest",
|
|
26
26
|
"prepublishOnly": "npm run build",
|
|
27
|
-
"prepare": "husky"
|
|
28
|
-
"postversion": "git push --follow-tags"
|
|
27
|
+
"prepare": "husky"
|
|
29
28
|
},
|
|
30
29
|
"keywords": [
|
|
31
30
|
"aws",
|