@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.
@@ -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
+ }