-
Notifications
You must be signed in to change notification settings - Fork 14.4k
Expand file tree
/
Copy patherror-throttler.test.ts
More file actions
221 lines (181 loc) · 8.76 KB
/
Copy patherror-throttler.test.ts
File metadata and controls
221 lines (181 loc) · 8.76 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
import { describe, expect, it, vi } from "vitest";
import { computeBackoffDelayMs, createErrorThrottler, summarizeError } from "../lib/error-throttler.js";
import type { Logger } from "pino";
function mockLogger(): Logger {
return {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
} as unknown as Logger;
}
describe("summarizeError", () => {
it("extracts error message from Error instance", () => {
const err = new Error("Database connection failed");
expect(summarizeError(err)).toEqual({ message: "Database connection failed", code: undefined });
});
it("extracts error code if present", () => {
const err = Object.assign(new Error("Connection refused"), { code: "ECONNREFUSED" });
expect(summarizeError(err)).toEqual({ message: "Connection refused", code: "ECONNREFUSED" });
});
it("truncates long error messages exceeding maxLength", () => {
const longMessage = "Failed query: " + "a".repeat(1000);
const summary = summarizeError(new Error(longMessage), 100);
expect(summary.message.length).toBeLessThanOrEqual(120);
expect(summary.message).toContain("[truncated]");
});
it("handles non-Error objects and primitives gracefully", () => {
expect(summarizeError("Plain text error")).toEqual({ message: "Plain text error", code: undefined });
expect(summarizeError({ custom: "err" })).toEqual({ message: '{"custom":"err"}', code: undefined });
expect(summarizeError(null)).toEqual({ message: "Unknown error", code: undefined });
expect(summarizeError(undefined)).toEqual({ message: "Unknown error", code: undefined });
});
it("preserves falsy-but-defined thrown values instead of treating them as unknown", () => {
// JS allows `throw 0` / `throw ""` / `throw false`; these are not "no error".
expect(summarizeError(0)).toEqual({ message: "0", code: undefined });
expect(summarizeError("")).toEqual({ message: "", code: undefined });
expect(summarizeError(false)).toEqual({ message: "false", code: undefined });
});
});
describe("ErrorThrottler", () => {
it("logs the first error immediately", () => {
let now = 1000;
const clock = () => now;
const throttler = createErrorThrottler({ minIntervalMs: 5000, clock });
const logger = mockLogger();
const err = new Error("Postgres unreachable");
throttler.logError(logger, "heartbeat timer tick failed", err);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith({ err }, "heartbeat timer tick failed");
});
it("suppresses repeated identical errors within minIntervalMs", () => {
let now = 1000;
const clock = () => now;
const throttler = createErrorThrottler({ minIntervalMs: 5000, clock });
const logger = mockLogger();
const err = new Error("Postgres unreachable");
throttler.logError(logger, "heartbeat timer tick failed", err);
expect(logger.error).toHaveBeenCalledTimes(1);
// Call 2 & 3 within interval
now += 1000;
throttler.logError(logger, "heartbeat timer tick failed", err);
now += 1000;
throttler.logError(logger, "heartbeat timer tick failed", err);
// Should still be called only once (suppressed 2 attempts)
expect(logger.error).toHaveBeenCalledTimes(1);
});
it("logs summary with suppressed count after minIntervalMs passes", () => {
let now = 1000;
const clock = () => now;
const throttler = createErrorThrottler({ minIntervalMs: 5000, clock });
const logger = mockLogger();
const err = new Error("Postgres unreachable");
throttler.logError(logger, "heartbeat timer tick failed", err);
// 3 suppressed calls
now += 1000;
throttler.logError(logger, "heartbeat timer tick failed", err);
now += 1000;
throttler.logError(logger, "heartbeat timer tick failed", err);
now += 1000;
throttler.logError(logger, "heartbeat timer tick failed", err);
// Now advance past 5000ms
now += 3000;
throttler.logError(logger, "heartbeat timer tick failed", err);
expect(logger.error).toHaveBeenCalledTimes(2);
expect(logger.error).toHaveBeenLastCalledWith(
{ err, suppressedErrors: 3 },
"[suppressed 3 repeated errors] heartbeat timer tick failed",
);
});
it("logs distinct errors independently", () => {
let now = 1000;
const clock = () => now;
const throttler = createErrorThrottler({ minIntervalMs: 5000, clock });
const logger = mockLogger();
const err1 = new Error("Database down");
const err2 = new Error("Network timeout");
throttler.logError(logger, "heartbeat timer tick failed", err1);
throttler.logError(logger, "routine scheduler tick failed", err2);
expect(logger.error).toHaveBeenCalledTimes(2);
});
it("resets suppression state when reset() is called", () => {
let now = 1000;
const clock = () => now;
const throttler = createErrorThrottler({ minIntervalMs: 5000, clock });
const logger = mockLogger();
const err = new Error("Database down");
throttler.logError(logger, "heartbeat timer tick failed", err);
expect(logger.error).toHaveBeenCalledTimes(1);
throttler.reset();
// After reset, same error logs immediately again
now += 500;
throttler.logError(logger, "heartbeat timer tick failed", err);
expect(logger.error).toHaveBeenCalledTimes(2);
});
it("bounds tracked state so distinct-message errors cannot grow memory unboundedly", () => {
let now = 1000;
const clock = () => now;
const throttler = createErrorThrottler({ minIntervalMs: 5000, clock, maxTrackedKeys: 3 });
const logger = mockLogger();
// Each error carries a unique piece of detail (e.g. a row id or query
// param), producing a distinct throttle key every time.
for (let i = 0; i < 100; i++) {
throttler.logError(logger, "sweep failed", new Error(`row ${i} failed`));
now += 1;
}
// Internal state must not grow past the configured cap.
expect((throttler as unknown as { state: Map<string, unknown> }).state.size).toBeLessThanOrEqual(3);
// Every call was for a first-seen key, so every call should have logged
// immediately regardless of eviction.
expect(logger.error).toHaveBeenCalledTimes(100);
});
it("evicts the least-recently-active key first, not an arbitrarily chosen one", () => {
let now = 1000;
const clock = () => now;
const throttler = createErrorThrottler({ minIntervalMs: 5000, clock, maxTrackedKeys: 2 });
const logger = mockLogger();
throttler.logError(logger, "ctx", new Error("a")); // call 1: first-seen "a"
now += 1;
throttler.logError(logger, "ctx", new Error("b")); // call 2: first-seen "b"
now += 1;
// Re-touch "a" so "b" becomes the least-recently-active key.
now += 6000; // past minIntervalMs so this counts as a fresh log for "a"
throttler.logError(logger, "ctx", new Error("a")); // call 3: "a" refreshed
now += 1;
// Adding a third distinct key should evict "b" (least-recently-active), not "a".
throttler.logError(logger, "ctx", new Error("c")); // call 4: first-seen "c"
expect(logger.error).toHaveBeenCalledTimes(4);
// "a" was refreshed most recently among the original two, so logging it
// again immediately should still be suppressed (not evicted) - no new call.
now += 1;
throttler.logError(logger, "ctx", new Error("a"));
expect(logger.error).toHaveBeenCalledTimes(4);
// "b" was evicted, so logging it again is treated as first-seen again.
now += 1;
throttler.logError(logger, "ctx", new Error("b"));
expect(logger.error).toHaveBeenCalledTimes(5);
});
});
describe("computeBackoffDelayMs", () => {
const baseIntervalMs = 30_000;
const maxIntervalMs = 300_000;
it("returns the base interval with no consecutive failures", () => {
expect(computeBackoffDelayMs(0, { baseIntervalMs, maxIntervalMs })).toBe(30_000);
});
it("doubles per consecutive failure up to the step cap", () => {
expect(computeBackoffDelayMs(1, { baseIntervalMs, maxIntervalMs })).toBe(60_000);
expect(computeBackoffDelayMs(2, { baseIntervalMs, maxIntervalMs })).toBe(120_000);
expect(computeBackoffDelayMs(3, { baseIntervalMs, maxIntervalMs })).toBe(240_000);
});
it("caps the delay at maxIntervalMs once the step cap is reached", () => {
expect(computeBackoffDelayMs(4, { baseIntervalMs, maxIntervalMs })).toBe(300_000);
expect(computeBackoffDelayMs(10, { baseIntervalMs, maxIntervalMs })).toBe(300_000);
});
it("treats negative failure counts as zero (no negative backoff)", () => {
expect(computeBackoffDelayMs(-5, { baseIntervalMs, maxIntervalMs })).toBe(30_000);
});
it("honors a custom maxBackoffSteps", () => {
expect(computeBackoffDelayMs(1, { baseIntervalMs, maxIntervalMs, maxBackoffSteps: 0 })).toBe(30_000);
expect(computeBackoffDelayMs(2, { baseIntervalMs, maxIntervalMs, maxBackoffSteps: 1 })).toBe(60_000);
});
});