hanzio 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -3
- package/dist/api-wrapper/connection-auth.d.ts +61 -0
- package/dist/cli/fields.d.ts +30 -0
- package/dist/cli/index.d.ts +122 -0
- package/dist/format/index.d.ts +91 -0
- package/dist/format/phone.d.ts +19 -0
- package/dist/http/index.d.ts +14 -0
- package/dist/i18n/catalog.d.ts +47 -0
- package/dist/i18n/check.d.ts +45 -0
- package/dist/i18n/cli.d.ts +2 -0
- package/dist/i18n/content-rules.d.ts +55 -0
- package/dist/i18n/i18next.d.ts +39 -0
- package/dist/i18n/index.d.ts +16 -1
- package/dist/i18n/locales.d.ts +62 -0
- package/dist/i18n/vite.d.ts +16 -0
- package/dist/i18n/vue.d.ts +93 -0
- package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
- package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
- package/dist/index-5n2e326q.js +409 -0
- package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
- package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
- package/dist/index-mq2qgc20.js +116 -0
- package/dist/index-najbyzpr.js +99 -0
- package/dist/index-nfvq3zax.js +475 -0
- package/dist/index-ww09bm49.js +250 -0
- package/dist/iso/countries.d.ts +262 -0
- package/dist/iso/currencies.d.ts +165 -0
- package/dist/iso/index.d.ts +4 -0
- package/dist/iso/languages.d.ts +195 -0
- package/dist/iso/phone-language.d.ts +5 -0
- package/dist/promise/index.d.ts +31 -0
- package/dist/redis/client.d.ts +49 -0
- package/dist/redis/concurrency-limit.d.ts +63 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/lua-script.d.ts +13 -0
- package/dist/secrets/infisical-identity-file.d.ts +12 -0
- package/dist/src/api-wrapper/connection-auth.js +82 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/cli/index.js +10 -0
- package/dist/src/format/index.js +79 -2
- package/dist/src/format/phone.js +21 -0
- package/dist/src/http/index.js +69 -0
- package/dist/src/i18n/check.js +15 -0
- package/dist/src/i18n/cli.js +71 -0
- package/dist/src/i18n/i18next.js +48 -0
- package/dist/src/i18n/index.js +16 -34
- package/dist/src/i18n/vite.js +25 -0
- package/dist/src/i18n/vue.js +51 -0
- package/dist/src/index.js +14 -4
- package/dist/src/iso/index.js +655 -0
- package/dist/src/redis/index.js +577 -0
- package/dist/src/secrets/index.js +78 -35
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/storage/index.js +8 -117
- package/dist/src/testing/index.js +348 -0
- package/dist/src/zod/index.js +20 -253
- package/dist/string/index.d.ts +21 -0
- package/dist/testing/connection-auth-contract.d.ts +43 -0
- package/dist/testing/helpers.d.ts +26 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/module-mocker.d.ts +30 -0
- package/dist/testing/prisma.d.ts +30 -0
- package/dist/testing/window.d.ts +18 -0
- package/dist/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +96 -2
- package/src/api-wrapper/README.md +52 -0
- package/src/api-wrapper/connection-auth.ts +142 -0
- package/src/cli/README.md +220 -0
- package/src/cli/fields.ts +213 -0
- package/src/cli/index.ts +533 -0
- package/src/format/README.md +82 -2
- package/src/format/index.ts +148 -0
- package/src/format/phone.ts +43 -0
- package/src/http/README.md +25 -0
- package/src/http/index.ts +94 -0
- package/src/i18n/README.md +206 -3
- package/src/i18n/catalog.ts +139 -0
- package/src/i18n/check.ts +376 -0
- package/src/i18n/cli.ts +90 -0
- package/src/i18n/content-rules.ts +179 -0
- package/src/i18n/i18next.ts +89 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/locales.ts +135 -0
- package/src/i18n/vite.ts +40 -0
- package/src/i18n/vue.ts +82 -0
- package/src/index.ts +1 -1
- package/src/iso/README.md +44 -0
- package/src/iso/countries.ts +272 -0
- package/src/iso/currencies.ts +172 -0
- package/src/iso/index.ts +8 -0
- package/src/iso/languages.ts +208 -0
- package/src/iso/phone-language.ts +46 -0
- package/src/promise/index.ts +100 -0
- package/src/redis/README.md +112 -0
- package/src/redis/client.ts +239 -0
- package/src/redis/concurrency-limit.ts +591 -0
- package/src/redis/index.ts +15 -0
- package/src/redis/lua-script.ts +43 -0
- package/src/secrets/README.md +5 -1
- package/src/secrets/infisical-identity-file.ts +69 -0
- package/src/secrets/infisical.ts +18 -3
- package/src/string/index.ts +59 -0
- package/src/testing/README.md +119 -0
- package/src/testing/connection-auth-contract.ts +303 -0
- package/src/testing/helpers.ts +41 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/module-mocker.ts +50 -0
- package/src/testing/prisma.ts +78 -0
- package/src/testing/window.ts +119 -0
- package/src/zod/index.ts +20 -0
- package/src/zod/introspect.ts +1 -1
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { createLuaScript, type RedisSend } from './lua-script'
|
|
2
|
+
|
|
3
|
+
const POLL_MS = 100
|
|
4
|
+
const FAILURE_LOG_THROTTLE_MS = 10_000
|
|
5
|
+
const DEFAULT_FAIL_OPEN_AFTER_MS = 15_000
|
|
6
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 3_000
|
|
7
|
+
const START_WINDOW_MS = 1_000
|
|
8
|
+
const MAX_COOLDOWN_MS = 5 * 60 * 1_000
|
|
9
|
+
const BREAKER_PROBE_INTERVAL_MS = 5_000
|
|
10
|
+
const MAX_RENEW_BATCH = 200
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Redis owns the clock. Pod clocks drift, and a client whose clock runs fast
|
|
14
|
+
* would evict slots that are still held - breaching the cap it exists to guard.
|
|
15
|
+
*/
|
|
16
|
+
const NOW_MS_LUA = `
|
|
17
|
+
local time = redis.call('TIME')
|
|
18
|
+
local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Grants slots to as many of `ARGV[3..]` as fit under the cap, in order, and
|
|
23
|
+
* reports the shared cooldown in the same round trip. Callers hand the whole
|
|
24
|
+
* local waiting queue to one call, so Redis traffic tracks the cap rather than
|
|
25
|
+
* the number of waiters.
|
|
26
|
+
*
|
|
27
|
+
* Returns `{grantedCount, waitMs}` - `waitMs > 0` means a cooldown is active.
|
|
28
|
+
*/
|
|
29
|
+
const ACQUIRE_SCRIPT = `
|
|
30
|
+
local inflightKey = KEYS[1]
|
|
31
|
+
local cooldownKey = KEYS[2]
|
|
32
|
+
local limit = tonumber(ARGV[1])
|
|
33
|
+
local ttlMs = tonumber(ARGV[2])
|
|
34
|
+
local cooldownMs = redis.call('PTTL', cooldownKey)
|
|
35
|
+
if cooldownMs > 0 then
|
|
36
|
+
return {0, cooldownMs}
|
|
37
|
+
end
|
|
38
|
+
${NOW_MS_LUA}
|
|
39
|
+
redis.call('ZREMRANGEBYSCORE', inflightKey, '-inf', now)
|
|
40
|
+
local free = limit - redis.call('ZCARD', inflightKey)
|
|
41
|
+
local granted = 0
|
|
42
|
+
while granted < free and (granted + 3) <= #ARGV do
|
|
43
|
+
redis.call('ZADD', inflightKey, now + ttlMs, ARGV[granted + 3])
|
|
44
|
+
granted = granted + 1
|
|
45
|
+
end
|
|
46
|
+
if granted > 0 then
|
|
47
|
+
redis.call('PEXPIRE', inflightKey, ttlMs)
|
|
48
|
+
end
|
|
49
|
+
return {granted, 0}
|
|
50
|
+
`.trim()
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Sliding window of *starts*. Unlike the slot cap, the window drains on a
|
|
54
|
+
* known schedule, so an ungranted caller is told exactly when the oldest start
|
|
55
|
+
* ages out instead of being left to poll.
|
|
56
|
+
*/
|
|
57
|
+
const START_SCRIPT = `
|
|
58
|
+
local startsKey = KEYS[1]
|
|
59
|
+
local cooldownKey = KEYS[2]
|
|
60
|
+
local limit = tonumber(ARGV[1])
|
|
61
|
+
local windowMs = tonumber(ARGV[2])
|
|
62
|
+
local cooldownMs = redis.call('PTTL', cooldownKey)
|
|
63
|
+
if cooldownMs > 0 then
|
|
64
|
+
return {0, cooldownMs}
|
|
65
|
+
end
|
|
66
|
+
${NOW_MS_LUA}
|
|
67
|
+
redis.call('ZREMRANGEBYSCORE', startsKey, '-inf', now - windowMs)
|
|
68
|
+
local free = limit - redis.call('ZCARD', startsKey)
|
|
69
|
+
local granted = 0
|
|
70
|
+
while granted < free and (granted + 3) <= #ARGV do
|
|
71
|
+
redis.call('ZADD', startsKey, now, ARGV[granted + 3])
|
|
72
|
+
granted = granted + 1
|
|
73
|
+
end
|
|
74
|
+
if granted > 0 then
|
|
75
|
+
redis.call('PEXPIRE', startsKey, windowMs)
|
|
76
|
+
end
|
|
77
|
+
local waitMs = 0
|
|
78
|
+
if granted < (#ARGV - 2) then
|
|
79
|
+
local oldest = redis.call('ZRANGE', startsKey, 0, 0, 'WITHSCORES')
|
|
80
|
+
if oldest[2] then
|
|
81
|
+
waitMs = math.ceil(tonumber(oldest[2]) + windowMs - now)
|
|
82
|
+
if waitMs < 0 then
|
|
83
|
+
waitMs = 0
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
return {granted, waitMs}
|
|
88
|
+
`.trim()
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Renews every slot this process still holds in one call. A member that has
|
|
92
|
+
* already been evicted is left alone: re-adding it would breach the cap that
|
|
93
|
+
* eviction just restored.
|
|
94
|
+
*/
|
|
95
|
+
const RENEW_SCRIPT = `
|
|
96
|
+
local key = KEYS[1]
|
|
97
|
+
local ttlMs = tonumber(ARGV[1])
|
|
98
|
+
${NOW_MS_LUA}
|
|
99
|
+
local renewed = 0
|
|
100
|
+
for i = 2, #ARGV do
|
|
101
|
+
if redis.call('ZSCORE', key, ARGV[i]) then
|
|
102
|
+
redis.call('ZADD', key, now + ttlMs, ARGV[i])
|
|
103
|
+
renewed = renewed + 1
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
if renewed > 0 then
|
|
107
|
+
redis.call('PEXPIRE', key, ttlMs)
|
|
108
|
+
end
|
|
109
|
+
return renewed
|
|
110
|
+
`.trim()
|
|
111
|
+
|
|
112
|
+
const TRIP_COOLDOWN_SCRIPT = `
|
|
113
|
+
local key = KEYS[1]
|
|
114
|
+
local waitMs = tonumber(ARGV[1])
|
|
115
|
+
local ttl = redis.call('PTTL', key)
|
|
116
|
+
if ttl < waitMs then
|
|
117
|
+
redis.call('SET', key, '1', 'PX', ARGV[1])
|
|
118
|
+
end
|
|
119
|
+
return 1
|
|
120
|
+
`.trim()
|
|
121
|
+
|
|
122
|
+
function withCommandTimeout(send: RedisSend, timeoutMs: number): RedisSend {
|
|
123
|
+
return async (command, args) => {
|
|
124
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
125
|
+
try {
|
|
126
|
+
return await Promise.race([
|
|
127
|
+
send(command, args),
|
|
128
|
+
new Promise<never>((_, reject) => {
|
|
129
|
+
timer = setTimeout(
|
|
130
|
+
() =>
|
|
131
|
+
reject(new Error(`Redis command timed out after ${timeoutMs}ms`)),
|
|
132
|
+
timeoutMs
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
])
|
|
136
|
+
} finally {
|
|
137
|
+
if (timer) clearTimeout(timer)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The pid tells a human which process holds a slot; it is absent outside Node and Bun.
|
|
143
|
+
function createToken() {
|
|
144
|
+
const pid = globalThis.process?.pid
|
|
145
|
+
return pid === undefined
|
|
146
|
+
? crypto.randomUUID()
|
|
147
|
+
: `${pid}:${crypto.randomUUID()}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function jitterMs() {
|
|
151
|
+
return Math.floor(Math.random() * POLL_MS)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Background timers must not keep a process alive; `unref` is Node/Bun only.
|
|
155
|
+
function unref(timer: ReturnType<typeof setTimeout>) {
|
|
156
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type GrantReply = { granted: number; waitMs: number }
|
|
160
|
+
|
|
161
|
+
function parseGrantReply(reply: unknown): GrantReply {
|
|
162
|
+
const [granted, waitMs] = Array.isArray(reply) ? reply : []
|
|
163
|
+
return {
|
|
164
|
+
granted: Math.max(0, Number(granted) || 0),
|
|
165
|
+
waitMs: Math.min(MAX_COOLDOWN_MS, Math.max(0, Number(waitMs) || 0))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type ConcurrencyLimitStage = 'slot' | 'start'
|
|
170
|
+
|
|
171
|
+
export type RedisConcurrencyLimitOptions = {
|
|
172
|
+
key: string
|
|
173
|
+
max: number
|
|
174
|
+
slotTtlMs: number
|
|
175
|
+
send: RedisSend
|
|
176
|
+
maxStartsPerSecond?: number
|
|
177
|
+
failOpenAfterMs?: number
|
|
178
|
+
commandTimeoutMs?: number
|
|
179
|
+
/** Receives throttled warnings. Defaults to `console`. */
|
|
180
|
+
logger?: Pick<Console, 'warn'>
|
|
181
|
+
/** Called when a caller starts queueing; the returned function runs once it stops. */
|
|
182
|
+
onWait?: (stage: ConcurrencyLimitStage) => (() => void) | void
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type RedisConcurrencyLimit = {
|
|
186
|
+
<T>(run: () => Promise<T>, signal?: AbortSignal): Promise<T>
|
|
187
|
+
tripCooldown: (retryAfterMs: number) => Promise<void>
|
|
188
|
+
/** Callers in this process waiting for a slot or a start, e.g. for a gauge. */
|
|
189
|
+
queueDepth: () => number
|
|
190
|
+
/** Drop this limiter's timers. See `createRedisConcurrencyLimit`. */
|
|
191
|
+
dispose: () => void
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Redis semaphore shared by every process. Returns `withLimit(run)` - at most
|
|
196
|
+
* `max` callbacks in flight for `key`.
|
|
197
|
+
*
|
|
198
|
+
* A held slot is heartbeated for as long as `run` lasts, so `slotTtlMs` is a
|
|
199
|
+
* crash-detection window rather than a run budget: slots leaked by a killed pod
|
|
200
|
+
* free themselves within one TTL, while slow-but-alive work keeps its slot.
|
|
201
|
+
*
|
|
202
|
+
* `maxStartsPerSecond` is a separate sliding 1s window of *starts*. Use it when
|
|
203
|
+
* the provider quota is requests-per-second (Cloudflare Quick Actions) so
|
|
204
|
+
* in-flight concurrency can sit higher than the start rate.
|
|
205
|
+
*
|
|
206
|
+
* `tripCooldown` writes a shared pause from an upstream `Retry-After` so every
|
|
207
|
+
* process backs off together instead of stampeding into 429s.
|
|
208
|
+
*
|
|
209
|
+
* Waiters inside one process queue locally and are handed to Redis in a single
|
|
210
|
+
* batched call, so a pod with hundreds of callers costs the same Redis traffic
|
|
211
|
+
* as one with a handful.
|
|
212
|
+
*
|
|
213
|
+
* When Redis itself is unreachable for `failOpenAfterMs` the limiter gives up
|
|
214
|
+
* and runs uncapped. Callers guard a third-party quota; degrading to the
|
|
215
|
+
* provider's own rate limit beats hanging every caller until Redis returns.
|
|
216
|
+
* That verdict is shared per limiter: once tripped, later calls degrade
|
|
217
|
+
* immediately while a background probe watches for Redis to come back.
|
|
218
|
+
*
|
|
219
|
+
* `onWait` and `queueDepth` feed metrics. A limiter minted per tenant or per
|
|
220
|
+
* customer host should report under a shared pool label rather than its key,
|
|
221
|
+
* or every key ever touched leaves a permanent series. Call `dispose()` when
|
|
222
|
+
* such a limiter is discarded, and unregister any gauge reading `queueDepth`.
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* const withLimit = createRedisConcurrencyLimit({
|
|
226
|
+
* key: 'openai',
|
|
227
|
+
* max: 20,
|
|
228
|
+
* slotTtlMs: 30_000,
|
|
229
|
+
* send: (command, args) => redis.getClient().send(command, args)
|
|
230
|
+
* })
|
|
231
|
+
* const reply = await withLimit(() => openai.chat(request), signal)
|
|
232
|
+
*/
|
|
233
|
+
export function createRedisConcurrencyLimit(
|
|
234
|
+
input: RedisConcurrencyLimitOptions
|
|
235
|
+
): RedisConcurrencyLimit {
|
|
236
|
+
if (!Number.isInteger(input.max) || input.max < 1)
|
|
237
|
+
throw new Error(`max must be a positive integer, got ${input.max}`)
|
|
238
|
+
const logger = input.logger ?? console
|
|
239
|
+
const commandTimeoutMs = input.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
|
|
240
|
+
const send = withCommandTimeout(input.send, commandTimeoutMs)
|
|
241
|
+
const ttlMs = String(input.slotTtlMs)
|
|
242
|
+
const renewEveryMs = Math.max(100, Math.floor(input.slotTtlMs / 3))
|
|
243
|
+
const failOpenAfterMs = input.failOpenAfterMs ?? DEFAULT_FAIL_OPEN_AFTER_MS
|
|
244
|
+
const maxStartsPerSecond = input.maxStartsPerSecond
|
|
245
|
+
|
|
246
|
+
// Hash tags keep the multi-key scripts on one slot under Redis Cluster.
|
|
247
|
+
// Renaming the inflight key means a rolling deploy briefly enforces the cap
|
|
248
|
+
// twice over; that resolves within one `slotTtlMs`.
|
|
249
|
+
const inflightKey = `{${input.key}}:inflight`
|
|
250
|
+
const startsKey = `{${input.key}}:starts`
|
|
251
|
+
const cooldownKey = `{${input.key}}:cooldown`
|
|
252
|
+
|
|
253
|
+
const acquireScript = createLuaScript(send, ACQUIRE_SCRIPT)
|
|
254
|
+
const startScript = createLuaScript(send, START_SCRIPT)
|
|
255
|
+
const renewScript = createLuaScript(send, RENEW_SCRIPT)
|
|
256
|
+
const tripScript = createLuaScript(send, TRIP_COOLDOWN_SCRIPT)
|
|
257
|
+
|
|
258
|
+
const lastFailureLogAtMs = new Map<string, number>()
|
|
259
|
+
|
|
260
|
+
function logFailure(message: string, error: unknown) {
|
|
261
|
+
const nowMs = Date.now()
|
|
262
|
+
if (
|
|
263
|
+
nowMs - (lastFailureLogAtMs.get(message) ?? 0) <
|
|
264
|
+
FAILURE_LOG_THROTTLE_MS
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
lastFailureLogAtMs.set(message, nowMs)
|
|
268
|
+
logger.warn(message, { key: input.key, error })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------- breaker
|
|
272
|
+
|
|
273
|
+
let firstFailureAtMs: number | null = null
|
|
274
|
+
let degraded = false
|
|
275
|
+
let probeTimer: ReturnType<typeof setTimeout> | null = null
|
|
276
|
+
|
|
277
|
+
function scheduleProbe() {
|
|
278
|
+
if (probeTimer) return
|
|
279
|
+
probeTimer = setTimeout(() => {
|
|
280
|
+
probeTimer = null
|
|
281
|
+
void probeRedis()
|
|
282
|
+
}, BREAKER_PROBE_INTERVAL_MS)
|
|
283
|
+
unref(probeTimer)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A zero-token acquire reads the cap without taking a slot. */
|
|
287
|
+
async function probeRedis() {
|
|
288
|
+
try {
|
|
289
|
+
await acquireScript(
|
|
290
|
+
[inflightKey, cooldownKey],
|
|
291
|
+
[String(input.max), ttlMs]
|
|
292
|
+
)
|
|
293
|
+
noteRedisSuccess()
|
|
294
|
+
} catch (error) {
|
|
295
|
+
logFailure('Redis concurrency probe failed', error)
|
|
296
|
+
scheduleProbe()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function noteRedisSuccess() {
|
|
301
|
+
firstFailureAtMs = null
|
|
302
|
+
if (!degraded) return
|
|
303
|
+
degraded = false
|
|
304
|
+
if (probeTimer) {
|
|
305
|
+
clearTimeout(probeTimer)
|
|
306
|
+
probeTimer = null
|
|
307
|
+
}
|
|
308
|
+
logger.warn('Redis reachable again, concurrency limiting re-enabled', {
|
|
309
|
+
key: input.key
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** True once Redis has been down long enough that callers should run uncapped. */
|
|
314
|
+
function noteRedisFailure(error: unknown) {
|
|
315
|
+
logFailure('Redis concurrency command failed, retrying', error)
|
|
316
|
+
if (degraded) return true
|
|
317
|
+
firstFailureAtMs ??= Date.now()
|
|
318
|
+
if (Date.now() - firstFailureAtMs < failOpenAfterMs) return false
|
|
319
|
+
degraded = true
|
|
320
|
+
scheduleProbe()
|
|
321
|
+
logger.warn('Redis unreachable, running without a concurrency slot', {
|
|
322
|
+
key: input.key
|
|
323
|
+
})
|
|
324
|
+
return true
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ------------------------------------------------------------------- pump
|
|
328
|
+
|
|
329
|
+
type Waiter = { token: string; settle: (grantedSlot: boolean) => void }
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* One loop per limiter drains a local FIFO: waiters are batched into a single
|
|
333
|
+
* script call, granted in order, and the remainder waits out the hint the
|
|
334
|
+
* script returned. Jitter keeps the fleet from retrying in lockstep after a
|
|
335
|
+
* shared cooldown expires.
|
|
336
|
+
*/
|
|
337
|
+
function createPump(
|
|
338
|
+
stage: ConcurrencyLimitStage,
|
|
339
|
+
maxBatch: number,
|
|
340
|
+
grant: (tokens: string[]) => Promise<GrantReply>
|
|
341
|
+
) {
|
|
342
|
+
const queue: Waiter[] = []
|
|
343
|
+
let running = false
|
|
344
|
+
let wakeSleeper: (() => void) | null = null
|
|
345
|
+
|
|
346
|
+
// Deliberately not unref'd: a caller is awaiting this sleep, so it has to
|
|
347
|
+
// hold the event loop open the way `Bun.sleep` would.
|
|
348
|
+
function sleep(ms: number) {
|
|
349
|
+
return new Promise<void>((resolve) => {
|
|
350
|
+
const timer = setTimeout(() => {
|
|
351
|
+
wakeSleeper = null
|
|
352
|
+
resolve()
|
|
353
|
+
}, ms)
|
|
354
|
+
wakeSleeper = () => {
|
|
355
|
+
wakeSleeper = null
|
|
356
|
+
clearTimeout(timer)
|
|
357
|
+
resolve()
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function settleAll(grantedSlot: boolean) {
|
|
363
|
+
for (const waiter of queue.splice(0)) waiter.settle(grantedSlot)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function loop() {
|
|
367
|
+
running = true
|
|
368
|
+
try {
|
|
369
|
+
// Callers usually arrive as a synchronous burst (one `map` over a page
|
|
370
|
+
// list). Yielding once lets the whole burst land in the first batch.
|
|
371
|
+
await Promise.resolve()
|
|
372
|
+
while (queue.length > 0) {
|
|
373
|
+
let reply: GrantReply
|
|
374
|
+
try {
|
|
375
|
+
reply = await grant(
|
|
376
|
+
queue.slice(0, maxBatch).map((waiter) => waiter.token)
|
|
377
|
+
)
|
|
378
|
+
noteRedisSuccess()
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (noteRedisFailure(error)) {
|
|
381
|
+
settleAll(false)
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
await sleep(POLL_MS + jitterMs())
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
for (const waiter of queue.splice(0, reply.granted))
|
|
389
|
+
waiter.settle(true)
|
|
390
|
+
if (queue.length === 0) break
|
|
391
|
+
await sleep((reply.waitMs || POLL_MS) + jitterMs())
|
|
392
|
+
}
|
|
393
|
+
} finally {
|
|
394
|
+
running = false
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
depth: () => queue.length,
|
|
400
|
+
/** Resolves true once the token holds a slot, false when failing open. */
|
|
401
|
+
acquire(token: string) {
|
|
402
|
+
const stopWait = input.onWait?.(stage)
|
|
403
|
+
const granted = new Promise<boolean>((resolve) => {
|
|
404
|
+
queue.push({ token, settle: resolve })
|
|
405
|
+
})
|
|
406
|
+
// Deliberately no wake here: a burst of arrivals must not turn the
|
|
407
|
+
// poll interval into a spin. Only a freed slot cuts the wait short.
|
|
408
|
+
if (!running) void loop()
|
|
409
|
+
return stopWait ? granted.finally(stopWait) : granted
|
|
410
|
+
},
|
|
411
|
+
/** Retry now - a slot this process held just came free. */
|
|
412
|
+
wake() {
|
|
413
|
+
wakeSleeper?.()
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const slotPump = createPump('slot', input.max, async (tokens) =>
|
|
419
|
+
parseGrantReply(
|
|
420
|
+
await acquireScript(
|
|
421
|
+
[inflightKey, cooldownKey],
|
|
422
|
+
[String(input.max), ttlMs, ...tokens]
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
const startPump =
|
|
428
|
+
maxStartsPerSecond === undefined
|
|
429
|
+
? null
|
|
430
|
+
: createPump('start', maxStartsPerSecond, async (tokens) =>
|
|
431
|
+
parseGrantReply(
|
|
432
|
+
await startScript(
|
|
433
|
+
[startsKey, cooldownKey],
|
|
434
|
+
[String(maxStartsPerSecond), String(START_WINDOW_MS), ...tokens]
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
const queueDepth = () => slotPump.depth() + (startPump?.depth() ?? 0)
|
|
440
|
+
|
|
441
|
+
// -------------------------------------------------------------- heartbeat
|
|
442
|
+
|
|
443
|
+
const heldTokens = new Set<string>()
|
|
444
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
|
|
445
|
+
|
|
446
|
+
function scheduleHeartbeat() {
|
|
447
|
+
if (heartbeatTimer || heldTokens.size === 0) return
|
|
448
|
+
heartbeatTimer = setTimeout(() => {
|
|
449
|
+
heartbeatTimer = null
|
|
450
|
+
void renewHeldSlots()
|
|
451
|
+
}, renewEveryMs)
|
|
452
|
+
unref(heartbeatTimer)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** One call covers every slot this process holds, however many are in flight. */
|
|
456
|
+
async function renewHeldSlots() {
|
|
457
|
+
const tokens = [...heldTokens]
|
|
458
|
+
for (let index = 0; index < tokens.length; index += MAX_RENEW_BATCH) {
|
|
459
|
+
try {
|
|
460
|
+
await renewScript(
|
|
461
|
+
[inflightKey],
|
|
462
|
+
[ttlMs, ...tokens.slice(index, index + MAX_RENEW_BATCH)]
|
|
463
|
+
)
|
|
464
|
+
noteRedisSuccess()
|
|
465
|
+
} catch (error) {
|
|
466
|
+
logFailure('Failed to renew Redis concurrency slots', error)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
scheduleHeartbeat()
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------------------------------------------------------------- release
|
|
473
|
+
|
|
474
|
+
let pendingReleases: string[] = []
|
|
475
|
+
let releaseFlush: Promise<void> | null = null
|
|
476
|
+
|
|
477
|
+
/** Slots freed in the same tick leave in one `ZREM`. */
|
|
478
|
+
function release(token: string) {
|
|
479
|
+
pendingReleases.push(token)
|
|
480
|
+
releaseFlush ??= (async () => {
|
|
481
|
+
await Promise.resolve()
|
|
482
|
+
const tokens = pendingReleases
|
|
483
|
+
pendingReleases = []
|
|
484
|
+
releaseFlush = null
|
|
485
|
+
try {
|
|
486
|
+
// Redis drops the sorted set once the last member leaves, so no
|
|
487
|
+
// explicit key cleanup is needed here.
|
|
488
|
+
await send('ZREM', [inflightKey, ...tokens])
|
|
489
|
+
noteRedisSuccess()
|
|
490
|
+
} catch (error) {
|
|
491
|
+
logFailure('Failed to release Redis concurrency slot', error)
|
|
492
|
+
}
|
|
493
|
+
slotPump.wake()
|
|
494
|
+
})()
|
|
495
|
+
return releaseFlush
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function tripCooldown(retryAfterMs: number) {
|
|
499
|
+
const waitMs = Math.min(
|
|
500
|
+
MAX_COOLDOWN_MS,
|
|
501
|
+
Math.max(0, Math.ceil(retryAfterMs))
|
|
502
|
+
)
|
|
503
|
+
if (waitMs === 0) return
|
|
504
|
+
try {
|
|
505
|
+
await tripScript([cooldownKey], [String(waitMs)])
|
|
506
|
+
} catch (error) {
|
|
507
|
+
logFailure('Failed to trip Redis rate-limit cooldown', error)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Abort promptly; a Redis grant already in flight must still be released. */
|
|
512
|
+
async function waitForGrant(
|
|
513
|
+
pending: Promise<boolean>,
|
|
514
|
+
signal: AbortSignal | undefined,
|
|
515
|
+
lateGrant: () => void
|
|
516
|
+
) {
|
|
517
|
+
if (!signal) return pending
|
|
518
|
+
let aborted = false
|
|
519
|
+
let abort: (() => void) | undefined
|
|
520
|
+
try {
|
|
521
|
+
return await Promise.race([
|
|
522
|
+
pending.then((granted) => {
|
|
523
|
+
if (aborted && granted) lateGrant()
|
|
524
|
+
return granted
|
|
525
|
+
}),
|
|
526
|
+
new Promise<never>((_, reject) => {
|
|
527
|
+
abort = () => {
|
|
528
|
+
aborted = true
|
|
529
|
+
reject(signal.reason)
|
|
530
|
+
}
|
|
531
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
532
|
+
if (signal.aborted) abort()
|
|
533
|
+
})
|
|
534
|
+
])
|
|
535
|
+
} finally {
|
|
536
|
+
if (abort) signal.removeEventListener('abort', abort)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function withRedisConcurrencyLimit<T>(
|
|
541
|
+
run: () => Promise<T>,
|
|
542
|
+
signal?: AbortSignal
|
|
543
|
+
) {
|
|
544
|
+
signal?.throwIfAborted()
|
|
545
|
+
if (degraded) return run()
|
|
546
|
+
|
|
547
|
+
const token = createToken()
|
|
548
|
+
const granted = await waitForGrant(slotPump.acquire(token), signal, () => {
|
|
549
|
+
void release(token)
|
|
550
|
+
})
|
|
551
|
+
if (!granted) {
|
|
552
|
+
signal?.throwIfAborted()
|
|
553
|
+
return run()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
heldTokens.add(token)
|
|
557
|
+
scheduleHeartbeat()
|
|
558
|
+
try {
|
|
559
|
+
// A start token is never released - it ages out of the sliding window.
|
|
560
|
+
if (startPump)
|
|
561
|
+
await waitForGrant(startPump.acquire(createToken()), signal, () => {})
|
|
562
|
+
signal?.throwIfAborted()
|
|
563
|
+
return await run()
|
|
564
|
+
} finally {
|
|
565
|
+
heldTokens.delete(token)
|
|
566
|
+
await release(token)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Only for limiters that are created on demand. Callers hold no slot by the
|
|
572
|
+
* time they discard one - `withRedisConcurrencyLimit` awaits its release
|
|
573
|
+
* before it resolves - so there is nothing left to heartbeat.
|
|
574
|
+
*/
|
|
575
|
+
function dispose() {
|
|
576
|
+
if (probeTimer) {
|
|
577
|
+
clearTimeout(probeTimer)
|
|
578
|
+
probeTimer = null
|
|
579
|
+
}
|
|
580
|
+
if (heartbeatTimer) {
|
|
581
|
+
clearTimeout(heartbeatTimer)
|
|
582
|
+
heartbeatTimer = null
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return Object.assign(withRedisConcurrencyLimit, {
|
|
587
|
+
tripCooldown,
|
|
588
|
+
queueDepth,
|
|
589
|
+
dispose
|
|
590
|
+
})
|
|
591
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createManagedRedis,
|
|
3
|
+
type ManagedRedis,
|
|
4
|
+
type ManagedRedisOptions,
|
|
5
|
+
type RedisErrorContext,
|
|
6
|
+
type RedisMessageHandler,
|
|
7
|
+
type RedisRole
|
|
8
|
+
} from './client'
|
|
9
|
+
export {
|
|
10
|
+
type ConcurrencyLimitStage,
|
|
11
|
+
createRedisConcurrencyLimit,
|
|
12
|
+
type RedisConcurrencyLimit,
|
|
13
|
+
type RedisConcurrencyLimitOptions
|
|
14
|
+
} from './concurrency-limit'
|
|
15
|
+
export { createLuaScript, type RedisSend } from './lua-script'
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** One raw Redis command, e.g. `(command, args) => redis.getClient().send(command, args)`. */
|
|
2
|
+
export type RedisSend = (command: string, args: string[]) => Promise<unknown>
|
|
3
|
+
|
|
4
|
+
function isNoScriptError(error: unknown) {
|
|
5
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
6
|
+
return message.includes('NOSCRIPT')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ship a Lua script by SHA instead of resending its source on every call.
|
|
11
|
+
*
|
|
12
|
+
* Redis forgets its script cache on restart and on `SCRIPT FLUSH`, so a
|
|
13
|
+
* `NOSCRIPT` reply falls back to an inline `EVAL` (which repopulates the cache)
|
|
14
|
+
* and drops the stale digest.
|
|
15
|
+
*
|
|
16
|
+
* The digest is cached per instance rather than per module so each limiter -
|
|
17
|
+
* and each test fake - owns its own cache.
|
|
18
|
+
*/
|
|
19
|
+
export function createLuaScript(send: RedisSend, source: string) {
|
|
20
|
+
let sha: string | null = null
|
|
21
|
+
let loading: Promise<string> | null = null
|
|
22
|
+
|
|
23
|
+
function load() {
|
|
24
|
+
loading ??= send('SCRIPT', ['LOAD', source])
|
|
25
|
+
.then(String)
|
|
26
|
+
.finally(() => {
|
|
27
|
+
loading = null
|
|
28
|
+
})
|
|
29
|
+
return loading
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return async function evalScript(keys: string[], args: string[]) {
|
|
33
|
+
const tail = [String(keys.length), ...keys, ...args]
|
|
34
|
+
sha ??= await load()
|
|
35
|
+
try {
|
|
36
|
+
return await send('EVALSHA', [sha, ...tail])
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (!isNoScriptError(error)) throw error
|
|
39
|
+
sha = null
|
|
40
|
+
return await send('EVAL', [source, ...tail])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/secrets/README.md
CHANGED
|
@@ -87,7 +87,11 @@ tokens belong in server or authenticated upload steps, outside this key list.
|
|
|
87
87
|
| `cli` | Uses the signed-in CLI session even when client credentials exist. |
|
|
88
88
|
|
|
89
89
|
Credentials come from `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET`.
|
|
90
|
-
Override those names with `clientIdEnvKey` and `clientSecretEnvKey`.
|
|
90
|
+
Override those names with `clientIdEnvKey` and `clientSecretEnvKey`. When neither
|
|
91
|
+
is set, `auto` and `http` read a machine identity from
|
|
92
|
+
`~/.infisical/universal-auth.json` (`INFISICAL_UNIVERSAL_AUTH_FILE` overrides the
|
|
93
|
+
path): `{ "clientId", "clientSecret", "siteUrl" }`, used only for that `siteUrl`'s
|
|
94
|
+
instance. agentgate writes this file on every machine of a user. HTTP
|
|
91
95
|
failures never switch to another authentication mode. If all required keys
|
|
92
96
|
are already supplied by genuine environment overrides, authentication is skipped.
|
|
93
97
|
|