switchroom 0.19.26 → 0.19.27
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/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression suite for the 2026-07-27 activity-feed flood ban.
|
|
3
|
+
*
|
|
4
|
+
* What happened: agent `overlord`'s live activity/worker feed edited its card
|
|
5
|
+
* at a sustained 4-17 `editMessageText`/min on ONE DM chat for hours (326
|
|
6
|
+
* edits against 6 real replies in the final 20 minutes). Telegram answered
|
|
7
|
+
* with a **15908-second** per-chat flood ban, which severed every outbound
|
|
8
|
+
* reply — a cosmetic progress indicator took out the agent's entire ability
|
|
9
|
+
* to communicate.
|
|
10
|
+
*
|
|
11
|
+
* The edit-flood fuse was already installed. It did not bind ONCE, because
|
|
12
|
+
* its ceilings (20 edits/60s per message, 30 edits/60s per chat) sat ABOVE
|
|
13
|
+
* the rate that earned the ban, and because it was class-blind: it could not
|
|
14
|
+
* tell a repaint from a reply, so it could not hold repaints to a low rate
|
|
15
|
+
* without also throttling answers.
|
|
16
|
+
*
|
|
17
|
+
* Every test here asserts an OUTCOME at the transformer seam — how many calls
|
|
18
|
+
* actually reached the API — under a scenario drawn from the incident. Each
|
|
19
|
+
* one fails if the cosmetic ceiling, the reply reservation, the class
|
|
20
|
+
* propagation, or the escalating 429 backoff is removed.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect } from 'vitest'
|
|
23
|
+
import {
|
|
24
|
+
createEditFloodFuse,
|
|
25
|
+
editFloodFuseConfigFromEnv,
|
|
26
|
+
EDIT_FLOOD_FUSE_DEFAULTS,
|
|
27
|
+
} from '../edit-flood-fuse.js'
|
|
28
|
+
import { withOutboundClass, currentOutboundClass } from '../outbound-class.js'
|
|
29
|
+
import { createSendGate, type Clock } from '../send-gate.js'
|
|
30
|
+
|
|
31
|
+
const CHAT = '1001'
|
|
32
|
+
const CARD = 4242
|
|
33
|
+
|
|
34
|
+
class FakeClock implements Clock {
|
|
35
|
+
private cur = 0
|
|
36
|
+
private seq = 0
|
|
37
|
+
private timers: { at: number; id: number; resolve: () => void }[] = []
|
|
38
|
+
|
|
39
|
+
now(): number { return this.cur }
|
|
40
|
+
|
|
41
|
+
sleep(ms: number): Promise<void> {
|
|
42
|
+
return new Promise<void>((resolve) => {
|
|
43
|
+
this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async advance(ms: number): Promise<void> {
|
|
48
|
+
const target = this.cur + ms
|
|
49
|
+
for (;;) {
|
|
50
|
+
await flush()
|
|
51
|
+
const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
|
|
52
|
+
if (due.length === 0) break
|
|
53
|
+
const t = due[0]!
|
|
54
|
+
this.timers = this.timers.filter((x) => x !== t)
|
|
55
|
+
this.cur = t.at
|
|
56
|
+
t.resolve()
|
|
57
|
+
await flush()
|
|
58
|
+
}
|
|
59
|
+
this.cur = target
|
|
60
|
+
await flush()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function flush(): Promise<void> {
|
|
65
|
+
return new Promise((r) => setImmediate(r))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('feed flood ban — a cosmetic edit stream is held below the banned rate', () => {
|
|
69
|
+
it('replays the incident cadence: ~17 feed edits/min offered for 10 min admit ≤ the cosmetic ceiling', async () => {
|
|
70
|
+
const clock = new FakeClock()
|
|
71
|
+
const landed: number[] = []
|
|
72
|
+
const fuse = createEditFloodFuse({ clock })
|
|
73
|
+
|
|
74
|
+
// The incident's peak 10-minute bucket: 174 editMessageText calls against
|
|
75
|
+
// one card in one chat. Offered at a steady ~3.4s spacing, exactly as the
|
|
76
|
+
// feed heartbeat + worker ticks produced them.
|
|
77
|
+
const OFFERED = 174
|
|
78
|
+
const SPAN_MS = 600_000
|
|
79
|
+
const inflight: Promise<unknown>[] = []
|
|
80
|
+
for (let i = 0; i < OFFERED; i++) {
|
|
81
|
+
inflight.push(
|
|
82
|
+
withOutboundClass('cosmetic', () => fuse.apply(
|
|
83
|
+
'editMessageText',
|
|
84
|
+
{ chat_id: CHAT, message_id: CARD, text: `frame ${i}` },
|
|
85
|
+
async () => { landed.push(i); return true },
|
|
86
|
+
)),
|
|
87
|
+
)
|
|
88
|
+
await clock.advance(Math.floor(SPAN_MS / OFFERED))
|
|
89
|
+
}
|
|
90
|
+
await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
|
|
91
|
+
await Promise.all(inflight)
|
|
92
|
+
|
|
93
|
+
const perMinuteAdmitted = landed.length / (SPAN_MS / 60_000)
|
|
94
|
+
// The ceiling that must hold. 4/min per card is the cosmetic per-message
|
|
95
|
+
// default; a little slack covers window-boundary effects.
|
|
96
|
+
expect(perMinuteAdmitted).toBeLessThanOrEqual(
|
|
97
|
+
EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerMessageMaxPerWindow + 1)
|
|
98
|
+
// …and it must be a REDUCTION, not a no-op: the offered rate is the rate
|
|
99
|
+
// that earned the ban.
|
|
100
|
+
expect(landed.length).toBeLessThan(OFFERED / 3)
|
|
101
|
+
// The card is still live — throttled, never silenced.
|
|
102
|
+
expect(landed.length).toBeGreaterThan(0)
|
|
103
|
+
|
|
104
|
+
// The pre-fix ceilings would not have bound at all. This is the assertion
|
|
105
|
+
// that makes the whole suite non-vacuous: 174/10min = 17.4/min, which is
|
|
106
|
+
// under BOTH old ceilings, which is precisely why the ban happened.
|
|
107
|
+
const offeredPerMin = OFFERED / (SPAN_MS / 60_000)
|
|
108
|
+
expect(offeredPerMin).toBeLessThan(EDIT_FLOOD_FUSE_DEFAULTS.perMessageMaxPerWindow)
|
|
109
|
+
expect(offeredPerMin).toBeLessThan(EDIT_FLOOD_FUSE_DEFAULTS.perChatEditMaxPerWindow)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('coalesces rather than loses state: the LAST frame offered is the last frame painted', async () => {
|
|
113
|
+
const clock = new FakeClock()
|
|
114
|
+
const landed: string[] = []
|
|
115
|
+
const fuse = createEditFloodFuse({ clock })
|
|
116
|
+
|
|
117
|
+
const inflight: Promise<unknown>[] = []
|
|
118
|
+
for (let i = 1; i <= 60; i++) {
|
|
119
|
+
inflight.push(
|
|
120
|
+
withOutboundClass('cosmetic', () => fuse.apply(
|
|
121
|
+
'editMessageText', { chat_id: CHAT, message_id: CARD, text: `v${i}` },
|
|
122
|
+
async () => { landed.push(`v${i}`); return true },
|
|
123
|
+
)),
|
|
124
|
+
)
|
|
125
|
+
await clock.advance(1_000)
|
|
126
|
+
}
|
|
127
|
+
await clock.advance(120_000)
|
|
128
|
+
await Promise.all(inflight)
|
|
129
|
+
|
|
130
|
+
// Backpressure, not dropping-on-the-floor: the operator sees CURRENT state
|
|
131
|
+
// at every permitted edit, just less often. If the newest frame could be
|
|
132
|
+
// shed the card would go stale, which is the failure mode a plain rate
|
|
133
|
+
// limiter would introduce.
|
|
134
|
+
expect(landed[landed.length - 1]).toBe('v60')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('N concurrent worker cards in one chat share the cosmetic allowance', async () => {
|
|
138
|
+
const clock = new FakeClock()
|
|
139
|
+
const landed: string[] = []
|
|
140
|
+
const fuse = createEditFloodFuse({ clock })
|
|
141
|
+
|
|
142
|
+
// 6 background workers, each with its own card, each individually inside
|
|
143
|
+
// the per-message ceiling. Only a per-CHAT cosmetic ceiling stops their sum.
|
|
144
|
+
const inflight: Promise<unknown>[] = []
|
|
145
|
+
// 9 rounds ⇒ every offer lands inside ONE 60s window, so the snapshot below
|
|
146
|
+
// measures the ceiling and not a window-boundary refill.
|
|
147
|
+
for (let round = 0; round < 9; round++) {
|
|
148
|
+
for (let w = 0; w < 6; w++) {
|
|
149
|
+
inflight.push(
|
|
150
|
+
withOutboundClass('cosmetic', () => fuse.apply(
|
|
151
|
+
'editMessageText', { chat_id: CHAT, message_id: 500 + w, text: `w${w} r${round}` },
|
|
152
|
+
async () => { landed.push(`w${w} r${round}`); return true },
|
|
153
|
+
)),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
await clock.advance(6_000) // the gateway's FEED_HEARTBEAT_TICK_MS
|
|
157
|
+
}
|
|
158
|
+
const inWindow = landed.length
|
|
159
|
+
await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
|
|
160
|
+
await Promise.all(inflight)
|
|
161
|
+
|
|
162
|
+
// 54 offers across one 60s window; the chat's cosmetic allowance binds.
|
|
163
|
+
expect(inWindow).toBeLessThanOrEqual(
|
|
164
|
+
EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerChatMaxPerWindow
|
|
165
|
+
+ EDIT_FLOOD_FUSE_DEFAULTS.lateReleaseMaxPerWindow + 1)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('feed flood ban — a real reply is never starved by feed edits', () => {
|
|
170
|
+
it('delivers a reply while the cosmetic budget is fully exhausted', async () => {
|
|
171
|
+
const clock = new FakeClock()
|
|
172
|
+
const replies: string[] = []
|
|
173
|
+
const fuse = createEditFloodFuse({ clock })
|
|
174
|
+
|
|
175
|
+
// Saturate: 200 cosmetic edits offered as fast as the feed can produce
|
|
176
|
+
// them, spread across several cards so every cosmetic tier is pinned.
|
|
177
|
+
const noise: Promise<unknown>[] = []
|
|
178
|
+
for (let i = 0; i < 200; i++) {
|
|
179
|
+
noise.push(
|
|
180
|
+
withOutboundClass('cosmetic', () => fuse.apply(
|
|
181
|
+
'editMessageText', { chat_id: CHAT, message_id: 700 + (i % 5), text: `n${i}` },
|
|
182
|
+
async () => true,
|
|
183
|
+
)),
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
await clock.advance(500)
|
|
187
|
+
|
|
188
|
+
// The operator's actual answer, issued mid-flood.
|
|
189
|
+
const reply = withOutboundClass('critical', () => fuse.apply(
|
|
190
|
+
'sendMessage', { chat_id: CHAT, text: 'the answer' },
|
|
191
|
+
async () => { replies.push('the answer'); return { message_id: 1 } },
|
|
192
|
+
))
|
|
193
|
+
// It must land, and land PROMPTLY — inside the reserve, not after the
|
|
194
|
+
// cosmetic window slides. This is the property the incident violated: the
|
|
195
|
+
// feed consumed the chat's budget and the reply could not go out at all.
|
|
196
|
+
await clock.advance(2_000)
|
|
197
|
+
await reply
|
|
198
|
+
expect(replies).toEqual(['the answer'])
|
|
199
|
+
|
|
200
|
+
await clock.advance(300_000)
|
|
201
|
+
await Promise.all(noise)
|
|
202
|
+
// Not one reply was dropped by the fuse.
|
|
203
|
+
expect(fuse.stats().dropped).toBeGreaterThan(0) // cosmetic edits were shed…
|
|
204
|
+
expect(replies).toHaveLength(1) // …the answer was not.
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('does not throttle a non-cosmetic edit with the cosmetic ceiling', async () => {
|
|
208
|
+
const clock = new FakeClock()
|
|
209
|
+
const landed: number[] = []
|
|
210
|
+
const fuse = createEditFloodFuse({ clock })
|
|
211
|
+
|
|
212
|
+
// An approval card being re-rendered by operator taps: `useful`, low
|
|
213
|
+
// volume, but above the 4/min cosmetic ceiling. Throttling it would be a
|
|
214
|
+
// regression — the fix must separate classes, not just lower one number.
|
|
215
|
+
const inflight: Promise<unknown>[] = []
|
|
216
|
+
for (let i = 0; i < 10; i++) {
|
|
217
|
+
inflight.push(
|
|
218
|
+
withOutboundClass('useful', () => fuse.apply(
|
|
219
|
+
'editMessageText', { chat_id: CHAT, message_id: 900, text: `tap ${i}` },
|
|
220
|
+
async () => { landed.push(i); return true },
|
|
221
|
+
)),
|
|
222
|
+
)
|
|
223
|
+
await clock.advance(2_000)
|
|
224
|
+
}
|
|
225
|
+
await clock.advance(60_000)
|
|
226
|
+
await Promise.all(inflight)
|
|
227
|
+
|
|
228
|
+
expect(landed).toHaveLength(10)
|
|
229
|
+
expect(landed[landed.length - 1]).toBe(9)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('treats an UNTAGGED edit as cosmetic — a new unkeyed call site is limited by default', async () => {
|
|
233
|
+
const clock = new FakeClock()
|
|
234
|
+
const landed: number[] = []
|
|
235
|
+
const fuse = createEditFloodFuse({ clock })
|
|
236
|
+
|
|
237
|
+
// No `withOutboundClass` wrapper at all: the shape of a future call site
|
|
238
|
+
// that never heard of the send gate. That is exactly how both the
|
|
239
|
+
// 2026-07-25 and 2026-07-27 floods started.
|
|
240
|
+
const inflight: Promise<unknown>[] = []
|
|
241
|
+
for (let i = 0; i < 60; i++) {
|
|
242
|
+
inflight.push(fuse.apply(
|
|
243
|
+
'editMessageText', { chat_id: CHAT, message_id: 950, text: `u${i}` },
|
|
244
|
+
async () => { landed.push(i); return true },
|
|
245
|
+
))
|
|
246
|
+
await clock.advance(1_000)
|
|
247
|
+
}
|
|
248
|
+
const inWindow = landed.length
|
|
249
|
+
await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
|
|
250
|
+
await Promise.all(inflight)
|
|
251
|
+
|
|
252
|
+
expect(inWindow).toBeLessThanOrEqual(
|
|
253
|
+
EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerMessageMaxPerWindow + 1)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('treats an UNTAGGED send as critical — never dropped', async () => {
|
|
257
|
+
const clock = new FakeClock()
|
|
258
|
+
const landed: number[] = []
|
|
259
|
+
const fuse = createEditFloodFuse({ clock })
|
|
260
|
+
const inflight: Promise<unknown>[] = []
|
|
261
|
+
for (let i = 0; i < 40; i++) {
|
|
262
|
+
inflight.push(fuse.apply('sendMessage', { chat_id: CHAT, text: `s${i}` },
|
|
263
|
+
async () => { landed.push(i); return { message_id: i } }))
|
|
264
|
+
}
|
|
265
|
+
await clock.advance(600_000)
|
|
266
|
+
await Promise.all(inflight)
|
|
267
|
+
expect(landed).toHaveLength(40)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('feed flood ban — a 429 reduces the subsequent feed edit rate', () => {
|
|
272
|
+
it('escalates: each 429 compounds the tightening rather than re-applying one step', async () => {
|
|
273
|
+
const clock = new FakeClock()
|
|
274
|
+
const fuse = createEditFloodFuse({ clock })
|
|
275
|
+
const base = EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerChatMaxPerWindow
|
|
276
|
+
|
|
277
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
278
|
+
expect(fuse.stats().cosmeticPerChatCeiling).toBe(base)
|
|
279
|
+
|
|
280
|
+
const flood = (): unknown => Object.assign(
|
|
281
|
+
new Error('Too Many Requests: retry after 3'),
|
|
282
|
+
{ error_code: 429, parameters: { retry_after: 3 } },
|
|
283
|
+
)
|
|
284
|
+
const takeOne429 = async (): Promise<void> => {
|
|
285
|
+
await expect(fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' },
|
|
286
|
+
async () => { throw flood() })).rejects.toThrow(/Too Many Requests/)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await takeOne429()
|
|
290
|
+
const after1 = fuse.stats().cosmeticPerChatCeiling
|
|
291
|
+
await clock.advance(1_000)
|
|
292
|
+
await takeOne429()
|
|
293
|
+
const after2 = fuse.stats().cosmeticPerChatCeiling
|
|
294
|
+
|
|
295
|
+
// Pre-fix a second 429 changed nothing: the tightening was a single flat
|
|
296
|
+
// 0.5× re-armed for another 10 minutes, so a stream taking repeated small
|
|
297
|
+
// 429s held the SAME rate and walked into the long ban.
|
|
298
|
+
expect(after1).toBeLessThan(base)
|
|
299
|
+
expect(after2).toBeLessThan(after1)
|
|
300
|
+
expect(fuse.stats().tightenLevel).toBe(2)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('a 429 actually reduces how many feed edits get through afterwards', async () => {
|
|
304
|
+
const clock = new FakeClock()
|
|
305
|
+
const fuse = createEditFloodFuse({ clock })
|
|
306
|
+
|
|
307
|
+
const offerBurst = async (tag: string): Promise<number> => {
|
|
308
|
+
const landed: number[] = []
|
|
309
|
+
const inflight: Promise<unknown>[] = []
|
|
310
|
+
for (let i = 0; i < 40; i++) {
|
|
311
|
+
inflight.push(
|
|
312
|
+
withOutboundClass('cosmetic', () => fuse.apply(
|
|
313
|
+
'editMessageText', { chat_id: CHAT, message_id: 1234, text: `${tag}${i}` },
|
|
314
|
+
async () => { landed.push(i); return true },
|
|
315
|
+
)),
|
|
316
|
+
)
|
|
317
|
+
await clock.advance(500)
|
|
318
|
+
}
|
|
319
|
+
const n = landed.length
|
|
320
|
+
await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
|
|
321
|
+
await Promise.all(inflight)
|
|
322
|
+
return n
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const before = await offerBurst('a')
|
|
326
|
+
// Let every window fully drain so the comparison isolates the tightening.
|
|
327
|
+
await clock.advance(120_000)
|
|
328
|
+
|
|
329
|
+
await expect(fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => {
|
|
330
|
+
throw Object.assign(new Error('Too Many Requests: retry after 3'),
|
|
331
|
+
{ error_code: 429, parameters: { retry_after: 3 } })
|
|
332
|
+
})).rejects.toThrow(/Too Many Requests/)
|
|
333
|
+
await clock.advance(120_000)
|
|
334
|
+
|
|
335
|
+
const after = await offerBurst('b')
|
|
336
|
+
|
|
337
|
+
expect(before).toBeGreaterThan(0)
|
|
338
|
+
expect(after).toBeLessThan(before)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('decays one level at a time rather than restoring full rate in one step', async () => {
|
|
342
|
+
const clock = new FakeClock()
|
|
343
|
+
const fuse = createEditFloodFuse({ clock, tightenMs: 60_000 })
|
|
344
|
+
const base = EDIT_FLOOD_FUSE_DEFAULTS.cosmeticPerMessageMaxPerWindow
|
|
345
|
+
|
|
346
|
+
for (let i = 0; i < 3; i++) {
|
|
347
|
+
await expect(fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => {
|
|
348
|
+
throw Object.assign(new Error('Too Many Requests: retry after 3'),
|
|
349
|
+
{ error_code: 429, parameters: { retry_after: 3 } })
|
|
350
|
+
})).rejects.toThrow(/Too Many Requests/)
|
|
351
|
+
}
|
|
352
|
+
expect(fuse.stats().tightenLevel).toBe(3)
|
|
353
|
+
|
|
354
|
+
// #3856: the hold is now `max(tightenMs, retry_after * 1000 + tightenMs)`,
|
|
355
|
+
// so a stated `retry_after: 3` holds for 63s — the fuse must not begin
|
|
356
|
+
// decaying while Telegram's own stated cooldown is still running. The
|
|
357
|
+
// one-level-at-a-time decay this test guards is unchanged; only the FIRST
|
|
358
|
+
// hold is longer, by the stated 3s.
|
|
359
|
+
await clock.advance(63_001)
|
|
360
|
+
expect(fuse.stats().tightenLevel).toBe(2)
|
|
361
|
+
await clock.advance(60_001)
|
|
362
|
+
expect(fuse.stats().tightenLevel).toBe(1)
|
|
363
|
+
await clock.advance(60_001)
|
|
364
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
365
|
+
expect(fuse.stats().cosmeticPerMessageCeiling).toBe(base)
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
describe('feed flood ban — the send gate publishes the class the fuse depends on', () => {
|
|
370
|
+
it('a cosmetic gated EDIT is visible as cosmetic at the transformer seam', async () => {
|
|
371
|
+
const gate = createSendGate({ enabled: true })
|
|
372
|
+
let seen: string | undefined
|
|
373
|
+
await gate.gate(
|
|
374
|
+
async () => { seen = currentOutboundClass(); return true },
|
|
375
|
+
{ chat_id: CHAT, messageId: CARD, editPayload: 'body', priorityClass: 'cosmetic' },
|
|
376
|
+
)
|
|
377
|
+
// Without this propagation the fuse's class awareness is decorative: every
|
|
378
|
+
// edit would fall back to the untagged default and the classes would never
|
|
379
|
+
// actually separate.
|
|
380
|
+
expect(seen).toBe('cosmetic')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('a critical gated SEND is visible as critical at the transformer seam', async () => {
|
|
384
|
+
const gate = createSendGate({ enabled: true })
|
|
385
|
+
let seen: string | undefined
|
|
386
|
+
await gate.gate(
|
|
387
|
+
async () => { seen = currentOutboundClass(); return true },
|
|
388
|
+
{ chat_id: CHAT, priorityClass: 'critical' },
|
|
389
|
+
)
|
|
390
|
+
expect(seen).toBe('critical')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('an untagged gated send reports the gate\'s own UNTAGGED_SEND_CLASS', async () => {
|
|
394
|
+
const gate = createSendGate({ enabled: true })
|
|
395
|
+
let seen: string | undefined
|
|
396
|
+
await gate.gate(async () => { seen = currentOutboundClass(); return true }, { chat_id: CHAT })
|
|
397
|
+
expect(seen).toBe('critical')
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
describe('feed flood ban — the ceilings are operator-configurable', () => {
|
|
402
|
+
it('reads every ceiling from env, and keeps defaults when unset', () => {
|
|
403
|
+
// Pre-fix the ONLY knob was SWITCHROOM_EDIT_FUSE=0, which turns the whole
|
|
404
|
+
// failsafe off — an operator being flooded had no way to tighten it.
|
|
405
|
+
const cfg = editFloodFuseConfigFromEnv({
|
|
406
|
+
SWITCHROOM_FEED_EDIT_MAX_PER_MSG_PER_MIN: '2',
|
|
407
|
+
SWITCHROOM_FEED_EDIT_MAX_PER_CHAT_PER_MIN: '3',
|
|
408
|
+
SWITCHROOM_CHAT_TOTAL_MAX_PER_MIN: '15',
|
|
409
|
+
SWITCHROOM_CHAT_REPLY_RESERVE: '9',
|
|
410
|
+
})
|
|
411
|
+
expect(cfg).toMatchObject({
|
|
412
|
+
enabled: true,
|
|
413
|
+
cosmeticPerMessageMaxPerWindow: 2,
|
|
414
|
+
cosmeticPerChatMaxPerWindow: 3,
|
|
415
|
+
perChatTotalMaxPerWindow: 15,
|
|
416
|
+
perChatReplyReserve: 9,
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
const bare = editFloodFuseConfigFromEnv({})
|
|
420
|
+
expect(bare.cosmeticPerMessageMaxPerWindow).toBeUndefined()
|
|
421
|
+
expect(bare.enabled).toBe(true)
|
|
422
|
+
expect(editFloodFuseConfigFromEnv({ SWITCHROOM_EDIT_FUSE: '0' }).enabled).toBe(false)
|
|
423
|
+
// Garbage must not silently become 0 (which would deadlock every card).
|
|
424
|
+
expect(editFloodFuseConfigFromEnv({ SWITCHROOM_FEED_EDIT_MAX_PER_MSG_PER_MIN: 'lots' })
|
|
425
|
+
.cosmeticPerMessageMaxPerWindow).toBeUndefined()
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('an env-tightened ceiling actually binds', async () => {
|
|
429
|
+
const clock = new FakeClock()
|
|
430
|
+
const fuse = createEditFloodFuse({
|
|
431
|
+
clock, ...editFloodFuseConfigFromEnv({ SWITCHROOM_FEED_EDIT_MAX_PER_MSG_PER_MIN: '1' }),
|
|
432
|
+
})
|
|
433
|
+
const landed: number[] = []
|
|
434
|
+
const inflight: Promise<unknown>[] = []
|
|
435
|
+
for (let i = 0; i < 30; i++) {
|
|
436
|
+
inflight.push(
|
|
437
|
+
withOutboundClass('cosmetic', () => fuse.apply(
|
|
438
|
+
'editMessageText', { chat_id: CHAT, message_id: 4321, text: `e${i}` },
|
|
439
|
+
async () => { landed.push(i); return true },
|
|
440
|
+
)),
|
|
441
|
+
)
|
|
442
|
+
await clock.advance(1_000)
|
|
443
|
+
}
|
|
444
|
+
const inWindow = landed.length
|
|
445
|
+
await clock.advance(EDIT_FLOOD_FUSE_DEFAULTS.maxDeferMs + 1_000)
|
|
446
|
+
await Promise.all(inflight)
|
|
447
|
+
expect(inWindow).toBeLessThanOrEqual(2)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('a reserve larger than the total budget cannot deadlock cosmetic traffic', async () => {
|
|
451
|
+
const clock = new FakeClock()
|
|
452
|
+
const fuse = createEditFloodFuse({
|
|
453
|
+
clock, perChatTotalMaxPerWindow: 5, perChatReplyReserve: 99,
|
|
454
|
+
})
|
|
455
|
+
const landed: number[] = []
|
|
456
|
+
await withOutboundClass('cosmetic', () => fuse.apply(
|
|
457
|
+
'editMessageText', { chat_id: CHAT, message_id: 8888, text: 'x' },
|
|
458
|
+
async () => { landed.push(1); return true },
|
|
459
|
+
))
|
|
460
|
+
expect(landed).toHaveLength(1)
|
|
461
|
+
})
|
|
462
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real 2026-07 Telegram 429 stream, verbatim.
|
|
3
|
+
*
|
|
4
|
+
* Every `telegram gateway: 429 …` line one production agent's gateway emitted
|
|
5
|
+
* between 2026-07-12 and 2026-07-27, extracted from `gateway-supervisor.log`
|
|
6
|
+
* (plus its rotated `.gz`) as
|
|
7
|
+
* `[<iso>] … (flood ban of <n>s | rate limited, waiting <n>s)`.
|
|
8
|
+
* 170 observations; nothing sampled, synthesised, or reordered. Timestamps and
|
|
9
|
+
* `retry_after` values only — no ids, no chat content.
|
|
10
|
+
*
|
|
11
|
+
* This is the fixture the flood-pressure classifier is proved against, because
|
|
12
|
+
* the shape is the whole problem:
|
|
13
|
+
*
|
|
14
|
+
* - 72 of the 170 are short rate nudges, the largest being 5s. They are
|
|
15
|
+
* invisible to the operator: `retryApiCall` sleeps them and the send lands.
|
|
16
|
+
* - The smallest `retry_after` belonging to a real ban is 282s. There is no
|
|
17
|
+
* observed traffic between 5s and 282s, so the benign band is wide.
|
|
18
|
+
* - 87 of the remaining observations are ONE ban (2026-07-27T20:19:56Z,
|
|
19
|
+
* `retry_after=15908`) re-observed every ~5s as it counted down. Any signal
|
|
20
|
+
* built on event COUNT explodes here, during the outage it should have
|
|
21
|
+
* predicted.
|
|
22
|
+
*
|
|
23
|
+
* The four real bans:
|
|
24
|
+
* 2026-07-12T05:06:46Z retry_after=21397 (5.9h)
|
|
25
|
+
* 2026-07-13T03:17:47Z retry_after=27951 (7.8h)
|
|
26
|
+
* 2026-07-25T23:43:07Z retry_after=3713 (62min)
|
|
27
|
+
* 2026-07-27T20:19:56Z retry_after=15908 (4.4h) ← the incident
|
|
28
|
+
*
|
|
29
|
+
* Generated once from the live logs; do not edit by hand.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** One observed 429: ISO timestamp + Telegram's `parameters.retry_after`. */
|
|
33
|
+
export interface RawFlood429 {
|
|
34
|
+
iso: string
|
|
35
|
+
ts: number
|
|
36
|
+
retryAfterSec: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const RAW: ReadonlyArray<{ iso: string; retryAfterSec: number }> = [
|
|
40
|
+
{ iso: "2026-07-12T00:43:42.969Z", retryAfterSec: 3 },
|
|
41
|
+
{ iso: "2026-07-12T00:43:52.754Z", retryAfterSec: 3 },
|
|
42
|
+
{ iso: "2026-07-12T05:06:46.641Z", retryAfterSec: 21397 },
|
|
43
|
+
{ iso: "2026-07-12T23:39:24.579Z", retryAfterSec: 436 },
|
|
44
|
+
{ iso: "2026-07-13T00:37:18.647Z", retryAfterSec: 563 },
|
|
45
|
+
{ iso: "2026-07-13T00:37:19.162Z", retryAfterSec: 563 },
|
|
46
|
+
{ iso: "2026-07-13T01:42:00.795Z", retryAfterSec: 283 },
|
|
47
|
+
{ iso: "2026-07-13T01:42:01.269Z", retryAfterSec: 282 },
|
|
48
|
+
{ iso: "2026-07-13T01:42:01.741Z", retryAfterSec: 282 },
|
|
49
|
+
{ iso: "2026-07-13T02:39:43.308Z", retryAfterSec: 425 },
|
|
50
|
+
{ iso: "2026-07-13T03:17:47.764Z", retryAfterSec: 27951 },
|
|
51
|
+
{ iso: "2026-07-14T01:00:55.498Z", retryAfterSec: 3 },
|
|
52
|
+
{ iso: "2026-07-14T01:01:05.283Z", retryAfterSec: 3 },
|
|
53
|
+
{ iso: "2026-07-14T01:42:51.141Z", retryAfterSec: 3 },
|
|
54
|
+
{ iso: "2026-07-14T01:43:00.941Z", retryAfterSec: 3 },
|
|
55
|
+
{ iso: "2026-07-14T10:17:08.494Z", retryAfterSec: 3 },
|
|
56
|
+
{ iso: "2026-07-14T10:17:18.394Z", retryAfterSec: 3 },
|
|
57
|
+
{ iso: "2026-07-15T06:47:31.287Z", retryAfterSec: 3 },
|
|
58
|
+
{ iso: "2026-07-15T11:28:14.054Z", retryAfterSec: 3 },
|
|
59
|
+
{ iso: "2026-07-15T11:28:23.913Z", retryAfterSec: 3 },
|
|
60
|
+
{ iso: "2026-07-16T01:48:28.656Z", retryAfterSec: 3 },
|
|
61
|
+
{ iso: "2026-07-16T01:48:38.477Z", retryAfterSec: 3 },
|
|
62
|
+
{ iso: "2026-07-16T02:37:16.659Z", retryAfterSec: 3 },
|
|
63
|
+
{ iso: "2026-07-16T02:37:26.496Z", retryAfterSec: 3 },
|
|
64
|
+
{ iso: "2026-07-16T07:39:14.389Z", retryAfterSec: 3 },
|
|
65
|
+
{ iso: "2026-07-16T07:39:24.206Z", retryAfterSec: 3 },
|
|
66
|
+
{ iso: "2026-07-16T19:29:32.965Z", retryAfterSec: 3 },
|
|
67
|
+
{ iso: "2026-07-16T19:29:42.817Z", retryAfterSec: 3 },
|
|
68
|
+
{ iso: "2026-07-16T21:32:57.539Z", retryAfterSec: 5 },
|
|
69
|
+
{ iso: "2026-07-17T00:04:44.818Z", retryAfterSec: 3 },
|
|
70
|
+
{ iso: "2026-07-17T00:04:54.594Z", retryAfterSec: 3 },
|
|
71
|
+
{ iso: "2026-07-17T02:45:40.820Z", retryAfterSec: 3 },
|
|
72
|
+
{ iso: "2026-07-17T02:45:50.608Z", retryAfterSec: 3 },
|
|
73
|
+
{ iso: "2026-07-17T06:10:38.674Z", retryAfterSec: 3 },
|
|
74
|
+
{ iso: "2026-07-17T06:10:48.510Z", retryAfterSec: 3 },
|
|
75
|
+
{ iso: "2026-07-17T07:55:18.855Z", retryAfterSec: 3 },
|
|
76
|
+
{ iso: "2026-07-17T07:55:28.640Z", retryAfterSec: 3 },
|
|
77
|
+
{ iso: "2026-07-17T09:21:15.798Z", retryAfterSec: 3 },
|
|
78
|
+
{ iso: "2026-07-18T22:35:20.897Z", retryAfterSec: 3 },
|
|
79
|
+
{ iso: "2026-07-19T01:25:52.829Z", retryAfterSec: 3 },
|
|
80
|
+
{ iso: "2026-07-19T01:26:02.649Z", retryAfterSec: 3 },
|
|
81
|
+
{ iso: "2026-07-19T03:29:55.399Z", retryAfterSec: 3 },
|
|
82
|
+
{ iso: "2026-07-19T03:30:05.203Z", retryAfterSec: 3 },
|
|
83
|
+
{ iso: "2026-07-19T06:05:47.796Z", retryAfterSec: 3 },
|
|
84
|
+
{ iso: "2026-07-19T06:05:57.632Z", retryAfterSec: 3 },
|
|
85
|
+
{ iso: "2026-07-19T10:21:12.347Z", retryAfterSec: 3 },
|
|
86
|
+
{ iso: "2026-07-19T10:21:22.178Z", retryAfterSec: 3 },
|
|
87
|
+
{ iso: "2026-07-19T10:21:50.826Z", retryAfterSec: 3 },
|
|
88
|
+
{ iso: "2026-07-19T10:22:00.651Z", retryAfterSec: 3 },
|
|
89
|
+
{ iso: "2026-07-19T10:22:10.486Z", retryAfterSec: 3 },
|
|
90
|
+
{ iso: "2026-07-19T10:47:10.609Z", retryAfterSec: 3 },
|
|
91
|
+
{ iso: "2026-07-19T10:47:20.439Z", retryAfterSec: 3 },
|
|
92
|
+
{ iso: "2026-07-19T10:47:30.277Z", retryAfterSec: 3 },
|
|
93
|
+
{ iso: "2026-07-20T01:40:22.311Z", retryAfterSec: 3 },
|
|
94
|
+
{ iso: "2026-07-20T01:40:32.183Z", retryAfterSec: 3 },
|
|
95
|
+
{ iso: "2026-07-20T02:26:37.436Z", retryAfterSec: 3 },
|
|
96
|
+
{ iso: "2026-07-20T02:26:47.514Z", retryAfterSec: 3 },
|
|
97
|
+
{ iso: "2026-07-21T11:14:47.910Z", retryAfterSec: 3 },
|
|
98
|
+
{ iso: "2026-07-21T11:14:57.729Z", retryAfterSec: 3 },
|
|
99
|
+
{ iso: "2026-07-21T22:59:02.412Z", retryAfterSec: 3 },
|
|
100
|
+
{ iso: "2026-07-21T22:59:12.189Z", retryAfterSec: 3 },
|
|
101
|
+
{ iso: "2026-07-23T19:58:39.060Z", retryAfterSec: 3 },
|
|
102
|
+
{ iso: "2026-07-24T08:22:21.126Z", retryAfterSec: 3 },
|
|
103
|
+
{ iso: "2026-07-24T09:00:54.528Z", retryAfterSec: 3 },
|
|
104
|
+
{ iso: "2026-07-24T09:01:04.413Z", retryAfterSec: 3 },
|
|
105
|
+
{ iso: "2026-07-24T21:47:04.852Z", retryAfterSec: 3 },
|
|
106
|
+
{ iso: "2026-07-24T21:49:20.213Z", retryAfterSec: 3 },
|
|
107
|
+
{ iso: "2026-07-24T21:49:30.004Z", retryAfterSec: 3 },
|
|
108
|
+
{ iso: "2026-07-24T21:49:39.786Z", retryAfterSec: 3 },
|
|
109
|
+
{ iso: "2026-07-25T05:24:04.941Z", retryAfterSec: 3 },
|
|
110
|
+
{ iso: "2026-07-25T22:50:39.203Z", retryAfterSec: 3 },
|
|
111
|
+
{ iso: "2026-07-25T22:50:48.981Z", retryAfterSec: 3 },
|
|
112
|
+
{ iso: "2026-07-25T23:43:07.861Z", retryAfterSec: 3713 },
|
|
113
|
+
{ iso: "2026-07-26T14:07:07.428Z", retryAfterSec: 3 },
|
|
114
|
+
{ iso: "2026-07-26T14:07:20.567Z", retryAfterSec: 3 },
|
|
115
|
+
{ iso: "2026-07-26T14:07:30.355Z", retryAfterSec: 3 },
|
|
116
|
+
{ iso: "2026-07-26T22:43:03.042Z", retryAfterSec: 3 },
|
|
117
|
+
{ iso: "2026-07-27T05:50:52.944Z", retryAfterSec: 3 },
|
|
118
|
+
{ iso: "2026-07-27T10:04:58.523Z", retryAfterSec: 3 },
|
|
119
|
+
{ iso: "2026-07-27T10:05:12.142Z", retryAfterSec: 3 },
|
|
120
|
+
{ iso: "2026-07-27T10:05:21.953Z", retryAfterSec: 3 },
|
|
121
|
+
{ iso: "2026-07-27T16:22:32.052Z", retryAfterSec: 3 },
|
|
122
|
+
{ iso: "2026-07-27T20:19:56.704Z", retryAfterSec: 15908 },
|
|
123
|
+
{ iso: "2026-07-27T20:53:27.532Z", retryAfterSec: 13897 },
|
|
124
|
+
{ iso: "2026-07-27T20:53:31.923Z", retryAfterSec: 13893 },
|
|
125
|
+
{ iso: "2026-07-27T20:53:36.919Z", retryAfterSec: 13888 },
|
|
126
|
+
{ iso: "2026-07-27T20:53:41.904Z", retryAfterSec: 13883 },
|
|
127
|
+
{ iso: "2026-07-27T20:53:46.908Z", retryAfterSec: 13878 },
|
|
128
|
+
{ iso: "2026-07-27T20:53:51.904Z", retryAfterSec: 13873 },
|
|
129
|
+
{ iso: "2026-07-27T20:53:56.914Z", retryAfterSec: 13868 },
|
|
130
|
+
{ iso: "2026-07-27T20:54:01.911Z", retryAfterSec: 13863 },
|
|
131
|
+
{ iso: "2026-07-27T20:54:06.907Z", retryAfterSec: 13858 },
|
|
132
|
+
{ iso: "2026-07-27T20:54:11.932Z", retryAfterSec: 13853 },
|
|
133
|
+
{ iso: "2026-07-27T20:54:16.925Z", retryAfterSec: 13848 },
|
|
134
|
+
{ iso: "2026-07-27T20:54:21.929Z", retryAfterSec: 13843 },
|
|
135
|
+
{ iso: "2026-07-27T20:54:26.932Z", retryAfterSec: 13838 },
|
|
136
|
+
{ iso: "2026-07-27T20:54:31.929Z", retryAfterSec: 13833 },
|
|
137
|
+
{ iso: "2026-07-27T20:54:36.940Z", retryAfterSec: 13828 },
|
|
138
|
+
{ iso: "2026-07-27T20:54:41.914Z", retryAfterSec: 13823 },
|
|
139
|
+
{ iso: "2026-07-27T20:54:46.919Z", retryAfterSec: 13818 },
|
|
140
|
+
{ iso: "2026-07-27T20:54:51.949Z", retryAfterSec: 13813 },
|
|
141
|
+
{ iso: "2026-07-27T20:54:56.911Z", retryAfterSec: 13808 },
|
|
142
|
+
{ iso: "2026-07-27T20:55:01.932Z", retryAfterSec: 13803 },
|
|
143
|
+
{ iso: "2026-07-27T20:55:06.918Z", retryAfterSec: 13798 },
|
|
144
|
+
{ iso: "2026-07-27T20:55:11.949Z", retryAfterSec: 13793 },
|
|
145
|
+
{ iso: "2026-07-27T20:55:17.011Z", retryAfterSec: 13788 },
|
|
146
|
+
{ iso: "2026-07-27T20:55:21.997Z", retryAfterSec: 13783 },
|
|
147
|
+
{ iso: "2026-07-27T20:55:26.949Z", retryAfterSec: 13778 },
|
|
148
|
+
{ iso: "2026-07-27T20:55:31.941Z", retryAfterSec: 13773 },
|
|
149
|
+
{ iso: "2026-07-27T20:55:36.943Z", retryAfterSec: 13768 },
|
|
150
|
+
{ iso: "2026-07-27T20:55:41.924Z", retryAfterSec: 13763 },
|
|
151
|
+
{ iso: "2026-07-27T20:55:46.927Z", retryAfterSec: 13758 },
|
|
152
|
+
{ iso: "2026-07-27T20:55:51.928Z", retryAfterSec: 13753 },
|
|
153
|
+
{ iso: "2026-07-27T20:55:56.922Z", retryAfterSec: 13748 },
|
|
154
|
+
{ iso: "2026-07-27T20:56:01.937Z", retryAfterSec: 13743 },
|
|
155
|
+
{ iso: "2026-07-27T20:56:06.922Z", retryAfterSec: 13738 },
|
|
156
|
+
{ iso: "2026-07-27T20:56:11.950Z", retryAfterSec: 13733 },
|
|
157
|
+
{ iso: "2026-07-27T20:56:16.950Z", retryAfterSec: 13728 },
|
|
158
|
+
{ iso: "2026-07-27T20:56:21.954Z", retryAfterSec: 13723 },
|
|
159
|
+
{ iso: "2026-07-27T20:56:26.955Z", retryAfterSec: 13718 },
|
|
160
|
+
{ iso: "2026-07-27T20:56:31.952Z", retryAfterSec: 13713 },
|
|
161
|
+
{ iso: "2026-07-27T20:56:36.954Z", retryAfterSec: 13708 },
|
|
162
|
+
{ iso: "2026-07-27T20:56:41.952Z", retryAfterSec: 13703 },
|
|
163
|
+
{ iso: "2026-07-27T20:56:46.938Z", retryAfterSec: 13698 },
|
|
164
|
+
{ iso: "2026-07-27T20:56:51.937Z", retryAfterSec: 13693 },
|
|
165
|
+
{ iso: "2026-07-27T20:56:56.942Z", retryAfterSec: 13688 },
|
|
166
|
+
{ iso: "2026-07-27T20:57:01.984Z", retryAfterSec: 13683 },
|
|
167
|
+
{ iso: "2026-07-27T20:57:06.955Z", retryAfterSec: 13678 },
|
|
168
|
+
{ iso: "2026-07-27T20:57:11.959Z", retryAfterSec: 13673 },
|
|
169
|
+
{ iso: "2026-07-27T20:57:16.976Z", retryAfterSec: 13668 },
|
|
170
|
+
{ iso: "2026-07-27T20:57:21.964Z", retryAfterSec: 13663 },
|
|
171
|
+
{ iso: "2026-07-27T20:57:26.962Z", retryAfterSec: 13658 },
|
|
172
|
+
{ iso: "2026-07-27T20:57:31.968Z", retryAfterSec: 13653 },
|
|
173
|
+
{ iso: "2026-07-27T20:57:36.960Z", retryAfterSec: 13648 },
|
|
174
|
+
{ iso: "2026-07-27T20:57:41.981Z", retryAfterSec: 13643 },
|
|
175
|
+
{ iso: "2026-07-27T20:57:46.972Z", retryAfterSec: 13638 },
|
|
176
|
+
{ iso: "2026-07-27T20:57:51.976Z", retryAfterSec: 13633 },
|
|
177
|
+
{ iso: "2026-07-27T20:57:56.966Z", retryAfterSec: 13628 },
|
|
178
|
+
{ iso: "2026-07-27T20:58:01.971Z", retryAfterSec: 13623 },
|
|
179
|
+
{ iso: "2026-07-27T20:58:07.023Z", retryAfterSec: 13618 },
|
|
180
|
+
{ iso: "2026-07-27T20:58:12.081Z", retryAfterSec: 13613 },
|
|
181
|
+
{ iso: "2026-07-27T20:58:16.975Z", retryAfterSec: 13608 },
|
|
182
|
+
{ iso: "2026-07-27T20:58:21.974Z", retryAfterSec: 13603 },
|
|
183
|
+
{ iso: "2026-07-27T20:58:26.994Z", retryAfterSec: 13598 },
|
|
184
|
+
{ iso: "2026-07-27T20:58:31.979Z", retryAfterSec: 13593 },
|
|
185
|
+
{ iso: "2026-07-27T20:58:36.977Z", retryAfterSec: 13588 },
|
|
186
|
+
{ iso: "2026-07-27T20:58:41.983Z", retryAfterSec: 13583 },
|
|
187
|
+
{ iso: "2026-07-27T20:58:46.966Z", retryAfterSec: 13578 },
|
|
188
|
+
{ iso: "2026-07-27T20:58:51.982Z", retryAfterSec: 13573 },
|
|
189
|
+
{ iso: "2026-07-27T20:58:56.984Z", retryAfterSec: 13568 },
|
|
190
|
+
{ iso: "2026-07-27T20:59:01.983Z", retryAfterSec: 13563 },
|
|
191
|
+
{ iso: "2026-07-27T20:59:06.982Z", retryAfterSec: 13558 },
|
|
192
|
+
{ iso: "2026-07-27T20:59:11.987Z", retryAfterSec: 13553 },
|
|
193
|
+
{ iso: "2026-07-27T20:59:16.995Z", retryAfterSec: 13548 },
|
|
194
|
+
{ iso: "2026-07-27T20:59:21.982Z", retryAfterSec: 13543 },
|
|
195
|
+
{ iso: "2026-07-27T20:59:26.987Z", retryAfterSec: 13538 },
|
|
196
|
+
{ iso: "2026-07-27T20:59:31.991Z", retryAfterSec: 13533 },
|
|
197
|
+
{ iso: "2026-07-27T20:59:36.994Z", retryAfterSec: 13528 },
|
|
198
|
+
{ iso: "2026-07-27T20:59:41.993Z", retryAfterSec: 13523 },
|
|
199
|
+
{ iso: "2026-07-27T20:59:47.005Z", retryAfterSec: 13518 },
|
|
200
|
+
{ iso: "2026-07-27T20:59:51.997Z", retryAfterSec: 13513 },
|
|
201
|
+
{ iso: "2026-07-27T20:59:56.996Z", retryAfterSec: 13508 },
|
|
202
|
+
{ iso: "2026-07-27T21:00:02.004Z", retryAfterSec: 13503 },
|
|
203
|
+
{ iso: "2026-07-27T21:00:07.009Z", retryAfterSec: 13498 },
|
|
204
|
+
{ iso: "2026-07-27T21:00:11.996Z", retryAfterSec: 13493 },
|
|
205
|
+
{ iso: "2026-07-27T21:00:17.016Z", retryAfterSec: 13488 },
|
|
206
|
+
{ iso: "2026-07-27T21:00:22.002Z", retryAfterSec: 13483 },
|
|
207
|
+
{ iso: "2026-07-27T21:00:27.001Z", retryAfterSec: 13478 },
|
|
208
|
+
{ iso: "2026-07-27T21:00:32.002Z", retryAfterSec: 13473 },
|
|
209
|
+
{ iso: "2026-07-27T21:00:36.999Z", retryAfterSec: 13468 },
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
export const REAL_429_STREAM: ReadonlyArray<RawFlood429> = RAW.map((e) => ({
|
|
213
|
+
...e,
|
|
214
|
+
ts: Date.parse(e.iso),
|
|
215
|
+
}))
|
|
216
|
+
|
|
217
|
+
/** Convenience: `Date.parse` of an ISO instant, for assertion timestamps. */
|
|
218
|
+
export function at(iso: string): number {
|
|
219
|
+
return Date.parse(iso)
|
|
220
|
+
}
|