@alxia/redis 0.1.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/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/cache-store.d.ts +18 -0
- package/dist/cache-store.d.ts.map +1 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/idempotency.d.ts +47 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +253 -0
- package/dist/index.js.map +13 -0
- package/dist/store.d.ts +17 -0
- package/dist/store.d.ts.map +1 -0
- package/docs/README.md +18 -0
- package/docs/guide/caches-and-locks.md +204 -0
- package/docs/guide/connecting.md +165 -0
- package/docs/guide/idempotency.md +255 -0
- package/docs/guide/rate-limits.md +198 -0
- package/docs/guide/response-cache.md +129 -0
- package/docs/guide/testing.md +135 -0
- package/docs/roadmap.md +46 -0
- package/docs/troubleshooting.md +613 -0
- package/package.json +70 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Each entry is headed by the text you see: a TypeScript error, an exception
|
|
4
|
+
at startup, an exception in the log beside a `500 {"error":"internal"}`, or
|
|
5
|
+
the response a client got. `@alxia/redis` throws nothing of its own: the
|
|
6
|
+
messages are `@nxgt/redis`'s, `@nxgt/redis-guard`'s and Bun's, and it lets
|
|
7
|
+
each through. What prints nothing is under [Traps](#traps), by symptom.
|
|
8
|
+
|
|
9
|
+
**Install and types**
|
|
10
|
+
|
|
11
|
+
- [`error TS2307: Cannot find module '@alxia/rate-limit' or its corresponding type declarations.`](#error-ts2307-cannot-find-module-alxiarate-limit-or-its-corresponding-type-declarations)
|
|
12
|
+
- [`error TS2307: Cannot find module '@alxia/cache' or its corresponding type declarations.`](#error-ts2307-cannot-find-module-alxiacache-or-its-corresponding-type-declarations)
|
|
13
|
+
- [`Property 'cache' does not exist on type '… & RedisContext<…> …'`](#property-cache-does-not-exist-on-type---rediscontext-)
|
|
14
|
+
- [`Property '…' does not exist on type 'CacheControls'`](#property--does-not-exist-on-type-cachecontrols)
|
|
15
|
+
|
|
16
|
+
**Startup**
|
|
17
|
+
|
|
18
|
+
- [`TypeError: defineIdempotency: "…" has a ttl of …; it is a whole number of seconds, and must be at least 1`](#typeerror-defineidempotency--has-a-ttl-of--it-is-a-whole-number-of-seconds-and-must-be-at-least-1)
|
|
19
|
+
- [`TypeError: defineIdempotency: "…" has a lease of …; it is a whole number of milliseconds, and must be at least 1`](#typeerror-defineidempotency--has-a-lease-of--it-is-a-whole-number-of-milliseconds-and-must-be-at-least-1)
|
|
20
|
+
- [`TypeError: defineIdempotency: an idempotent operation needs a name, for its keys`](#typeerror-defineidempotency-an-idempotent-operation-needs-a-name-for-its-keys)
|
|
21
|
+
- [`RedisError: Connection closed`](#rediserror-connection-closed)
|
|
22
|
+
- [`TypeError: connectRedis: this URI is already connected with other options. Pass the same options everywhere, or close the first connection.`](#typeerror-connectredis-this-uri-is-already-connected-with-other-options-pass-the-same-options-everywhere-or-close-the-first-connection)
|
|
23
|
+
|
|
24
|
+
**Runtime: a 500, with this in the log**
|
|
25
|
+
|
|
26
|
+
- [`TypeError: defineRateLimit: "…" has a burst of … and a per of …ms; burst × per must be at most 9007199254740 for the script to count exactly`](#typeerror-defineratelimit--has-a-burst-of--and-a-per-of-ms-burst--per-must-be-at-most-9007199254740-for-the-script-to-count-exactly)
|
|
27
|
+
- [`TypeError: defineRateLimit: "…" would take longer than ten years to refill from empty (burst × per ÷ limit); check that per is in milliseconds`](#typeerror-defineratelimit--would-take-longer-than-ten-years-to-refill-from-empty-burst--per--limit-check-that-per-is-in-milliseconds)
|
|
28
|
+
- [`TypeError: run on "…": wait is a whole number of milliseconds, 0 or more`](#typeerror-run-on--wait-is-a-whole-number-of-milliseconds-0-or-more)
|
|
29
|
+
- [`RedisError: Connection has failed`](#rediserror-connection-has-failed)
|
|
30
|
+
- [`GuardError: run on "…": the key was taken from this run before it finished (forgotten, or its lease of …ms went unrenewed), so a repeat may have run it too; its result was not stored`](#guarderror-run-on--the-key-was-taken-from-this-run-before-it-finished-forgotten-or-its-lease-of-ms-went-unrenewed-so-a-repeat-may-have-run-it-too-its-result-was-not-stored)
|
|
31
|
+
- [``RedisError: The lock "…" is held by somebody else, and this call did not wait for it — pass `wait` to keep trying``](#rediserror-the-lock--is-held-by-somebody-else-and-this-call-did-not-wait-for-it--pass-wait-to-keep-trying)
|
|
32
|
+
- [`RedisError: The lock "…" expired before its work finished: it ran longer than the …ms ttl, so it may have run beside another holder`](#rediserror-the-lock--expired-before-its-work-finished-it-ran-longer-than-the-ms-ttl-so-it-may-have-run-beside-another-holder)
|
|
33
|
+
- [`TypeError: undefined is not an object (evaluating 'cache.…')`](#typeerror-undefined-is-not-an-object-evaluating-cache)
|
|
34
|
+
|
|
35
|
+
**Calling the store yourself**
|
|
36
|
+
|
|
37
|
+
- [`TypeError: defineRateLimit: "…" has a per of …; it is a whole number of milliseconds, and must be at least 1`](#typeerror-defineratelimit--has-a-per-of--it-is-a-whole-number-of-milliseconds-and-must-be-at-least-1)
|
|
38
|
+
- [`TypeError: defineRateLimit: "…" has a limit of …; it is a whole number of requests, and must be at least 1`](#typeerror-defineratelimit--has-a-limit-of--it-is-a-whole-number-of-requests-and-must-be-at-least-1)
|
|
39
|
+
|
|
40
|
+
**Responses**
|
|
41
|
+
|
|
42
|
+
- [`400 {"error":"idempotency_key_missing"}`](#400-erroridempotency_key_missing)
|
|
43
|
+
- [`400 {"error":"idempotency_key_invalid"}`](#400-erroridempotency_key_invalid)
|
|
44
|
+
- [`409 {"error":"idempotency_in_progress","retryAfter":10}`](#409-erroridempotency_in_progressretryafter10)
|
|
45
|
+
- [`422 {"error":"idempotency_key_reused"}`](#422-erroridempotency_key_reused)
|
|
46
|
+
|
|
47
|
+
**Traps**
|
|
48
|
+
|
|
49
|
+
- [A rate limit lets more than `limit` requests through](#a-rate-limit-lets-more-than-limit-requests-through)
|
|
50
|
+
- [Two limits count each other's requests](#two-limits-count-each-others-requests)
|
|
51
|
+
- [A `401` or a `429` is replayed, with `Idempotent-Replayed: true`, after the client fixed it](#a-401-or-a-429-is-replayed-with-idempotent-replayed-true-after-the-client-fixed-it)
|
|
52
|
+
- [One client gets another client's response](#one-client-gets-another-clients-response)
|
|
53
|
+
|
|
54
|
+
## Install and types
|
|
55
|
+
|
|
56
|
+
### `error TS2307: Cannot find module '@alxia/rate-limit' or its corresponding type declarations.`
|
|
57
|
+
|
|
58
|
+
**When:** typechecking an app that uses `@alxia/redis` without
|
|
59
|
+
`@alxia/rate-limit` installed, with `skipLibCheck` off. The error points
|
|
60
|
+
into `node_modules/@alxia/redis/dist/store.d.ts`.
|
|
61
|
+
|
|
62
|
+
**Why:** `@alxia/rate-limit` is an optional peer: `redisStore` returns its
|
|
63
|
+
`RateLimitStore` type. Nothing loads it at run time, so the app runs; only
|
|
64
|
+
`tsc` reads the declaration.
|
|
65
|
+
|
|
66
|
+
**Fix:** install it, or turn `skipLibCheck` on:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
bun add @alxia/rate-limit
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### `error TS2307: Cannot find module '@alxia/cache' or its corresponding type declarations.`
|
|
73
|
+
|
|
74
|
+
**When:** the same, for `@alxia/cache`, pointing into
|
|
75
|
+
`node_modules/@alxia/redis/dist/cache-store.d.ts`.
|
|
76
|
+
|
|
77
|
+
**Why:** `redisCacheStore` returns `@alxia/cache`'s `CacheStore` type.
|
|
78
|
+
|
|
79
|
+
**Fix:**
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
bun add @alxia/cache
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `Property 'cache' does not exist on type '… & RedisContext<…> …'`
|
|
86
|
+
|
|
87
|
+
**When:** a route behind `redis()` reads its typed caches as `cache` —
|
|
88
|
+
`({ cache }) => cache.users.remember(…)`, the name they had before
|
|
89
|
+
`caches`.
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
error TS2339: Property 'cache' does not exist on type 'Omit<BaseContext, "reply"> & Empty & RedisContext<{ readonly users: CacheDefinition<string, ZodObject<…>>; }> & { …; }'.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A `derive(({ cache }) => ({ caches: cache }))` written to keep them apart
|
|
96
|
+
from `@alxia/cache`'s `cache` fails the same way, on the `derive`:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
error TS2339: Property 'cache' does not exist on type 'BaseContext & Empty & RedisContext<{ readonly users: CacheDefinition<string, ZodObject<…>>; }>'.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Why:** `redis()` puts its caches in the context as `caches`, so that they
|
|
103
|
+
never meet `@alxia/cache`'s `cache`.
|
|
104
|
+
|
|
105
|
+
**Fix:** read `caches`, and drop the `derive`:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
alxia()
|
|
109
|
+
.use(redis(connection.client, { caches: { users } }))
|
|
110
|
+
.get('/users/:id', async ({ caches, params, reply }) =>
|
|
111
|
+
reply.ok(await caches.users.remember(params.id, () => loadUser(params.id))),
|
|
112
|
+
);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `Property '…' does not exist on type 'CacheControls'`
|
|
116
|
+
|
|
117
|
+
**When:** the same old name, on a route behind both `redis()` and
|
|
118
|
+
`@alxia/cache`'s `cache()`.
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
error TS2339: Property 'users' does not exist on type 'CacheControls'.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Why:** `ctx.cache` is the response cache's `{ tag, skip }`; the Redis
|
|
125
|
+
caches are `ctx.caches`.
|
|
126
|
+
|
|
127
|
+
**Fix:** `caches.users`, as above
|
|
128
|
+
([With `@alxia/cache`](guide/caches-and-locks.md#with-alxiacache)).
|
|
129
|
+
|
|
130
|
+
## Startup
|
|
131
|
+
|
|
132
|
+
### `TypeError: defineIdempotency: "…" has a ttl of …; it is a whole number of seconds, and must be at least 1`
|
|
133
|
+
|
|
134
|
+
**When:** calling `idempotency(client, { name, ttl })` with a `ttl` of 0,
|
|
135
|
+
below 0, or with a fraction — often milliseconds divided by 1000.
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
TypeError: defineIdempotency: "payments" has a ttl of 1.5; it is a whole number of seconds, and must be at least 1
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Why:** `ttl` is how long a finished response is replayed, in whole
|
|
142
|
+
**seconds**, Redis's own unit for an expiry.
|
|
143
|
+
|
|
144
|
+
**Fix:**
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
idempotency(connection.client, { name: 'payments', ttl: 86_400 }); // a day
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### `TypeError: defineIdempotency: "…" has a lease of …; it is a whole number of milliseconds, and must be at least 1`
|
|
151
|
+
|
|
152
|
+
**When:** `lease` is 0, below 0, or has a fraction.
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
TypeError: defineIdempotency: "payments" has a lease of 500.5; it is a whole number of milliseconds, and must be at least 1
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Why:** `lease` is in whole **milliseconds** — unlike `ttl`.
|
|
159
|
+
|
|
160
|
+
**Fix:**
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
idempotency(connection.client, { name: 'payments', lease: 30_000 }); // 30 s
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### `TypeError: defineIdempotency: an idempotent operation needs a name, for its keys`
|
|
167
|
+
|
|
168
|
+
**When:** `idempotency(client, { name: '' })` — a name read from an
|
|
169
|
+
environment variable that is not set, say.
|
|
170
|
+
|
|
171
|
+
**Why:** `name` prefixes every key it stores; an empty one would share keys
|
|
172
|
+
with anything else in the Redis.
|
|
173
|
+
|
|
174
|
+
**Fix:** give it a fixed name:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
idempotency(connection.client, { name: 'payments' });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `RedisError: Connection closed`
|
|
181
|
+
|
|
182
|
+
**When:** `await connectRedis(url)` at startup, with no Redis listening at
|
|
183
|
+
`url`. With Bun's default reconnects it rejects after about half a
|
|
184
|
+
minute; with `autoReconnect: false`, at once.
|
|
185
|
+
|
|
186
|
+
```text
|
|
187
|
+
RedisError [ERR_REDIS_CONNECTION_CLOSED]: Connection closed
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Why:** Bun's client could not reach the server. `REDIS_URL` is unset —
|
|
191
|
+
Bun then tries `localhost:6379` — or points at the wrong host or port, or
|
|
192
|
+
the server is not up yet.
|
|
193
|
+
|
|
194
|
+
**Fix:** check the URL, and fail fast where a startup check should:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!, { autoReconnect: false });
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
See [Connecting](guide/connecting.md#when-redis-is-down), and the next
|
|
201
|
+
entry before you mix options.
|
|
202
|
+
|
|
203
|
+
### `TypeError: connectRedis: this URI is already connected with other options. Pass the same options everywhere, or close the first connection.`
|
|
204
|
+
|
|
205
|
+
**When:** a startup check calls `connectRedis(url, { autoReconnect: false })`
|
|
206
|
+
and the app later calls `connectRedis(url)` for the same URL.
|
|
207
|
+
|
|
208
|
+
**Why:** `connectRedis` shares one client per URI, and refuses to hand out
|
|
209
|
+
a client opened with other options than those asked for.
|
|
210
|
+
|
|
211
|
+
**Fix:** close the check before the app connects, or pass the same options
|
|
212
|
+
everywhere:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const check = await connectRedis(Bun.env['REDIS_URL']!, { autoReconnect: false });
|
|
216
|
+
await check.close();
|
|
217
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Runtime: a 500, with this in the log
|
|
221
|
+
|
|
222
|
+
Each of these makes the request answer `500 {"error":"internal"}`; the
|
|
223
|
+
message is in the app's log.
|
|
224
|
+
|
|
225
|
+
### `TypeError: defineRateLimit: "…" has a burst of … and a per of …ms; burst × per must be at most 9007199254740 for the script to count exactly`
|
|
226
|
+
|
|
227
|
+
**When:** every counted request, with a large `limit` over a long
|
|
228
|
+
`windowMs`: `limit × windowMs` above 9,007,199,254,740 (about 9e12) — a
|
|
229
|
+
million a year. `rateLimit` starts without complaint.
|
|
230
|
+
|
|
231
|
+
```text
|
|
232
|
+
TypeError: defineRateLimit: "api:1000000/31536000000" has a burst of 1000000 and a per of 31536000000ms; burst × per must be at most 9007199254740 for the script to count exactly
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Why:** the script counts in exact integers; `limit × windowMs` past that
|
|
236
|
+
bound would lose precision. `redisStore` hands each policy to
|
|
237
|
+
`@nxgt/redis-guard` when it first counts under it, and a refused policy is
|
|
238
|
+
not kept, so it is checked again, and refused again, on each request.
|
|
239
|
+
|
|
240
|
+
**Fix:** state the same rate over a shorter window:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
rateLimit({ limit: 2_740, windowMs: 86_400_000, store: redisStore(connection.client, { name: 'api' }) }); // ~a million a year
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### `TypeError: defineRateLimit: "…" would take longer than ten years to refill from empty (burst × per ÷ limit); check that per is in milliseconds`
|
|
247
|
+
|
|
248
|
+
**When:** every counted request, with a `windowMs` above 315,360,000,000
|
|
249
|
+
(ten 365-day years) while `limit × windowMs` is still at most
|
|
250
|
+
9,007,199,254,740 — so a `limit` of 28 or less. Past that, the entry
|
|
251
|
+
above is hit first.
|
|
252
|
+
|
|
253
|
+
```text
|
|
254
|
+
TypeError: defineRateLimit: "api:1/315360000001" would take longer than ten years to refill from empty (burst × per ÷ limit); check that per is in milliseconds
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Why:** `redisStore` sets the guard's burst to `limit`, so the time to
|
|
258
|
+
refill from empty is `windowMs` itself. The guard refuses one over ten
|
|
259
|
+
years: it is far more often a window written in the wrong unit than a
|
|
260
|
+
limit anyone means. As above, nothing is checked at startup, and the
|
|
261
|
+
refused policy is checked again on each request.
|
|
262
|
+
|
|
263
|
+
**Fix:** `windowMs` in milliseconds, at most ten years:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
rateLimit({ limit: 1, windowMs: 365 * 86_400_000, store: redisStore(connection.client, { name: 'trial' }) }); // once a year
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
For "once, ever", a rate limit is the wrong tool: record that it happened.
|
|
270
|
+
|
|
271
|
+
### `TypeError: run on "…": wait is a whole number of milliseconds, 0 or more`
|
|
272
|
+
|
|
273
|
+
**When:** every guarded request, when `idempotency` was given a `wait`
|
|
274
|
+
with a fraction or below 0.
|
|
275
|
+
|
|
276
|
+
```text
|
|
277
|
+
TypeError: run on "payments": wait is a whole number of milliseconds, 0 or more
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Why:** `wait` is checked when it is used, not when the plugin is made.
|
|
281
|
+
|
|
282
|
+
**Fix:**
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
idempotency(connection.client, { name: 'payments', wait: 2_000 });
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### `RedisError: Connection has failed`
|
|
289
|
+
|
|
290
|
+
**When:** any request that reaches Redis while it is unreachable — a
|
|
291
|
+
counted request, a guarded route, a `caches.<name>` or a `lock`, or a call
|
|
292
|
+
to the response cache's `invalidate` or `invalidateTag`. The first command
|
|
293
|
+
to fail once Bun's client has given up reconnecting logs
|
|
294
|
+
`Max reconnection attempts reached`; the calls after it log
|
|
295
|
+
`Connection has failed`:
|
|
296
|
+
|
|
297
|
+
```text
|
|
298
|
+
RedisError [ERR_REDIS_CONNECTION_CLOSED]: Max reconnection attempts reached
|
|
299
|
+
RedisError: Connection has failed
|
|
300
|
+
code: "ERR_REDIS_CONNECTION_CLOSED"
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**Why:** `@alxia/redis` does not catch Redis's errors: a rate limit does
|
|
304
|
+
not let the request through uncounted, and an idempotent route does not
|
|
305
|
+
run unguarded. The response cache is the exception, as `@alxia/cache`
|
|
306
|
+
decides it: a cached route whose store cannot answer runs and answers
|
|
307
|
+
`X-Cache: MISS`, with the outage's first error logged — only its invalidations
|
|
308
|
+
reject.
|
|
309
|
+
|
|
310
|
+
**Fix:** bring Redis back. Bun's client reconnects on its own while it is
|
|
311
|
+
still retrying — a short outage of a few seconds recovers without a
|
|
312
|
+
restart — but once it has logged `Max reconnection attempts reached`, do
|
|
313
|
+
not count on the same client coming back: `connection.ping()` tells you
|
|
314
|
+
whether it answers, and restarting the process is the way back that is
|
|
315
|
+
sure.
|
|
316
|
+
|
|
317
|
+
### `GuardError: run on "…": the key was taken from this run before it finished (forgotten, or its lease of …ms went unrenewed), so a repeat may have run it too; its result was not stored`
|
|
318
|
+
|
|
319
|
+
**When:** a guarded route that blocks the event loop longer than `lease`
|
|
320
|
+
(10 s by default) — a synchronous loop, `Bun.sleepSync`, a large
|
|
321
|
+
synchronous parse.
|
|
322
|
+
|
|
323
|
+
```text
|
|
324
|
+
GuardError: run on "payments": the key was taken from this run before it finished (forgotten, or its lease of 10000ms went unrenewed), so a repeat may have run it too; its result was not stored
|
|
325
|
+
definition: "payments",
|
|
326
|
+
code: "LEASE_LOST"
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Why:** the lease is renewed by a timer every third of `lease`; while the
|
|
330
|
+
event loop is blocked, the timer cannot fire. Once the lease lapsed, a
|
|
331
|
+
repeat could take the key and run the route again — so the route may have
|
|
332
|
+
run twice, and neither response is kept.
|
|
333
|
+
|
|
334
|
+
**Fix:** yield inside long synchronous work, or move it to a `Worker`; or
|
|
335
|
+
raise `lease` above the longest block:
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
idempotency(connection.client, { name: 'reports', lease: 60_000 });
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### ``RedisError: The lock "…" is held by somebody else, and this call did not wait for it — pass `wait` to keep trying``
|
|
342
|
+
|
|
343
|
+
**When:** `lock(key, work)` while another request or process holds `key`.
|
|
344
|
+
With `wait`, the message ends `and …ms was not long enough to wait for it`.
|
|
345
|
+
|
|
346
|
+
```text
|
|
347
|
+
RedisError: The lock "invoices" is held by somebody else, and this call did not wait for it — pass `wait` to keep trying
|
|
348
|
+
key: "lock:invoices",
|
|
349
|
+
code: "LOCK_HELD"
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Why:** a lock refuses at once by default. Nothing ran.
|
|
353
|
+
|
|
354
|
+
**Fix:** wait for it, or answer the refusal yourself
|
|
355
|
+
([Locks](guide/caches-and-locks.md#locks)):
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
await lock('invoices', sendInvoices, { wait: 5_000 });
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### `RedisError: The lock "…" expired before its work finished: it ran longer than the …ms ttl, so it may have run beside another holder`
|
|
362
|
+
|
|
363
|
+
**When:** `work` took longer than the lock's `ttl` (30 s by default).
|
|
364
|
+
|
|
365
|
+
```text
|
|
366
|
+
RedisError: The lock "lost" expired before its work finished: it ran longer than the 100ms ttl, so it may have run beside another holder
|
|
367
|
+
key: "lock:lost",
|
|
368
|
+
code: "LOCK_LOST"
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**Why:** Redis dropped the lock at `ttl`, so another holder may have taken
|
|
372
|
+
it while `work` still ran. `work` did finish; its result is lost to the
|
|
373
|
+
throw.
|
|
374
|
+
|
|
375
|
+
**Fix:** size `ttl` above the slowest run you accept:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
await lock('invoices', sendInvoices, { ttl: 120_000 });
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### `TypeError: undefined is not an object (evaluating 'cache.…')`
|
|
382
|
+
|
|
383
|
+
**When:** the old name at run time — the app runs without a typecheck, so
|
|
384
|
+
the [type error](#property-cache-does-not-exist-on-type---rediscontext-)
|
|
385
|
+
never showed. The route answers `500 {"error":"internal"}`:
|
|
386
|
+
|
|
387
|
+
```text
|
|
388
|
+
TypeError: undefined is not an object (evaluating 'cache.users') ← redis() alone
|
|
389
|
+
TypeError: undefined is not an object (evaluating 'cache.users.remember') ← with @alxia/cache's cache()
|
|
390
|
+
TypeError: undefined is not an object (evaluating 'caches.users') ← with the old derive
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
**Why:** the Redis caches are `ctx.caches`. Nothing else in the context is
|
|
394
|
+
`cache` but `@alxia/cache`'s controls, and the old
|
|
395
|
+
`derive(({ cache }) => ({ caches: cache }))` now replaces the real `caches`
|
|
396
|
+
with `undefined`.
|
|
397
|
+
|
|
398
|
+
**Fix:** read `caches.<name>`, drop the `derive`, and typecheck:
|
|
399
|
+
`tsc --noEmit` finds every place.
|
|
400
|
+
|
|
401
|
+
## Calling the store yourself
|
|
402
|
+
|
|
403
|
+
A policy `rateLimit` would refuse at startup still reaches the store when
|
|
404
|
+
you call its `consume` directly; the promise rejects with the guard's
|
|
405
|
+
message.
|
|
406
|
+
|
|
407
|
+
### `TypeError: defineRateLimit: "…" has a per of …; it is a whole number of milliseconds, and must be at least 1`
|
|
408
|
+
|
|
409
|
+
**When:** `consume` rejects with it, on every call, when you call
|
|
410
|
+
`redisStore`'s `consume` yourself with a `windowMs` that is not a whole
|
|
411
|
+
number, or below 1.
|
|
412
|
+
|
|
413
|
+
```text
|
|
414
|
+
TypeError: defineRateLimit: "api:5/1.5" has a per of 1.5; it is a whole number of milliseconds, and must be at least 1
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
**Why:** `redisStore` hands each `limit`/`windowMs` to `@nxgt/redis-guard`
|
|
418
|
+
the first time it counts under it, and the guard counts in whole
|
|
419
|
+
milliseconds. `rateLimit` never passes such a value: it refuses it at
|
|
420
|
+
startup, with [`TypeError: rateLimit: windowMs must be a whole number of 1 or more, not …`](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-).
|
|
421
|
+
|
|
422
|
+
**Fix:** a whole number of milliseconds, at least 1:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
import { redisStore } from '@alxia/redis';
|
|
426
|
+
import { connectRedis } from '@nxgt/redis';
|
|
427
|
+
|
|
428
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
429
|
+
const store = redisStore(connection.client, { name: 'api' });
|
|
430
|
+
const key = '203.0.113.7';
|
|
431
|
+
|
|
432
|
+
await store.consume(key, { limit: 5, windowMs: 1_500 });
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### `TypeError: defineRateLimit: "…" has a limit of …; it is a whole number of requests, and must be at least 1`
|
|
436
|
+
|
|
437
|
+
**When:** `consume` rejects with it, on every call, when you call
|
|
438
|
+
`redisStore`'s `consume` yourself with a `limit` of 0, below 0, or with a
|
|
439
|
+
fraction.
|
|
440
|
+
|
|
441
|
+
```text
|
|
442
|
+
TypeError: defineRateLimit: "api:0/1000" has a limit of 0; it is a whole number of requests, and must be at least 1
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
**Why:** a limit of 0 would refuse everything; the guard refuses to count
|
|
446
|
+
it. `rateLimit` never passes such a value: it refuses it at startup, with
|
|
447
|
+
[`TypeError: rateLimit: limit must be a whole number of 1 or more, not …`](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-).
|
|
448
|
+
To shut a route, answer it yourself.
|
|
449
|
+
|
|
450
|
+
**Fix:** a whole number, at least 1:
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
import { redisStore } from '@alxia/redis';
|
|
454
|
+
import { connectRedis } from '@nxgt/redis';
|
|
455
|
+
|
|
456
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
457
|
+
const store = redisStore(connection.client, { name: 'api' });
|
|
458
|
+
const key = '203.0.113.7';
|
|
459
|
+
|
|
460
|
+
await store.consume(key, { limit: 1, windowMs: 60_000 });
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## Responses
|
|
464
|
+
|
|
465
|
+
What a client of a route behind `idempotency` may get back.
|
|
466
|
+
|
|
467
|
+
### `400 {"error":"idempotency_key_missing"}`
|
|
468
|
+
|
|
469
|
+
**When:** a guarded method without the key header, with `required: true`.
|
|
470
|
+
|
|
471
|
+
**Fix (client):** send one, new for each operation and the same on each
|
|
472
|
+
retry of it:
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
const key = crypto.randomUUID();
|
|
476
|
+
await fetch('/payments', { method: 'POST', headers: { 'idempotency-key': key }, body });
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
If the key travels under another header, set `header` on the server.
|
|
480
|
+
|
|
481
|
+
### `400 {"error":"idempotency_key_invalid"}`
|
|
482
|
+
|
|
483
|
+
**When:** the key is empty, longer than 255 characters, or has a space or
|
|
484
|
+
any character outside printable ASCII — an accented letter, a newline.
|
|
485
|
+
|
|
486
|
+
**Why:** the key is stored in a Redis key name and compared byte for byte;
|
|
487
|
+
it is refused rather than altered.
|
|
488
|
+
|
|
489
|
+
**Fix (client):** a UUID, or any token of printable ASCII up to 255
|
|
490
|
+
characters:
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
headers.set('idempotency-key', crypto.randomUUID());
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### `409 {"error":"idempotency_in_progress","retryAfter":10}`
|
|
497
|
+
|
|
498
|
+
**When:** a repeat of a key whose first request is still running —
|
|
499
|
+
usually a client that retried on its own timeout, or a double click.
|
|
500
|
+
|
|
501
|
+
**Why:** the first request holds the key while it runs. `retryAfter` (and
|
|
502
|
+
`Retry-After`) is when its lease would lapse unless renewed: 10 seconds by
|
|
503
|
+
default, however short the route is.
|
|
504
|
+
|
|
505
|
+
**Fix:** the client retries the same request after `Retry-After` and gets
|
|
506
|
+
the replay. Or let the server wait for the first request instead:
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
idempotency(connection.client, { name: 'payments', wait: 2_000 });
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### `422 {"error":"idempotency_key_reused"}`
|
|
513
|
+
|
|
514
|
+
**When:** a key already used is sent with another method, path, query or
|
|
515
|
+
body.
|
|
516
|
+
|
|
517
|
+
**Why:** the first request's method, path, query and raw body are hashed
|
|
518
|
+
with the key; a repeat must be the same request. A client that reuses one
|
|
519
|
+
key for every request, or serialises the same body differently on a retry
|
|
520
|
+
(key order, whitespace), hits this.
|
|
521
|
+
|
|
522
|
+
**Fix (client):** a new key for each new operation, and the exact same
|
|
523
|
+
bytes on each retry of it — serialise the body once, before the first
|
|
524
|
+
attempt:
|
|
525
|
+
|
|
526
|
+
```ts
|
|
527
|
+
const body = JSON.stringify(payment);
|
|
528
|
+
const key = crypto.randomUUID();
|
|
529
|
+
const send = () => fetch('/payments', { method: 'POST', headers: { 'idempotency-key': key, 'content-type': 'application/json' }, body });
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
## Traps
|
|
533
|
+
|
|
534
|
+
### A rate limit lets more than `limit` requests through
|
|
535
|
+
|
|
536
|
+
**When:** the store is `redisStore`, and a client that was idle sends
|
|
537
|
+
requests steadily: up to `2 × limit − 1` pass in the first `windowMs`.
|
|
538
|
+
|
|
539
|
+
**Why:** `redisStore` is GCRA, not the memory store's fixed window. A full
|
|
540
|
+
bucket holds `limit`, and it refills continuously at `limit` per
|
|
541
|
+
`windowMs`. Measured at `limit: 5, windowMs: 2_000`: five pass at once, two
|
|
542
|
+
are refused, and two more pass one second later.
|
|
543
|
+
|
|
544
|
+
**Fix:** none is needed for a rate; it never lets more than `limit`
|
|
545
|
+
through at once. For a smaller burst at the same rate, divide `limit` and
|
|
546
|
+
`windowMs` by the same factor:
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
rateLimit({ limit: 10, windowMs: 6_000, store }); // 100 a minute, at most 10 at once
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Two limits count each other's requests
|
|
553
|
+
|
|
554
|
+
**When:** two `rateLimit`s with the same `limit` and `windowMs` are given
|
|
555
|
+
stores with the same `name`.
|
|
556
|
+
|
|
557
|
+
**Why:** the key is `<name>:<limit>/<windowMs>:<key>`: the same three are
|
|
558
|
+
the same count, whichever route counts it.
|
|
559
|
+
|
|
560
|
+
**Fix:** one `name` per limit:
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
rateLimit({ limit: 5, windowMs: 60_000, store: redisStore(connection.client, { name: 'login' }) });
|
|
564
|
+
rateLimit({ limit: 5, windowMs: 60_000, store: redisStore(connection.client, { name: 'signup' }) });
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### A `401` or a `429` is replayed, with `Idempotent-Replayed: true`, after the client fixed it
|
|
568
|
+
|
|
569
|
+
**When:** a request under a key was refused with a `4xx` — by the route, or
|
|
570
|
+
by a rate limit or an authentication check declared after `idempotency` —
|
|
571
|
+
and the client, once allowed, retries with the same key and body.
|
|
572
|
+
|
|
573
|
+
**Why:** every response below `500` is kept and replayed for `ttl`, not
|
|
574
|
+
only successes. Measured: a `429` from a rate limit declared after the
|
|
575
|
+
guard was replayed after its window had passed; a `401` was replayed after
|
|
576
|
+
the request was sent with a valid token.
|
|
577
|
+
|
|
578
|
+
**Fix:** declare the rate limit and the authentication check **before**
|
|
579
|
+
`idempotency`, so their refusals are not kept:
|
|
580
|
+
|
|
581
|
+
```ts
|
|
582
|
+
alxia()
|
|
583
|
+
.use(rateLimit({ limit: 10, windowMs: 60_000, store: redisStore(connection.client, { name: 'pay' }) }))
|
|
584
|
+
.use(idempotency(connection.client, { name: 'payments' }))
|
|
585
|
+
.post('/payments', ({ reply }) => reply(201, { ok: true }));
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
For a refusal the route itself makes, the client sends a new key with the
|
|
589
|
+
corrected request ([Order](guide/idempotency.md#order-what-runs-inside-the-guard)).
|
|
590
|
+
|
|
591
|
+
### One client gets another client's response
|
|
592
|
+
|
|
593
|
+
**When:** two clients send the same `Idempotency-Key` with the same
|
|
594
|
+
request, and the second gets the first's response, replayed. Usually
|
|
595
|
+
behind a proxy, or with keys that are not random — `1`, `order-1`.
|
|
596
|
+
|
|
597
|
+
**Why:** keys are scoped by `scope(ctx)`, the client's address by default.
|
|
598
|
+
Behind a proxy that the app's `ip` option does not see through, every
|
|
599
|
+
client has the proxy's address; with no address at all, the scope is
|
|
600
|
+
`anyone`. Clients then share one key space.
|
|
601
|
+
|
|
602
|
+
**Fix:** scope by the user where there is one, and have clients send
|
|
603
|
+
random keys:
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
idempotency(connection.client, {
|
|
607
|
+
name: 'payments',
|
|
608
|
+
scope: ({ request }) => request.headers.get('x-user-id') ?? undefined,
|
|
609
|
+
});
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
[Scope](guide/idempotency.md#scope-whose-key-it-is) shows the `ip` option
|
|
613
|
+
behind a proxy.
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alxia/redis",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Redis for alxia on @nxgt/redis and @nxgt/redis-guard: a rate-limit store every process shares, idempotent routes, caches and locks in the context",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"package.json",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/softistx/alxia.git",
|
|
27
|
+
"directory": "packages/redis"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"registry": "https://registry.npmjs.org",
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "bun run ../../build.ts",
|
|
35
|
+
"test": "bun test src",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"alxia": {
|
|
39
|
+
"entrypoints": [
|
|
40
|
+
"src/index.ts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@alxia/cache": "^0.1.0",
|
|
45
|
+
"@alxia/client": "^0.1.0",
|
|
46
|
+
"@alxia/core": "^0.1.0",
|
|
47
|
+
"@alxia/rate-limit": "^0.1.0",
|
|
48
|
+
"@nxgt/redis": "^0.3.1",
|
|
49
|
+
"@nxgt/redis-guard": "^0.3.1",
|
|
50
|
+
"@types/bun": "^1.4.2",
|
|
51
|
+
"zod": "^4.6.5"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@alxia/cache": "^0.1.0",
|
|
55
|
+
"@alxia/core": "^0.1.0",
|
|
56
|
+
"@alxia/rate-limit": "^0.1.0",
|
|
57
|
+
"@nxgt/redis": "^0.3.1",
|
|
58
|
+
"@nxgt/redis-guard": "^0.3.1",
|
|
59
|
+
"typescript": "^6.0.3 || ^7.0.0",
|
|
60
|
+
"zod": "^4.6.5"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@alxia/cache": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"@alxia/rate-limit": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|