@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/docs/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @alxia/redis documentation
2
+
3
+ The [package README](../README.md) is the short version. This folder is
4
+ the long one: a guide page per area, with the options, defaults, errors and
5
+ a realistic example for each.
6
+
7
+ ## Guide
8
+
9
+ | Page | Read it when |
10
+ | --- | --- |
11
+ | [Connecting](guide/connecting.md) | opening the client every export takes, deciding when to build the plugins, failing fast when Redis is down, closing it, or naming keys so features never share them |
12
+ | [Rate limits](guide/rate-limits.md) | sharing an `@alxia/rate-limit` count across processes, understanding why GCRA lets a burst through, choosing `name`, or resetting a key |
13
+ | [Response cache](guide/response-cache.md) | sharing `@alxia/cache` responses across processes, invalidating them everywhere, or knowing what is stored in Redis |
14
+ | [Idempotency](guide/idempotency.md) | making a `POST` run once per `Idempotency-Key`, reading the `409`, `422` and `400`, scoping keys by user, or ordering it with a rate limit or an auth check |
15
+ | [Caches and locks](guide/caches-and-locks.md) | reading typed caches and a lock from the context, catching `LOCK_HELD`, or using `redis()` beside `@alxia/cache` |
16
+ | [Testing](guide/testing.md) | writing specs against a real Redis, giving `app.request()` a client address, or pinning the refusals in the types |
17
+ | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a limit, a cache or a replay does not behave as you expected |
18
+ | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
@@ -0,0 +1,204 @@
1
+ # Caches and locks
2
+
3
+ This page covers `redis`: a plugin that puts the client, typed caches and a
4
+ lock in the context of every route declared after it.
5
+
6
+ ```ts
7
+ import { alxia } from '@alxia/core';
8
+ import { redis } from '@alxia/redis';
9
+ import { connectRedis, defineCache } from '@nxgt/redis';
10
+ import { z } from 'zod';
11
+
12
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
13
+
14
+ const User = z.object({ id: z.string(), name: z.string() });
15
+ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: User });
16
+
17
+ const loadUser = async (id: string) => ({ id, name: 'Ada' }); // your database
18
+
19
+ const app = alxia()
20
+ .use(redis(connection.client, { caches: { users } }))
21
+ .get('/users/:id', async ({ caches, params, reply }) => {
22
+ const user = await caches.users.remember(params.id, () => loadUser(params.id)); // typed by User
23
+ return reply.ok(user);
24
+ });
25
+ ```
26
+
27
+ ## The signature
28
+
29
+ ```ts
30
+ function redis<Caches>(client: RedisClient, options?: RedisContextOptions<Caches>); // a plugin
31
+
32
+ interface RedisContextOptions<Caches> {
33
+ /** `@nxgt/redis` cache definitions, by the name routes read them under. */
34
+ readonly caches?: Caches;
35
+ }
36
+
37
+ /** What routes after `redis()` read. */
38
+ interface RedisContext<Caches> {
39
+ readonly redis: RedisClient;
40
+ readonly caches: BoundCaches<Caches>;
41
+ lock<T>(key: string, work: () => Promise<T> | T, options?: LockOptions): Promise<T>;
42
+ }
43
+ ```
44
+
45
+ | Option | Type | Default | Effect |
46
+ | --- | --- | --- | --- |
47
+ | `caches` | `Record<string, CacheDefinition>` | none | each definition, bound to the client once, under `ctx.caches.<name>` |
48
+
49
+ | In the context | What it is |
50
+ | --- | --- |
51
+ | `redis` | Bun's `RedisClient`, untouched: every command Bun has |
52
+ | `caches.<name>` | the `@nxgt/redis` `BoundCache` of that definition, typed by its schema |
53
+ | `lock(key, work, options?)` | `@nxgt/redis`'s `withLock`: `work` under a lock every process respects |
54
+
55
+ The caches are bound when `redis(…)` is called, not per request.
56
+
57
+ ## Caches
58
+
59
+ A cache is described with `defineCache` from `@nxgt/redis` — a `name`, a
60
+ `key` function, a `ttl` in **seconds**, and a zod `schema` — and read under
61
+ the name you give it in `caches`:
62
+
63
+ | `caches.<name>` | |
64
+ | --- | --- |
65
+ | `get(params)` | the value, or `undefined`: a miss, an expiry, or a stored value the schema no longer accepts |
66
+ | `set(params, value, { ttl }?)` | checks `value` against the schema, then stores it |
67
+ | `remember(params, load, { ttl }?)` | the value if it is there; otherwise what `load` gives, stored |
68
+ | `delete(params)` | `true` when something was there |
69
+ | `keyFor(params)` | the Redis key it uses: `<name>:<key(params)>` |
70
+
71
+ A realistic case, a profile read through the cache and forgotten on write:
72
+
73
+ ```ts
74
+ import { alxia } from '@alxia/core';
75
+ import { redis } from '@alxia/redis';
76
+ import { connectRedis, defineCache } from '@nxgt/redis';
77
+ import { z } from 'zod';
78
+
79
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
80
+
81
+ const Profile = z.object({ id: z.string(), name: z.string(), plan: z.enum(['free', 'pro']).default('free') });
82
+ const profiles = defineCache({ name: 'profile', key: (id: string) => id, ttl: 600, schema: Profile });
83
+ const table = new Map<string, z.input<typeof Profile>>([['1', { id: '1', name: 'Ada' }]]);
84
+
85
+ const app = alxia()
86
+ .use(redis(connection.client, { caches: { profiles } }))
87
+ .get('/profiles/:id', async ({ caches, params, reply }) => {
88
+ const profile = await caches.profiles.remember(params.id, async () => table.get(params.id) ?? { id: params.id, name: '?' });
89
+ return reply.ok(profile); // plan is filled in: 'free'
90
+ })
91
+ .put('/profiles/:id', { body: Profile.omit({ id: true }) }, async ({ caches, params, body, reply }) => {
92
+ table.set(params.id, { id: params.id, ...body });
93
+ await caches.profiles.delete(params.id); // the next read loads it again
94
+ return reply(204, undefined);
95
+ });
96
+ ```
97
+
98
+ `remember` holds no lock: two requests missing at once both call `load`.
99
+ Wrap it in `lock` where loading is expensive or must happen once. The
100
+ caches' errors, their schemas and their traps are `@nxgt/redis`'s: see its
101
+ documentation.
102
+
103
+ ## Locks
104
+
105
+ `lock(key, work, options?)` takes `lock:<key>` in Redis, runs `work`, and
106
+ releases the lock — only if it still holds it.
107
+
108
+ | Option | Type | Default | Effect |
109
+ | --- | --- | --- | --- |
110
+ | `ttl` | `number` | `30_000` | **milliseconds** the lock is held before Redis drops it |
111
+ | `wait` | `number` | `0` | milliseconds to keep trying to take a held lock |
112
+ | `retryDelay` | `number` | `50` | milliseconds between tries |
113
+
114
+ A lock that is held, or that expires before `work` returns, throws a
115
+ `RedisError`. Uncaught, the route answers `500 {"error":"internal"}`; catch
116
+ it to answer something better:
117
+
118
+ ```ts
119
+ import { alxia } from '@alxia/core';
120
+ import { redis } from '@alxia/redis';
121
+ import { connectRedis, RedisError } from '@nxgt/redis';
122
+
123
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
124
+
125
+ const sendInvoices = async () => 12; // the work that must not run twice at once
126
+
127
+ const app = alxia()
128
+ .use(redis(connection.client))
129
+ .post('/invoices/run', async ({ lock, reply }) => {
130
+ try {
131
+ const sent = await lock('invoices', sendInvoices, { ttl: 60_000 });
132
+ return reply(200, { sent });
133
+ } catch (error) {
134
+ if (error instanceof RedisError && error.code === 'LOCK_HELD') {
135
+ return reply(409, { error: 'already_running' as const });
136
+ }
137
+ throw error;
138
+ }
139
+ });
140
+ ```
141
+
142
+ | `error.code` | When | Message |
143
+ | --- | --- | --- |
144
+ | `LOCK_HELD` | someone else holds it, and `wait` ran out | ``The lock "invoices" is held by somebody else, and this call did not wait for it — pass `wait` to keep trying`` |
145
+ | `LOCK_LOST` | `work` returned after `ttl` had passed | `The lock "invoices" expired before its work finished: it ran longer than the 60000ms ttl, so it may have run beside another holder` |
146
+
147
+ `LOCK_HELD` means nothing ran. `LOCK_LOST` means `work` did run, possibly
148
+ beside another holder: size `ttl` above the slowest run you accept.
149
+
150
+ ## The client
151
+
152
+ `redis` is Bun's `RedisClient`, for every command the plugin does not wrap:
153
+
154
+ ```ts
155
+ import { alxia } from '@alxia/core';
156
+ import { redis } from '@alxia/redis';
157
+ import { connectRedis } from '@nxgt/redis';
158
+
159
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
160
+
161
+ const app = alxia()
162
+ .use(redis(connection.client))
163
+ .post('/articles/:id/views', async ({ redis, params, reply }) => {
164
+ const views = await redis.incr(`views:${params.id}`);
165
+ return reply(200, { views });
166
+ });
167
+ ```
168
+
169
+ ## With `@alxia/cache`
170
+
171
+ `@alxia/cache`'s `cache()` adds `cache` to the context — the response
172
+ cache's `{ tag, skip }`. `redis()` adds `caches`, so the two sit side by side
173
+ on one route, declared in either order:
174
+
175
+ ```ts
176
+ import { cache } from '@alxia/cache';
177
+ import { alxia } from '@alxia/core';
178
+ import { redis } from '@alxia/redis';
179
+ import { connectRedis, defineCache } from '@nxgt/redis';
180
+ import { z } from 'zod';
181
+
182
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
183
+ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: z.object({ id: z.string(), name: z.string() }) });
184
+ const loadUser = async (id: string) => ({ id, name: 'Ada' });
185
+
186
+ const app = alxia()
187
+ .use(redis(connection.client, { caches: { users } }))
188
+ .use(cache({ ttl: 60 }))
189
+ .get('/users/:id', async ({ caches, cache, params, reply }) => {
190
+ cache.tag(`user:${params.id}`); // the response cache
191
+ return reply.ok(await caches.users.remember(params.id, () => loadUser(params.id))); // the Redis cache
192
+ });
193
+ ```
194
+
195
+ Code written for the earlier name — `ctx.cache.users` — no longer compiles;
196
+ [Troubleshooting](../troubleshooting.md#property-cache-does-not-exist-on-type---rediscontext-) has the
197
+ message and the rename.
198
+
199
+ ## Next
200
+
201
+ - [Connecting](connecting.md) — the client, and closing it.
202
+ - [Testing](testing.md) — a route with a cache, in a spec.
203
+ - [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) — caches,
204
+ locks and pub/sub in full.
@@ -0,0 +1,165 @@
1
+ # Connecting
2
+
3
+ This page covers the one thing every export of `@alxia/redis` takes first:
4
+ a Bun `RedisClient`, how to open it, when to make the plugins with it, and
5
+ how to close it.
6
+
7
+ ```ts
8
+ import { alxia } from '@alxia/core';
9
+ import { redis } from '@alxia/redis';
10
+ import { connectRedis } from '@nxgt/redis';
11
+
12
+ const connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
13
+
14
+ const app = alxia()
15
+ .use(redis(connection.client))
16
+ .get('/ping', async ({ redis, reply }) => reply(200, await redis.ping()));
17
+
18
+ app.listen({ port: 3000 });
19
+ ```
20
+
21
+ ## The client
22
+
23
+ Every export takes Bun's own `RedisClient` as its first argument:
24
+
25
+ ```ts
26
+ redisStore(client: RedisClient, options: RedisStoreOptions): RateLimitStore
27
+ redisCacheStore(client: RedisClient, options: RedisCacheStoreOptions): CacheStore
28
+ idempotency(client: RedisClient, options: IdempotencyOptions) // a plugin
29
+ redis(client: RedisClient, options?: RedisContextOptions) // a plugin
30
+ ```
31
+
32
+ Any `RedisClient` will do. `connectRedis` from
33
+ [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) is the usual
34
+ way: it shares one client per URI across the process, connects it once
35
+ even when two calls race, and gives back a `RedisConnection` with
36
+ `client`, `ping()` and `close()`. A client you open yourself works the
37
+ same:
38
+
39
+ ```ts
40
+ import { redisStore } from '@alxia/redis';
41
+ import { RedisClient } from 'bun';
42
+
43
+ const client = new RedisClient(Bun.env['REDIS_URL']);
44
+ const store = redisStore(client, { name: 'api' });
45
+ ```
46
+
47
+ One client is enough for the whole app: the rate-limit store, the cache
48
+ store, idempotency and `redis()` all send ordinary commands over it. None
49
+ of them subscribes, so none needs a connection of its own.
50
+
51
+ ## Make the plugins after you connect
52
+
53
+ Each export binds the client it is given when it is called. Open the
54
+ connection first, then build the app:
55
+
56
+ ```ts
57
+ import { alxia } from '@alxia/core';
58
+ import { rateLimit } from '@alxia/rate-limit';
59
+ import { idempotency, redisStore } from '@alxia/redis';
60
+ import { connectRedis } from '@nxgt/redis';
61
+
62
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
63
+
64
+ const app = alxia()
65
+ .use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(connection.client, { name: 'api' }) }))
66
+ .use(idempotency(connection.client, { name: 'orders' }))
67
+ .post('/orders', ({ reply }) => reply(201, { id: crypto.randomUUID() }));
68
+ ```
69
+
70
+ In a test, that means building the app in `beforeAll`, once Redis is up —
71
+ see [Testing](testing.md).
72
+
73
+ ## When Redis is down
74
+
75
+ `@alxia/redis` does not catch Redis's errors. A command that fails while a
76
+ request runs fails that request: the app answers
77
+ `500 {"error":"internal"}` and logs Bun's error, code
78
+ `ERR_REDIS_CONNECTION_CLOSED`: `Max reconnection attempts reached` for the
79
+ first command once Bun's client has given up reconnecting, then
80
+ `Connection has failed` for the ones after it
81
+ ([troubleshooting](../troubleshooting.md#rediserror-connection-has-failed)).
82
+ A short outage recovers on its own while the client is still retrying;
83
+ after it has given up, do not count on it coming back — restart the process.
84
+ There is no fail-open mode: a rate limit that cannot count does not let the
85
+ request through, and an idempotent route that cannot take its key does not
86
+ run. The response cache is the exception, as `@alxia/cache` decides it: a
87
+ cached route whose `redisCacheStore` cannot answer runs and answers
88
+ `X-Cache: MISS`, with the outage's first error logged; only its
89
+ invalidations reject.
90
+
91
+ At startup, Bun's client reconnects by default, so a `connectRedis` to a
92
+ server that is not there keeps trying for about half a minute before it
93
+ rejects. For a check that should fail at once, pass `autoReconnect: false`:
94
+
95
+ ```ts
96
+ import { connectRedis } from '@nxgt/redis';
97
+
98
+ try {
99
+ const check = await connectRedis(Bun.env['REDIS_URL']!, { autoReconnect: false });
100
+ await check.close();
101
+ } catch (error) {
102
+ console.error('Redis is not reachable', error);
103
+ process.exit(1);
104
+ }
105
+ ```
106
+
107
+ `connectRedis` shares one client per URI, so every later call for the same
108
+ URI must pass the same options, or it throws
109
+ `TypeError: connectRedis: this URI is already connected with other options. Pass the same options everywhere, or close the first connection.` Close the
110
+ check before the app connects, as above, or pass the same options
111
+ everywhere.
112
+
113
+ `connection.ping()` never throws, and is a health route in one line:
114
+
115
+ ```ts
116
+ import { alxia } from '@alxia/core';
117
+ import { connectRedis } from '@nxgt/redis';
118
+
119
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
120
+
121
+ const app = alxia().get('/health', async ({ reply }) => {
122
+ const redis = await connection.ping();
123
+ return redis.ok ? reply(200, { redis: 'up', ms: redis.latencyMs }) : reply(503, { redis: 'down' });
124
+ });
125
+ ```
126
+
127
+ ## Closing
128
+
129
+ Nothing here listens to a signal. Close the connection when the process
130
+ stops:
131
+
132
+ ```ts
133
+ import { connectRedis } from '@nxgt/redis';
134
+
135
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
136
+
137
+ process.on('SIGTERM', async () => {
138
+ await connection.close();
139
+ process.exit(0);
140
+ });
141
+ ```
142
+
143
+ `closeRedis()` from `@nxgt/redis` closes every client the process opened
144
+ through `connectRedis` — the end of a test file, say.
145
+
146
+ ## Naming keys
147
+
148
+ Every export that writes takes a `name`, prepended to its keys, so several
149
+ apps and several features can share one Redis:
150
+
151
+ | Export | Keys it writes |
152
+ | --- | --- |
153
+ | `redisStore(client, { name })` | `<name>:<limit>/<windowMs>:<key>`, and `<name>:policies` |
154
+ | `redisCacheStore(client, { name })` | `<name>:response:<key>`, and `<name>:tag:<tag>`, which expires with its longest-kept response |
155
+ | `idempotency(client, { name })` | `<name>:<route>:<scope>:<Idempotency-Key>` |
156
+ | `redis(client, { caches })` | each cache's own `<name>:<key>`, and `lock:<key>` |
157
+
158
+ Two features given the same `name` share their keys; give each its own.
159
+
160
+ ## Next
161
+
162
+ - [Rate limits](rate-limits.md) — `redisStore`.
163
+ - [Response cache](response-cache.md) — `redisCacheStore`.
164
+ - [Idempotency](idempotency.md) — `idempotency`.
165
+ - [Caches and locks](caches-and-locks.md) — `redis`.
@@ -0,0 +1,255 @@
1
+ # Idempotency
2
+
3
+ This page covers `idempotency`: a plugin that runs a `POST` or `PATCH`
4
+ once per `Idempotency-Key`, replays its response to every repeat, and
5
+ refuses the repeats it cannot answer — across every process sharing a
6
+ Redis.
7
+
8
+ ```ts
9
+ import { alxia } from '@alxia/core';
10
+ import { idempotency } from '@alxia/redis';
11
+ import { connectRedis } from '@nxgt/redis';
12
+ import { z } from 'zod';
13
+
14
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
15
+ const Payment = z.object({ amount: z.number().int().positive() });
16
+
17
+ const app = alxia()
18
+ .use(idempotency(connection.client, { name: 'payments' }))
19
+ .post('/payments', { body: Payment }, ({ body, reply }) =>
20
+ reply(201, { id: crypto.randomUUID(), amount: body.amount }),
21
+ );
22
+ ```
23
+
24
+ ```sh
25
+ curl -X POST localhost:3000/payments -H 'idempotency-key: 4f1c' -H 'content-type: application/json' -d '{"amount":10}'
26
+ # 201 {"id":"9a…","amount":10}
27
+ curl -X POST localhost:3000/payments -H 'idempotency-key: 4f1c' -H 'content-type: application/json' -d '{"amount":10}'
28
+ # 201 {"id":"9a…","amount":10} Idempotent-Replayed: true — the route did not run
29
+ ```
30
+
31
+ Only the routes declared **after** `use(idempotency(…))` are guarded.
32
+
33
+ ## The signature
34
+
35
+ ```ts
36
+ function idempotency(client: RedisClient, options: IdempotencyOptions); // a plugin
37
+
38
+ interface IdempotencyOptions {
39
+ readonly name: string;
40
+ readonly ttl?: number;
41
+ readonly lease?: number;
42
+ readonly wait?: number;
43
+ readonly methods?: readonly string[];
44
+ readonly header?: string;
45
+ readonly required?: boolean;
46
+ readonly scope?: (ctx: BaseContext) => string | undefined;
47
+ }
48
+ ```
49
+
50
+ | Option | Type | Default | Effect |
51
+ | --- | --- | --- | --- |
52
+ | `name` | `string` | required | the prefix of every key it stores |
53
+ | `ttl` | `number` | `86_400` (a day) | **seconds** a finished response is replayed. A whole number, at least 1 |
54
+ | `lease` | `number` | `10_000` | **milliseconds** a running request holds its key unless renewed; renewed every third of it while the route runs. A whole number, at least 1 |
55
+ | `wait` | `number` | `0` | **milliseconds** a repeat waits for a running first request before its `409`. A whole number, 0 or more |
56
+ | `methods` | `readonly string[]` | `['POST', 'PATCH']` | the methods it guards; the others pass through |
57
+ | `header` | `string` | `'idempotency-key'` | the request header the key is read from (any case) |
58
+ | `required` | `boolean` | `false` | refuse a guarded request with no key, with a `400` |
59
+ | `scope` | `(ctx: BaseContext) => string \| undefined` | `ctx.ip` | whose key it is; `undefined` scopes it to everyone |
60
+
61
+ `name`, `ttl` and `lease` are checked when `idempotency(…)` is called, so a
62
+ wrong one throws at startup:
63
+
64
+ ```text
65
+ TypeError: defineIdempotency: "payments" has a ttl of 1.5; it is a whole number of seconds, and must be at least 1
66
+ TypeError: defineIdempotency: "payments" has a lease of 500.5; it is a whole number of milliseconds, and must be at least 1
67
+ TypeError: defineIdempotency: an idempotent operation needs a name, for its keys
68
+ ```
69
+
70
+ `wait` is checked on the first guarded request: a wrong one makes every
71
+ guarded request a `500`, logging
72
+ `TypeError: run on "payments": wait is a whole number of milliseconds, 0 or more`.
73
+
74
+ ## What each request gets
75
+
76
+ | Request | Answer |
77
+ | --- | --- |
78
+ | a guarded method with a new key | the route runs; its response is kept and sent |
79
+ | the same key, method, path, query and body again, within `ttl` | the first response — status, headers, body — with `Idempotent-Replayed: true`; the route does not run |
80
+ | the same key while the first still runs | `409 { error: 'idempotency_in_progress', retryAfter }`, with `Retry-After` — after up to `wait` ms |
81
+ | the same key with another method, path, query or body | `422 { error: 'idempotency_key_reused' }` |
82
+ | a key that is not 1 to 255 printable ASCII characters (no space) | `400 { error: 'idempotency_key_invalid' }` |
83
+ | no key, with `required: true` | `400 { error: 'idempotency_key_missing' }` |
84
+ | no key, without `required` | the route runs, unguarded |
85
+ | a method not in `methods` | the route runs, unguarded |
86
+
87
+ Every refusal is typed as `IdempotencyErrorBody` and is part of each
88
+ guarded route's type, so `@alxia/client` reads it:
89
+
90
+ ```ts
91
+ interface IdempotencyErrorBody {
92
+ readonly error:
93
+ | 'idempotency_key_missing'
94
+ | 'idempotency_key_invalid'
95
+ | 'idempotency_in_progress'
96
+ | 'idempotency_key_reused';
97
+ /** Seconds until a running request should be over: with `idempotency_in_progress`. */
98
+ readonly retryAfter?: number;
99
+ }
100
+ ```
101
+
102
+ ```ts
103
+ import { client } from '@alxia/client';
104
+ import { alxia } from '@alxia/core';
105
+ import { idempotency } from '@alxia/redis';
106
+ import { connectRedis } from '@nxgt/redis';
107
+ import { z } from 'zod';
108
+
109
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
110
+
111
+ const app = alxia()
112
+ .use(idempotency(connection.client, { name: 'payments', required: true }))
113
+ .post('/payments', { body: z.object({ amount: z.number() }) }, ({ body, reply }) =>
114
+ reply(201, { id: crypto.randomUUID(), amount: body.amount }),
115
+ );
116
+
117
+ const api = client(app);
118
+ const result = await api.post('/payments', {
119
+ body: { amount: 10 },
120
+ init: { headers: { 'idempotency-key': crypto.randomUUID() } },
121
+ });
122
+ // result.status: 201 | 400 | 409 | 422 | 500
123
+ if (result.status === 409) {
124
+ await Bun.sleep(result.data.retryAfter! * 1000); // then send the same request again
125
+ }
126
+ ```
127
+
128
+ ### What is replayed
129
+
130
+ The status, every header but `Set-Cookie`, `Date` and `Content-Length`,
131
+ and the body, byte for byte. A cookie belongs to the response that set it:
132
+ a repeat never receives a session.
133
+
134
+ ### What is not kept
135
+
136
+ - **A `5xx`** — a thrown error included — is answered and not kept: the key
137
+ is free again, and the next repeat runs the route.
138
+ - **A `text/event-stream` response** is answered and not kept.
139
+ - **Everything else is kept**, `4xx` included: a `400`, a `401`, a `404`
140
+ or a `429` answered under a key is replayed to every repeat for `ttl`
141
+ seconds — see [Order](#order-what-runs-inside-the-guard) below.
142
+
143
+ ### `409` and `Retry-After`
144
+
145
+ `retryAfter` is when the running request's lease lapses **unless it is
146
+ renewed**, rounded up to the second — 10 by default, however short the
147
+ route actually is. A client that waits that long and repeats gets the
148
+ replay. With `wait`, the repeat waits on the server instead, and usually
149
+ gets the replay rather than the `409`:
150
+
151
+ ```ts
152
+ import { alxia } from '@alxia/core';
153
+ import { idempotency } from '@alxia/redis';
154
+ import { connectRedis } from '@nxgt/redis';
155
+
156
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
157
+
158
+ const app = alxia()
159
+ .use(idempotency(connection.client, { name: 'orders', wait: 2_000 })) // keep it under your HTTP timeout
160
+ .post('/orders', async ({ reply }) => reply(201, { id: crypto.randomUUID() }));
161
+ ```
162
+
163
+ ## Scope: whose key it is
164
+
165
+ Clients choose their keys, so two clients can choose the same one. The
166
+ stored key is `<name>:<route>:<scope>:<key>`, where `scope(ctx)` is the
167
+ client's address by default. When it is `undefined` — no address, as with
168
+ `app.request()` in a test or a server that cannot see one — the scope is
169
+ `anyone`, and every client shares the key space.
170
+
171
+ Behind a proxy, the address is the proxy's unless the app's `ip` option
172
+ reads the forwarded header. Where requests carry a user, scope by the user:
173
+
174
+ ```ts
175
+ import { alxia } from '@alxia/core';
176
+ import { idempotency } from '@alxia/redis';
177
+ import { connectRedis } from '@nxgt/redis';
178
+
179
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
180
+
181
+ const app = alxia({ ip: (request) => request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() })
182
+ .use(
183
+ idempotency(connection.client, {
184
+ name: 'payments',
185
+ scope: ({ request }) => request.headers.get('x-user-id') ?? undefined,
186
+ }),
187
+ )
188
+ .post('/payments', ({ reply }) => reply(201, { ok: true }));
189
+ ```
190
+
191
+ `scope` receives `BaseContext` — `request`, `url`, `ip`, `route` and the
192
+ rest — not what an earlier `derive` added; read the user from the request.
193
+ Read the forwarded header only behind a proxy you trust.
194
+
195
+ A key is also scoped by route: the same key on `/payments` and `/refunds`
196
+ is two keys.
197
+
198
+ ## Order: what runs inside the guard
199
+
200
+ The plugin wraps the routes declared after it, and every route hook
201
+ declared after it too. Whatever those answer is kept like the route's
202
+ answer. A rate limit or an authentication check declared **after**
203
+ `idempotency` has its `429` or `401` kept and replayed — even once the
204
+ client is allowed through. Declare them **before**:
205
+
206
+ ```ts
207
+ import { alxia } from '@alxia/core';
208
+ import { rateLimit } from '@alxia/rate-limit';
209
+ import { idempotency, redisStore } from '@alxia/redis';
210
+ import { connectRedis } from '@nxgt/redis';
211
+
212
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
213
+
214
+ const app = alxia()
215
+ .use(rateLimit({ limit: 10, windowMs: 60_000, store: redisStore(connection.client, { name: 'pay' }) })) // its 429 is never kept
216
+ .use(idempotency(connection.client, { name: 'payments' }))
217
+ .post('/payments', ({ reply }) => reply(201, { ok: true }));
218
+ ```
219
+
220
+ Measured: with the rate limit after `idempotency`, a key refused with a
221
+ `429` answered `429` again, `Idempotent-Replayed: true`, after the window
222
+ had passed; with it before, the same repeat ran the route and answered
223
+ `201`.
224
+
225
+ A route that refuses a request it might accept later — a `401` before the
226
+ client signs in again, a `409` on a state that changes — should answer it
227
+ before the guard, or the client should send a new key when it retries.
228
+
229
+ ## The fingerprint
230
+
231
+ The first request's method, path, query and raw body are hashed and stored
232
+ with the key. A repeat whose hash differs is the `422`. Headers are not
233
+ part of it: a repeat with another `Authorization` and the same body is a
234
+ replay, within the same scope.
235
+
236
+ ## When the route outlives its lease
237
+
238
+ The lease is renewed by a timer while the route runs. A route that blocks
239
+ the event loop — a synchronous loop, `Bun.sleepSync` — cannot renew it:
240
+ after a whole `lease`, a repeat can take the key and run the route again,
241
+ and the first request ends in a `500`, logging
242
+
243
+ ```text
244
+ 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
245
+ ```
246
+
247
+ Yield (`await Bun.sleep(0)`) inside long synchronous work, or move it to a
248
+ `Worker`.
249
+
250
+ ## Next
251
+
252
+ - [Rate limits](rate-limits.md) — `redisStore`.
253
+ - [Testing](testing.md) — specs for a guarded route.
254
+ - [`@nxgt/redis-guard`](https://www.npmjs.com/package/@nxgt/redis-guard) —
255
+ the primitive underneath, for idempotency outside HTTP.