@chaosity/location-client 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +108 -23
  2. package/dist/adapters/GeoPlaces.js +53 -17
  3. package/dist/auth/TokenProvider.d.ts +2 -0
  4. package/dist/auth/TokenProvider.js +39 -10
  5. package/dist/auth/tokenHold.d.ts +72 -0
  6. package/dist/auth/tokenHold.js +109 -0
  7. package/dist/cjs/adapters/GeoPlaces.js +53 -17
  8. package/dist/cjs/auth/TokenProvider.d.ts +2 -0
  9. package/dist/cjs/auth/TokenProvider.js +39 -10
  10. package/dist/cjs/auth/tokenHold.d.ts +72 -0
  11. package/dist/cjs/auth/tokenHold.js +115 -0
  12. package/dist/cjs/client/GeoPlacesClient.d.ts +6 -2
  13. package/dist/cjs/client/GeoPlacesClient.js +83 -21
  14. package/dist/cjs/client/commands.d.ts +2 -3
  15. package/dist/cjs/errors/LocationServiceException.d.ts +30 -2
  16. package/dist/cjs/errors/LocationServiceException.js +53 -1
  17. package/dist/cjs/index.d.ts +1 -1
  18. package/dist/cjs/server/LocationServiceConnector.d.ts +2 -0
  19. package/dist/cjs/server/LocationServiceConnector.js +53 -14
  20. package/dist/cjs/server/getClientConfig.d.ts +3 -2
  21. package/dist/cjs/server/getClientConfig.js +44 -17
  22. package/dist/cjs/transport/errors.d.ts +14 -8
  23. package/dist/cjs/transport/errors.js +26 -18
  24. package/dist/client/GeoPlacesClient.d.ts +6 -2
  25. package/dist/client/GeoPlacesClient.js +83 -21
  26. package/dist/client/commands.d.ts +2 -3
  27. package/dist/errors/LocationServiceException.d.ts +30 -2
  28. package/dist/errors/LocationServiceException.js +52 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/server/LocationServiceConnector.d.ts +2 -0
  31. package/dist/server/LocationServiceConnector.js +53 -14
  32. package/dist/server/getClientConfig.d.ts +3 -2
  33. package/dist/server/getClientConfig.js +44 -17
  34. package/dist/transport/errors.d.ts +14 -8
  35. package/dist/transport/errors.js +27 -19
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -43,14 +43,15 @@ its worker set up once under a bundler: see [The MapLibre worker](#the-maplibre-
43
43
  ## Key Features
44
44
 
45
45
  - **Custom Authentication**: Uses Bearer tokens instead of AWS SigV4
46
- - **AWS SDK Commands**: Full access to all AWS Location Service commands
46
+ - **Places Commands**: the seven Amazon Location Places commands from `@aws-sdk/client-geo-places`, plus `VerifyAddressCommand`
47
47
  - **Address Verification**: `verifyAddress(placeId)` returns the one Places result you may store — see [Verifying an address](#verifying-an-address)
48
- - **Data Type Utilities**: Built-in GeoJSON conversion utilities
48
+ - **Data Type Utilities**: GeoJSON converters for the Places responses
49
49
  - **MapLibre Integration**: Adapter for MapLibre GL Geocoder and `createTransformRequest` helper
50
50
  - **Map Style Control**: Fetch and customize map style descriptors — color scheme, points of interest, label language, and terrain, 3D buildings, traffic and more as [plan features](#plan-features)
51
51
  - **Map Language**: Switch map label language client-side with zero API calls
52
52
  - **POI Layer Control**: Toggle point-of-interest categories on/off by layer
53
53
  - **Server Utilities**: `getClientConfig()` with auto-env detection and token caching
54
+ - **Typed Errors**: every failure is a `LocationServiceException` whose `code` is a `LocationServiceErrorCode`
54
55
 
55
56
  ## Quick Start
56
57
 
@@ -73,7 +74,8 @@ export async function getLocationConfig() {
73
74
  Set these environment variables:
74
75
 
75
76
  ```bash
76
- LOCATION_API_URL=https://api.chaosity.cloud
77
+ # The API URL on your application's page in the developer portal
78
+ LOCATION_API_URL=https://your-api-url.example
77
79
  LOCATION_CLIENT_ID=your-client-id
78
80
  LOCATION_CLIENT_SECRET=your-client-secret
79
81
 
@@ -86,7 +88,7 @@ Or pass credentials explicitly:
86
88
 
87
89
  ```typescript
88
90
  const config = await getClientConfig({
89
- apiUrl: 'https://api.chaosity.cloud',
91
+ apiUrl: process.env.MY_API_URL!,
90
92
  clientId: process.env.MY_CLIENT_ID!,
91
93
  clientSecret: process.env.MY_SECRET!,
92
94
  })
@@ -102,10 +104,9 @@ import {
102
104
  type SuggestCommandOutput,
103
105
  } from '@chaosity/location-client'
104
106
 
105
- const client = new GeoPlacesClient({
106
- apiUrl: 'https://api.chaosity.cloud',
107
- token: 'your-bearer-token',
108
- })
107
+ // apiUrl and token as getLocationConfig() above returns them
108
+ const { apiUrl, token } = await getLocationConfig()
109
+ const client = new GeoPlacesClient({ apiUrl, token })
109
110
 
110
111
  const response: SuggestCommandOutput = await client.send(
111
112
  new SuggestCommand({
@@ -172,9 +173,9 @@ if (answer.verified) {
172
173
  because the service would drop `Language`, `PoliticalView` and
173
174
  `AdditionalFeatures`. A repeat verify of the same PlaceId may be answered
174
175
  from the service's own store, and is billed all the same.
175
- - Keep the PlaceId you sent beside the answer. The answer's own `PlaceId` can
176
- differ, and for a unit it does. The service does not accept that one back,
177
- while the one you sent verifies again.
176
+ - The answer's `PlaceId` is the one you sent, for a unit as for a building,
177
+ so a stored verification can be verified, or looked up with
178
+ `GetPlaceCommand`, again by its own `PlaceId`.
178
179
  - `client.verifyAddress(placeId)` is
179
180
  `client.send(new VerifyAddressCommand({ PlaceId: placeId }))`, typed as
180
181
  `VerifyAddressResponse`. `connector.verifyAddress` does the same on the
@@ -399,10 +400,11 @@ const geocoder = new MaplibreGeocoder(geoPlaces, {
399
400
 
400
401
  map.addControl(geocoder, 'top-left')
401
402
 
402
- // The geocoder calls getSuggestions → searchByPlaceId internally.
403
- // The 'result' event fires with the resolved place feature.
404
- geocoder.on('result', (event) => {
405
- console.log('Selected place:', event.result)
403
+ // Picking a suggestion calls searchByPlaceId, and the geocoder emits
404
+ // 'results' with the resolved place on `place`. It emits 'result' only when a
405
+ // typed query is geocoded (forwardGeocode) and one of its features is chosen.
406
+ geocoder.on('results', (event) => {
407
+ if (event.place) console.log('Selected place:', event.place[0])
406
408
  })
407
409
  ```
408
410
 
@@ -417,7 +419,7 @@ Client for executing AWS Location Service commands with Bearer token auth.
417
419
  ```typescript
418
420
  const client = new GeoPlacesClient({
419
421
  apiUrl: string,
420
- token: string,
422
+ token?: string, // at least one of token, getToken and refreshToken
421
423
  getToken?: () => string | undefined, // Optional: dynamic token getter
422
424
  refreshToken?: () => Promise<string | undefined>, // Optional: 401 self-heal
423
425
  })
@@ -453,6 +455,18 @@ nothing, and no retry is sent — a request that is going to fail again is not
453
455
  worth a second round trip. A 403 is never retried: a new token cannot fix an
454
456
  `Origin` the application does not allow.
455
457
 
458
+ **A refused token is not sent again for 30 seconds.** When the API refuses a
459
+ token and `refreshToken` cannot replace it — it rejects with a 401 or 403, or
460
+ returns the same token — the next sends with that token reject with the same
461
+ refusal without a request, and without asking `refreshToken`. A suspended
462
+ application is refused on every route and by its token route alike, so each
463
+ send used to cost a refused request and a refused refresh. A different token
464
+ from `getToken` ends the wait at once. A `refreshToken` that fails with a
465
+ `Retry-After` is held for that long instead. One that fails with nothing to
466
+ say about when to try again — a network fault, or a Server Action's error,
467
+ which reaches the browser without its fields — is asked again on the next
468
+ send, and the refused token is not sent before it.
469
+
456
470
  #### Request options
457
471
 
458
472
  Every call in this package takes the same options object — `client.send`,
@@ -502,6 +516,39 @@ produced one yet raises `InvalidCredentialsException` instead of putting
502
516
  trip spent to be told what you already know. `GeoPlacesClient` asks `refreshToken` first, so a
503
517
  client whose token simply has not arrived yet still works.
504
518
 
519
+ #### Errors
520
+
521
+ Every failure is a `LocationServiceException`. Its `code` is typed
522
+ `LocationServiceErrorCode`: every code the API documents at
523
+ [docs.chaosity.cloud/api/errors](https://docs.chaosity.cloud/api/errors), the
524
+ Amazon Location names it passes through, and the four this package raises for
525
+ a failure that never reached the API (`AbortedException`, `NetworkException`,
526
+ `ServiceException`, `UnknownCommandException`). A code the API adds later
527
+ arrives all the same, so treat an unknown one as you would its status.
528
+
529
+ ```typescript
530
+ import {
531
+ LocationServiceException,
532
+ type LocationServiceErrorCode,
533
+ } from '@chaosity/location-client'
534
+
535
+ try {
536
+ await client.send(command)
537
+ } catch (err) {
538
+ if (!(err instanceof LocationServiceException)) throw err
539
+ const code: LocationServiceErrorCode | (string & {}) = err.code
540
+ if (code === 'RateLimitExceededException') {
541
+ // this application's own rate: wait `err.retryAfterMs`
542
+ } else if (code === 'ApplicationNotActiveException') {
543
+ // new, suspended, or off its plan: see the application in the portal
544
+ }
545
+ }
546
+ ```
547
+
548
+ `message` is the API's own sentence. On `/auth/token` that is its
549
+ `error_description`, so a suspended application's refusal reads
550
+ `Application is not active`.
551
+
505
552
  #### GeoPlaces Adapter
506
553
 
507
554
  Implements the `MaplibreGeocoderApi` interface for use with `@maplibre/maplibre-gl-geocoder`. Methods are called automatically by the geocoder control.
@@ -655,21 +702,45 @@ import { VerifyAddressCommand } from '@chaosity/location-client'
655
702
 
656
703
  #### Data Type Utilities
657
704
 
658
- GeoJSON conversion utilities from `@aws/amazon-location-utilities-datatypes`:
705
+ GeoJSON converters from `@aws/amazon-location-utilities-datatypes`, one per
706
+ Places response that carries positions. A result with no position becomes no
707
+ feature:
708
+
709
+ | Converter | Takes the response of |
710
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
711
+ | `geocodeResponseToFeatureCollection` | `GeocodeCommand` |
712
+ | `reverseGeocodeResponseToFeatureCollection` | `ReverseGeocodeCommand` |
713
+ | `getPlaceResponseToFeatureCollection` | `GetPlaceCommand` |
714
+ | `suggestResponseToFeatureCollection` | `SuggestCommand` with `AdditionalFeatures: ['Core']`. Without it, Suggest results carry no position, and it returns none |
715
+ | `searchTextResponseToFeatureCollection` | `SearchTextCommand` |
716
+ | `searchNearbyResponseToFeatureCollection` | `SearchNearbyCommand` |
659
717
 
660
718
  ```typescript
661
719
  import {
662
- placeToFeatureCollection,
663
- routeToFeatureCollection,
664
- devicePositionsToFeatureCollection,
720
+ SearchTextCommand,
721
+ searchTextResponseToFeatureCollection,
722
+ type SearchTextCommandOutput,
665
723
  } from '@chaosity/location-client'
724
+
725
+ const response: SearchTextCommandOutput = await client.send(
726
+ new SearchTextCommand({
727
+ QueryText: 'coffee',
728
+ // SearchText takes exactly one of BiasPosition, Filter.BoundingBox or Filter.Circle.
729
+ BiasPosition: [-123.1207, 49.2827],
730
+ }),
731
+ )
732
+ const geojson = searchTextResponseToFeatureCollection(response)
666
733
  ```
667
734
 
735
+ Autocomplete has no converter: its results carry no position. The package
736
+ re-exports the rest of that package's converters too, but they take responses
737
+ from other Amazon Location APIs, which this service does not serve.
738
+
668
739
  ### Server Exports (`@chaosity/location-client/server`)
669
740
 
670
741
  #### getClientConfig
671
742
 
672
- Gets a client config with a fresh token. Uses a singleton `TokenProvider` internally — safe to call repeatedly (tokens are cached and refreshed automatically).
743
+ Gets a client config with a fresh token. It keeps one `TokenProvider` per application, for the applications used most recently, so it is safe to call repeatedly: tokens are cached and refreshed automatically.
673
744
 
674
745
  ```typescript
675
746
  import { getClientConfig } from '@chaosity/location-client/server'
@@ -682,6 +753,15 @@ const config = await getClientConfig()
682
753
  const replacement = await getClientConfig({ forceRefresh: true })
683
754
  ```
684
755
 
756
+ A refusal arrives with the API's code and sentence. Where the API refused the
757
+ credentials themselves, the message goes on to say which variables to check,
758
+ and names the client ID. An application that is not active reads
759
+ `Application is not active`, although once the API's authorizer has
760
+ refused it the sentence is the same as for a wrong secret, and the advice then
761
+ says to check both. A refusal is remembered for 30 seconds, and a
762
+ `Retry-After` for as long as it asks: calls in that time reject at once,
763
+ without asking `/auth/token` again.
764
+
685
765
  The return value is **plain data** — no methods, no closures — so it can be
686
766
  returned straight out of a Next.js Server Action to a Client Component. It is
687
767
  therefore a snapshot: the token in it stops working at its `expiresAt`, and the
@@ -738,8 +818,13 @@ which wins over both). `/auth/token` is the one endpoint exempt.
738
818
 
739
819
  A connector configured this way keeps working indefinitely: it holds a live
740
820
  token source, refreshes before expiry, and retries once with a new token if the
741
- API rejects the one it sent. Pass an explicit `token` instead and you opt out of
742
- all of that — it is a fixed string, and it dies at its own `exp`:
821
+ API rejects the one it sent. If the new token is refused too, as a suspended
822
+ application's is, the refusal is remembered for 30 seconds: sends in that time
823
+ reject with it at once, without a data request or a token request. A refresh
824
+ that fails with nothing to say about when to try again, a network fault for
825
+ one, is asked again on the next send, without the refused token first. Pass an
826
+ explicit `token` instead and you opt out of all of that — it is a fixed string,
827
+ and it dies at its own `exp`:
743
828
 
744
829
  ```typescript
745
830
  // Managing credentials yourself: an explicit token source wins outright, and
@@ -47,6 +47,26 @@ function toCarmenFeatures(features) {
47
47
  };
48
48
  });
49
49
  }
50
+ /**
51
+ * The geocoder's `bbox` — `[minX, minY, maxX, maxY]`, the order Amazon's
52
+ * `BoundingBox` takes too — or undefined when it has none, or not four
53
+ * numbers.
54
+ */
55
+ function boundingBox(bbox) {
56
+ return bbox?.length === 4 && bbox.every((v) => typeof v === 'number')
57
+ ? bbox
58
+ : undefined;
59
+ }
60
+ /**
61
+ * Whether `[x, y]` lies in `bbox`, edges included. A box whose `minX` is
62
+ * greater than its `maxX` crosses the antimeridian.
63
+ */
64
+ function insideBox([x, y], bbox) {
65
+ const [minX, minY, maxX, maxY] = bbox;
66
+ if (y < minY || y > maxY)
67
+ return false;
68
+ return minX <= maxX ? x >= minX && x <= maxX : x >= minX || x <= maxX;
69
+ }
50
70
  export class GeoPlaces {
51
71
  constructor(client, map, options = {}) {
52
72
  this.client = client;
@@ -101,9 +121,16 @@ export class GeoPlaces {
101
121
  const converted = geocodeResponseToFeatureCollection(response, {
102
122
  flattenProperties: true,
103
123
  });
124
+ // Geocode takes no box, so the geocoder's `bbox` is applied here, to what
125
+ // comes back (#59). It used to be ignored, and a result picked with Enter
126
+ // could land outside the box the integrator set.
127
+ const bbox = boundingBox(config.bbox);
128
+ const features = bbox
129
+ ? converted.features.filter((f) => insideBox(f.geometry.coordinates, bbox))
130
+ : converted.features;
104
131
  const result = {
105
132
  type: 'FeatureCollection',
106
- features: toCarmenFeatures(converted.features),
133
+ features: toCarmenFeatures(features),
107
134
  };
108
135
  log('forwardGeocode returned %d results', result.features.length);
109
136
  return result;
@@ -131,38 +158,47 @@ export class GeoPlaces {
131
158
  }
132
159
  async getSuggestions(config) {
133
160
  log('getSuggestions query=%s', config.query);
161
+ // Suggest takes exactly ONE of BiasPosition, Filter.BoundingBox and
162
+ // Filter.Circle, and refuses a request with two: 400 "Exactly one of the
163
+ // following fields must be set". This sent a bias always, and the box
164
+ // beside it whenever the geocoder carried one, so a `bbox` made every
165
+ // suggestion fail (#59). The box, when there is one, is the bias.
166
+ const bbox = boundingBox(config.bbox);
134
167
  const center = this.map.getCenter();
135
- const biasPosition = config.proximity && config.proximity.length >= 2
136
- ? [config.proximity[0], config.proximity[1]]
137
- : [center.lng, center.lat];
168
+ const biasPosition = bbox
169
+ ? undefined
170
+ : config.proximity && config.proximity.length >= 2
171
+ ? [config.proximity[0], config.proximity[1]]
172
+ : [center.lng, center.lat];
173
+ const countries = config.countries
174
+ ? Array.isArray(config.countries)
175
+ ? config.countries
176
+ : config.countries.split(',')
177
+ : undefined;
138
178
  const commandInput = {
139
179
  QueryText: config.query,
140
- BiasPosition: biasPosition,
180
+ ...(biasPosition ? { BiasPosition: biasPosition } : {}),
141
181
  MaxResults: config.limit || 5,
142
182
  Language: this.normalizeLanguage(config.language),
143
183
  // No AdditionalFeatures (#3 / T19).
144
184
  //
145
185
  // This used to send `[Core]`, which put every keystroke in the Core
146
- // bucket at $0.50/1k. The only thing Core adds to a Suggest response is
147
- // `Highlights`, and this adapter reads `Title` and `Place.PlaceId` —
148
- // nothing else. Verified against Amazon Location on 2026-08-25:
186
+ // bucket at $0.50/1k. What Core adds to a Suggest response is
187
+ // `Highlights` and the place's `Position` (without it,
188
+ // `suggestResponseToFeatureCollection` finds no feature: measured
189
+ // 2026-09-29), and this adapter reads neither — only `Title` and
190
+ // `Place.PlaceId`. Verified against Amazon Location on 2026-08-25:
149
191
  //
150
192
  // with [Core] -> bucket Core keys: Title, ..., Place, Highlights
151
193
  // without -> bucket Label keys: Title, ..., Place
152
194
  //
153
195
  // Same two fields, $0.20/1k instead of $0.50. Suggest fires per
154
196
  // keystroke, so it is the highest-volume call the library makes.
155
- ...(config.countries || config.bbox
197
+ ...(bbox || countries
156
198
  ? {
157
199
  Filter: {
158
- ...(config.countries
159
- ? {
160
- IncludeCountries: Array.isArray(config.countries)
161
- ? config.countries
162
- : config.countries.split(','),
163
- }
164
- : {}),
165
- ...(config.bbox ? { BoundingBox: config.bbox } : {}),
200
+ ...(bbox ? { BoundingBox: bbox } : {}),
201
+ ...(countries ? { IncludeCountries: countries } : {}),
166
202
  },
167
203
  }
168
204
  : {}),
@@ -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
  /**
@@ -1,6 +1,7 @@
1
1
  import debug from 'debug';
2
2
  import { LocationServiceException } from '../errors/LocationServiceException.js';
3
3
  import { requestJson } from '../transport/http.js';
4
+ import { TokenHold, isTokenRefusal } from './tokenHold.js';
4
5
  import { TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from './tokenRefresh.js';
5
6
  const log = debug('location-client:auth');
6
7
  /**
@@ -33,6 +34,8 @@ const log = debug('location-client:auth');
33
34
  */
34
35
  export class TokenProvider {
35
36
  constructor(config) {
37
+ /** A refusal or a Retry-After from `/auth/token`, until it lapses (#38). */
38
+ this.hold = new TokenHold();
36
39
  // Runtime check: prevent usage in browser
37
40
  if (typeof window !== 'undefined') {
38
41
  throw new Error('TokenProvider cannot be used in browser environments. ' +
@@ -54,6 +57,15 @@ export class TokenProvider {
54
57
  expiresAt: this.cachedExpiresAt,
55
58
  };
56
59
  }
60
+ // The endpoint refused these credentials, or asked us to wait, a moment
61
+ // ago: answer with that rather than ask again (#38). Forced or not — a
62
+ // forced refresh asks for a different token, and these credentials will
63
+ // not get one until the hold lapses.
64
+ const held = this.hold.check()?.error;
65
+ if (held) {
66
+ log('Token request held: %s', held.message);
67
+ throw held;
68
+ }
57
69
  // If token fetch is already in progress, wait for it
58
70
  if (this.tokenPromise) {
59
71
  log('Token fetch in progress, waiting for existing request...');
@@ -93,16 +105,32 @@ export class TokenProvider {
93
105
  async fetchToken() {
94
106
  const { clientId, clientSecret, apiUrl } = this.config;
95
107
  const credentials = btoa(`${clientId}:${clientSecret}`);
96
- const data = await requestJson(`${apiUrl}/auth/token`, {
97
- method: 'POST',
98
- headers: {
99
- Authorization: `Basic ${credentials}`,
100
- 'Content-Type': 'application/x-www-form-urlencoded',
101
- },
102
- body: new URLSearchParams({
103
- grant_type: 'client_credentials',
104
- }).toString(),
105
- }, { retry: { maxAttempts: 3 } });
108
+ let data;
109
+ try {
110
+ data = await requestJson(`${apiUrl}/auth/token`, {
111
+ method: 'POST',
112
+ headers: {
113
+ Authorization: `Basic ${credentials}`,
114
+ 'Content-Type': 'application/x-www-form-urlencoded',
115
+ },
116
+ body: new URLSearchParams({
117
+ grant_type: 'client_credentials',
118
+ }).toString(),
119
+ }, { retry: { maxAttempts: 3 } });
120
+ }
121
+ catch (error) {
122
+ // Remembered, so the next call is answered without a request (#38), and
123
+ // a Retry-After is honoured across calls rather than only within this
124
+ // one (#63).
125
+ this.hold.remember(error);
126
+ // A refusal is about the credentials, so every token they minted is
127
+ // refused too — a suspended application's on its next use, a rotated
128
+ // secret's at once. Never hand the cached one out again. A Retry-After
129
+ // says nothing about it, and keeps it.
130
+ if (isTokenRefusal(error))
131
+ this.clearCache();
132
+ throw error;
133
+ }
106
134
  if (!data.access_token) {
107
135
  throw new LocationServiceException({
108
136
  code: 'InvalidCredentialsException',
@@ -110,6 +138,7 @@ export class TokenProvider {
110
138
  details: { source: 'client' },
111
139
  });
112
140
  }
141
+ this.hold.forget();
113
142
  this.cachedToken = data.access_token;
114
143
  // The token's own `exp` claim first — it is the only value that cannot
115
144
  // 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,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
+ }