@gigzen/populace 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/LICENSE +661 -0
- package/README.md +258 -0
- package/adapters/buzzbuzz.mjs +247 -0
- package/adapters/contract.md +164 -0
- package/adapters/template-rest.mjs +192 -0
- package/adapters/template.mjs +80 -0
- package/examples/buzzbuzz/populace-report.html +245 -0
- package/examples/buzzbuzz/populace-report.json +280 -0
- package/examples/buzzbuzz/populace.config.mjs +51 -0
- package/examples/buzzbuzz/run-test.ps1 +61 -0
- package/examples/demo/adapters/demo.mjs +90 -0
- package/examples/demo/populace-report.html +230 -0
- package/examples/demo/populace-report.json +219 -0
- package/examples/demo/populace.config.mjs +22 -0
- package/examples/rest-api/README.md +85 -0
- package/examples/rest-api/adapter.mjs +166 -0
- package/examples/rest-api/populace.config.mjs +40 -0
- package/examples/rest-api/server.mjs +247 -0
- package/examples/token-expiry/expiry-demo.mjs +119 -0
- package/package.json +56 -0
- package/populace.config.example.mjs +65 -0
- package/src/cli.mjs +591 -0
- package/src/config.mjs +186 -0
- package/src/contract.mjs +130 -0
- package/src/diagnose.mjs +40 -0
- package/src/engine/agent.mjs +264 -0
- package/src/engine/geo.mjs +59 -0
- package/src/engine/index.mjs +4 -0
- package/src/engine/personas.mjs +115 -0
- package/src/engine/world.mjs +120 -0
- package/src/html-report.mjs +218 -0
- package/src/index.mjs +38 -0
- package/src/instrument.mjs +299 -0
- package/src/net.mjs +175 -0
- package/src/report.mjs +251 -0
- package/src/selftest.mjs +1369 -0
- package/src/smoke.mjs +274 -0
- package/src/version.mjs +24 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Wraps an adapter so every call through it is timed, counted, and its failures
|
|
2
|
+
// grouped by message.
|
|
3
|
+
//
|
|
4
|
+
// This is what a customer actually buys. The simulation being interesting to
|
|
5
|
+
// watch is not the product — the product is the evidence it leaves behind:
|
|
6
|
+
// which of your endpoints broke, how often, under how many concurrent users,
|
|
7
|
+
// and how slow they got while it was happening.
|
|
8
|
+
//
|
|
9
|
+
// Neither the engine nor the adapter knows this exists. The engine calls
|
|
10
|
+
// `adapter.post(...)`; the adapter does its thing; the numbers accumulate in
|
|
11
|
+
// between.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
backoffMs,
|
|
15
|
+
CircuitBreaker,
|
|
16
|
+
isTransportError,
|
|
17
|
+
sleep,
|
|
18
|
+
TargetUnreachableError,
|
|
19
|
+
} from "./net.mjs";
|
|
20
|
+
|
|
21
|
+
const PERCENTILES = [50, 95, 99];
|
|
22
|
+
|
|
23
|
+
// A call that never comes back is the worst failure mode this tool has, because
|
|
24
|
+
// it does not look like a failure — it looks like nothing. A real run against a
|
|
25
|
+
// flaky network froze at tick 24 of 60 and sat there silently until an external
|
|
26
|
+
// timeout killed it 9 minutes later, producing no report at all. The customer's
|
|
27
|
+
// API was fine; one socket died and the whole run went with it.
|
|
28
|
+
//
|
|
29
|
+
// So every adapter call gets a deadline. Past it we stop waiting, record a
|
|
30
|
+
// normal failure, and let the other agents carry on. A slow API then shows up
|
|
31
|
+
// as a timeout in the report — which is a finding — instead of a hung process,
|
|
32
|
+
// which is nothing.
|
|
33
|
+
export const DEFAULT_TIMEOUT_MS = 20_000;
|
|
34
|
+
|
|
35
|
+
// Transport failures get retried; application failures never do. Three extra
|
|
36
|
+
// attempts absorbs the ordinary blips of a real network — a CI runner losing a
|
|
37
|
+
// socket, a staging box behind a flaky VPN — without papering over an endpoint
|
|
38
|
+
// that is genuinely down, which still fails after the last attempt.
|
|
39
|
+
export const DEFAULT_RETRIES = 3;
|
|
40
|
+
|
|
41
|
+
// Consecutive transport failures before Populace concludes the target is gone
|
|
42
|
+
// and stops the run. Twelve is roughly two full ticks' worth of calls for a
|
|
43
|
+
// small population — long enough that a brief outage does not abort a good run,
|
|
44
|
+
// short enough that a dead host is called in under a minute instead of grinding
|
|
45
|
+
// out the full duration and producing nothing.
|
|
46
|
+
export const DEFAULT_GIVE_UP_AFTER = 12;
|
|
47
|
+
|
|
48
|
+
export class TimeoutError extends Error {
|
|
49
|
+
constructor(method, ms) {
|
|
50
|
+
super(`${method} timed out after ${ms}ms`);
|
|
51
|
+
this.name = "TimeoutError";
|
|
52
|
+
this.isTimeout = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reject once `ms` has passed. Resolves to a cancel() so the timer is always
|
|
58
|
+
* cleared — an uncleared timer keeps the process alive past the end of a run,
|
|
59
|
+
* which would make `populace run` hang on exit for every fast call.
|
|
60
|
+
*/
|
|
61
|
+
function deadline(method, ms) {
|
|
62
|
+
let timer;
|
|
63
|
+
const promise = new Promise((_, reject) => {
|
|
64
|
+
timer = setTimeout(() => reject(new TimeoutError(method, ms)), ms);
|
|
65
|
+
});
|
|
66
|
+
return { promise, cancel: () => clearTimeout(timer) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createMetrics() {
|
|
70
|
+
return { methods: new Map(), startedAt: Date.now(), endedAt: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function bucket(metrics, method) {
|
|
74
|
+
if (!metrics.methods.has(method)) {
|
|
75
|
+
metrics.methods.set(method, {
|
|
76
|
+
method,
|
|
77
|
+
calls: 0,
|
|
78
|
+
failures: 0,
|
|
79
|
+
// Failures split by whose problem they are. `apiFailures` are findings
|
|
80
|
+
// about the customer's code; `transportFailures` are the network between
|
|
81
|
+
// us and them, and must never be presented as the same thing.
|
|
82
|
+
apiFailures: 0,
|
|
83
|
+
transportFailures: 0,
|
|
84
|
+
// Attempts that failed at transport level and were retried. Kept and
|
|
85
|
+
// reported: a run that only survived on its fifth try is not the same as
|
|
86
|
+
// one that worked first time, and hiding that would inflate reliability.
|
|
87
|
+
retries: 0,
|
|
88
|
+
durations: [],
|
|
89
|
+
errors: new Map(),
|
|
90
|
+
firstErrorAt: null,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return metrics.methods.get(method);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Group errors by SHAPE, not by exact text. Two failures that differ only by a
|
|
98
|
+
* uuid or a row count are the same bug, and a report that lists them separately
|
|
99
|
+
* buries the signal it was written to surface.
|
|
100
|
+
*/
|
|
101
|
+
export function normaliseError(error) {
|
|
102
|
+
return String(error?.message || error || "unknown error")
|
|
103
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>")
|
|
104
|
+
.replace(/\b\d+\b/g, "<n>")
|
|
105
|
+
.replace(/\s+/g, " ")
|
|
106
|
+
.trim()
|
|
107
|
+
.slice(0, 200);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function instrument(
|
|
111
|
+
adapter,
|
|
112
|
+
metrics,
|
|
113
|
+
{
|
|
114
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
115
|
+
retries = DEFAULT_RETRIES,
|
|
116
|
+
giveUpAfter = DEFAULT_GIVE_UP_AFTER,
|
|
117
|
+
breaker = new CircuitBreaker({ threshold: giveUpAfter }),
|
|
118
|
+
} = {},
|
|
119
|
+
) {
|
|
120
|
+
const wrapped = { name: adapter.name };
|
|
121
|
+
metrics.breaker = breaker;
|
|
122
|
+
// 0 or Infinity disables the deadline, for adapters whose work is legitimately
|
|
123
|
+
// long (a batch import, a deliberate slow-endpoint probe).
|
|
124
|
+
const limited = Number.isFinite(timeoutMs) && timeoutMs > 0;
|
|
125
|
+
const maxAttempts = Math.max(1, Number(retries) + 1);
|
|
126
|
+
|
|
127
|
+
for (const key of Object.keys(adapter)) {
|
|
128
|
+
const value = adapter[key];
|
|
129
|
+
if (typeof value !== "function") {
|
|
130
|
+
wrapped[key] = value;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One attempt, with the deadline applied. Timing is per-attempt. */
|
|
135
|
+
const attemptOnce = async (args) => {
|
|
136
|
+
const clock = limited ? deadline(key, timeoutMs) : null;
|
|
137
|
+
const started = performance.now();
|
|
138
|
+
try {
|
|
139
|
+
// We stop WAITING at the deadline; we cannot cancel the adapter's own
|
|
140
|
+
// work, so a late reply may still land and is simply ignored. Recording
|
|
141
|
+
// the timeout as the outcome is honest — the run did not get an answer
|
|
142
|
+
// in time, which is exactly what a user of the API would experience.
|
|
143
|
+
const call = value.apply(adapter, args);
|
|
144
|
+
// Never leave a late rejection unhandled: once we have raced past it,
|
|
145
|
+
// nothing is awaiting `call`, and an unhandled rejection would crash the
|
|
146
|
+
// run with a stack trace that has nothing to do with the real problem.
|
|
147
|
+
if (clock) call.then?.(undefined, () => {});
|
|
148
|
+
const result = clock ? await Promise.race([call, clock.promise]) : await call;
|
|
149
|
+
return { ok: true, result, ms: performance.now() - started };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return { ok: false, error, ms: performance.now() - started };
|
|
152
|
+
} finally {
|
|
153
|
+
clock?.cancel();
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
wrapped[key] = async (...args) => {
|
|
158
|
+
const entry = bucket(metrics, key);
|
|
159
|
+
entry.calls += 1;
|
|
160
|
+
|
|
161
|
+
// Open and still inside its cooldown: fail instantly rather than spend
|
|
162
|
+
// the full deadline rediscovering the same thing. allows() lets a single
|
|
163
|
+
// probe through once the cooldown expires, so a passing outage recovers
|
|
164
|
+
// instead of ending the run.
|
|
165
|
+
if (!breaker.allows()) {
|
|
166
|
+
const error = new TargetUnreachableError(breaker.openedAfter);
|
|
167
|
+
error.fromAdapter = true;
|
|
168
|
+
entry.failures += 1;
|
|
169
|
+
entry.transportFailures += 1;
|
|
170
|
+
entry.durations.push(0);
|
|
171
|
+
if (entry.firstErrorAt === null) entry.firstErrorAt = Date.now();
|
|
172
|
+
const shape = normaliseError(error);
|
|
173
|
+
entry.errors.set(shape, (entry.errors.get(shape) || 0) + 1);
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (let attempt = 1; ; attempt++) {
|
|
178
|
+
const outcome = await attemptOnce(args);
|
|
179
|
+
|
|
180
|
+
if (outcome.ok) {
|
|
181
|
+
breaker.recordSuccess();
|
|
182
|
+
// Only the SUCCESSFUL attempt's duration is recorded. Including the
|
|
183
|
+
// failed attempts before it would fold network problems into the
|
|
184
|
+
// customer's latency figures and make p50/p95 unpublishable — which
|
|
185
|
+
// is exactly what went wrong in an earlier run of this tool.
|
|
186
|
+
entry.durations.push(outcome.ms);
|
|
187
|
+
return outcome.result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const error = outcome.error;
|
|
191
|
+
const transport = isTransportError(error);
|
|
192
|
+
|
|
193
|
+
// An error the server RETURNED proves the link is alive, whatever it
|
|
194
|
+
// says about their code. That must reset the breaker, or a genuinely
|
|
195
|
+
// broken endpoint would look like a dead network and abort the run.
|
|
196
|
+
if (transport) breaker.recordTransportFailure();
|
|
197
|
+
else breaker.recordApiFailure();
|
|
198
|
+
|
|
199
|
+
// Retry ONLY when the server never answered. Any response the server
|
|
200
|
+
// actually produced — including a 500 — is a finding about their code,
|
|
201
|
+
// and retrying it would quietly turn a real bug into a green tick.
|
|
202
|
+
if (transport && attempt < maxAttempts && !breaker.open) {
|
|
203
|
+
entry.retries += 1;
|
|
204
|
+
await sleep(backoffMs(attempt));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Mark it as the adapter's, so the engine can tell a customer's API
|
|
209
|
+
// failing apart from a bug of our own. Untagged failures reaching the
|
|
210
|
+
// agent loop are Populace's fault and must not be reported as theirs.
|
|
211
|
+
if (error && typeof error === "object") error.fromAdapter = true;
|
|
212
|
+
entry.durations.push(outcome.ms);
|
|
213
|
+
entry.failures += 1;
|
|
214
|
+
if (transport) entry.transportFailures += 1;
|
|
215
|
+
else entry.apiFailures += 1;
|
|
216
|
+
if (entry.firstErrorAt === null) entry.firstErrorAt = Date.now();
|
|
217
|
+
const shape = normaliseError(error);
|
|
218
|
+
entry.errors.set(shape, (entry.errors.get(shape) || 0) + 1);
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return wrapped;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function percentile(sorted, p) {
|
|
227
|
+
if (!sorted.length) return 0;
|
|
228
|
+
const i = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
|
|
229
|
+
return sorted[Math.max(0, i)];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function summarise(metrics) {
|
|
233
|
+
const methods = [...metrics.methods.values()].map((entry) => {
|
|
234
|
+
const sorted = [...entry.durations].sort((a, b) => a - b);
|
|
235
|
+
const latency = Object.fromEntries(
|
|
236
|
+
PERCENTILES.map((p) => [`p${p}`, Math.round(percentile(sorted, p))]),
|
|
237
|
+
);
|
|
238
|
+
return {
|
|
239
|
+
method: entry.method,
|
|
240
|
+
calls: entry.calls,
|
|
241
|
+
failures: entry.failures,
|
|
242
|
+
apiFailures: entry.apiFailures,
|
|
243
|
+
transportFailures: entry.transportFailures,
|
|
244
|
+
retries: entry.retries,
|
|
245
|
+
failureRate: entry.calls ? entry.failures / entry.calls : 0,
|
|
246
|
+
latencyMs: { ...latency, max: Math.round(sorted[sorted.length - 1] || 0) },
|
|
247
|
+
errors: [...entry.errors.entries()]
|
|
248
|
+
.map(([message, count]) => ({ message, count }))
|
|
249
|
+
.sort((a, b) => b.count - a.count),
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Sort by the customer's own failures first. On a bad link transport noise
|
|
254
|
+
// can dwarf a single real bug, and the bug is what they need to see at the
|
|
255
|
+
// top of the table.
|
|
256
|
+
methods.sort(
|
|
257
|
+
(a, b) => b.apiFailures - a.apiFailures || b.failures - a.failures || b.calls - a.calls,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const sum = (f) => methods.reduce((n, m) => n + f(m), 0);
|
|
261
|
+
const calls = sum((m) => m.calls);
|
|
262
|
+
const failures = sum((m) => m.failures);
|
|
263
|
+
const apiFailures = sum((m) => m.apiFailures);
|
|
264
|
+
const transportFailures = sum((m) => m.transportFailures);
|
|
265
|
+
const retries = sum((m) => m.retries);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
calls,
|
|
269
|
+
failures,
|
|
270
|
+
apiFailures,
|
|
271
|
+
transportFailures,
|
|
272
|
+
retries,
|
|
273
|
+
failureRate: calls ? failures / calls : 0,
|
|
274
|
+
// The rate that actually says something about their software. Reported
|
|
275
|
+
// separately so a bad link cannot inflate the number they are judged on.
|
|
276
|
+
apiFailureRate: calls ? apiFailures / calls : 0,
|
|
277
|
+
network: {
|
|
278
|
+
retries,
|
|
279
|
+
transportFailures,
|
|
280
|
+
// Attempts that had to be repeated, as a share of all attempts made.
|
|
281
|
+
// A high number here means the link was bad, NOT that the API was.
|
|
282
|
+
retryRate: calls + retries ? retries / (calls + retries) : 0,
|
|
283
|
+
healthy: retries === 0 && transportFailures === 0,
|
|
284
|
+
// Set when Populace concluded the target was gone and stopped early.
|
|
285
|
+
// Without this the report would show a short run full of failures and
|
|
286
|
+
// give no clue that it was cut short deliberately.
|
|
287
|
+
// gaveUp means ABANDONED, not merely open. A breaker that opened and
|
|
288
|
+
// then recovered is a run that survived an outage — reporting that as
|
|
289
|
+
// "gave up" would call a completed run incomplete.
|
|
290
|
+
gaveUp: Boolean(metrics.breaker?.abandoned),
|
|
291
|
+
gaveUpAfter: metrics.breaker?.openedAfter ?? null,
|
|
292
|
+
// Outages ridden out and recovered from. Worth reporting: it is the
|
|
293
|
+
// difference between a clean network and one that merely held together.
|
|
294
|
+
outages: metrics.breaker?.trips ?? 0,
|
|
295
|
+
},
|
|
296
|
+
durationMs: (metrics.endedAt || Date.now()) - metrics.startedAt,
|
|
297
|
+
methods,
|
|
298
|
+
};
|
|
299
|
+
}
|
package/src/net.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Telling "your API said no" apart from "we never reached your API".
|
|
2
|
+
//
|
|
3
|
+
// This distinction is the difference between a report a customer trusts and one
|
|
4
|
+
// they argue with. A dropped socket is not a bug in their code, and a tool that
|
|
5
|
+
// reports it as one will be dismissed the first time it cries wolf. Equally, a
|
|
6
|
+
// 500 from their server IS their bug and must never be quietly retried away.
|
|
7
|
+
//
|
|
8
|
+
// So the rule is narrow on purpose: only failures that happened BELOW the HTTP
|
|
9
|
+
// layer count as transport. Anything the server actually answered — any status
|
|
10
|
+
// code at all — is the application's, and is reported exactly once.
|
|
11
|
+
|
|
12
|
+
const TRANSPORT = [
|
|
13
|
+
"fetch failed", // undici's blanket wrapper; cause carries the detail
|
|
14
|
+
"ENOTFOUND", // DNS did not resolve
|
|
15
|
+
"EAI_AGAIN", // DNS temporary failure
|
|
16
|
+
"ECONNREFUSED",
|
|
17
|
+
"ECONNRESET",
|
|
18
|
+
"EPIPE",
|
|
19
|
+
"ETIMEDOUT",
|
|
20
|
+
"EHOSTUNREACH",
|
|
21
|
+
"ENETUNREACH",
|
|
22
|
+
"ENETDOWN",
|
|
23
|
+
"UND_ERR", // undici's own timeouts (connect, headers, body)
|
|
24
|
+
"socket hang up",
|
|
25
|
+
"network",
|
|
26
|
+
"Client network socket disconnected",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when a call failed without the server ever answering.
|
|
31
|
+
*
|
|
32
|
+
* Matches against `cause.code` first — undici hides the real reason there and
|
|
33
|
+
* flattens everything to the message "fetch failed", which on its own tells you
|
|
34
|
+
* nothing.
|
|
35
|
+
*/
|
|
36
|
+
export function isTransportError(error) {
|
|
37
|
+
if (!error) return false;
|
|
38
|
+
if (error.isTimeout) return true; // our own deadline: no answer arrived
|
|
39
|
+
const text = String(
|
|
40
|
+
error.cause?.code || error.code || error.cause?.message || error.message || error,
|
|
41
|
+
);
|
|
42
|
+
return TRANSPORT.some((t) => text.toLowerCase().includes(t.toLowerCase()));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Exponential backoff with jitter.
|
|
47
|
+
*
|
|
48
|
+
* Jitter matters here more than usual: Populace runs N agents in lockstep on a
|
|
49
|
+
* tick, so a blip tends to hit all of them at once. Without jitter they would
|
|
50
|
+
* retry in unison and hammer an already-struggling API in synchronised waves —
|
|
51
|
+
* turning a small outage into a self-inflicted load test.
|
|
52
|
+
*/
|
|
53
|
+
export function backoffMs(attempt, base = 250, cap = 4000) {
|
|
54
|
+
const exponential = Math.min(cap, base * 2 ** (attempt - 1));
|
|
55
|
+
return Math.round(exponential / 2 + Math.random() * (exponential / 2));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Stops retrying once the target is clearly gone rather than merely flaky.
|
|
62
|
+
*
|
|
63
|
+
* Retries fix a flaky link and make a dead one agonising: every call costs the
|
|
64
|
+
* full deadline times every attempt, so a run against an unreachable host
|
|
65
|
+
* crawls for twenty minutes and then produces nothing. Observed exactly that —
|
|
66
|
+
* the retry fix removed the hang and replaced it with a grind.
|
|
67
|
+
*
|
|
68
|
+
* So: after `threshold` transport failures in a row with no success in between,
|
|
69
|
+
* the breaker opens. Calls then fail immediately, the run ends early, and the
|
|
70
|
+
* report says the target became unreachable — in seconds instead of the best
|
|
71
|
+
* part of an hour.
|
|
72
|
+
*
|
|
73
|
+
* Any single success closes it again. A brief outage in the middle of an
|
|
74
|
+
* otherwise fine run must not abort that run; only a sustained one should.
|
|
75
|
+
*/
|
|
76
|
+
export class CircuitBreaker {
|
|
77
|
+
constructor({ threshold = 12, cooldownMs = 15_000, giveUpAfterProbes = 4 } = {}) {
|
|
78
|
+
this.threshold = threshold;
|
|
79
|
+
this.cooldownMs = cooldownMs;
|
|
80
|
+
// How many cooldowns may expire with the target still dead before we stop
|
|
81
|
+
// trying. Four at 15s means a run survives roughly a minute of outage and
|
|
82
|
+
// still abandons a genuinely dead host promptly.
|
|
83
|
+
this.giveUpAfterProbes = giveUpAfterProbes;
|
|
84
|
+
|
|
85
|
+
this.consecutive = 0;
|
|
86
|
+
this.open = false;
|
|
87
|
+
this.openedAfter = null;
|
|
88
|
+
this.openedAt = 0;
|
|
89
|
+
this.probes = 0; // cooldowns elapsed without a success
|
|
90
|
+
this.trips = 0; // how many times it opened during the run
|
|
91
|
+
this.abandoned = false; // permanently given up
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether a call may go through right now.
|
|
96
|
+
*
|
|
97
|
+
* This is the half-open state, and leaving it out was a real bug: the breaker
|
|
98
|
+
* opened on the first burst of a flaky link and stayed open for the rest of
|
|
99
|
+
* the run, so a twenty-second blip killed a five-minute run that could have
|
|
100
|
+
* carried on. Network loss arrives in bursts, not as independent coin flips,
|
|
101
|
+
* so "N consecutive failures" is reached by any ordinary outage — the breaker
|
|
102
|
+
* has to be able to come back.
|
|
103
|
+
*/
|
|
104
|
+
allows(now = Date.now()) {
|
|
105
|
+
if (this.abandoned) return false;
|
|
106
|
+
if (!this.open) return true;
|
|
107
|
+
if (now - this.openedAt < this.cooldownMs) return false;
|
|
108
|
+
// Cooldown elapsed: let exactly one call through to test the water.
|
|
109
|
+
this.openedAt = now;
|
|
110
|
+
this.probes += 1;
|
|
111
|
+
if (this.probes > this.giveUpAfterProbes) {
|
|
112
|
+
this.abandoned = true;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
recordSuccess() {
|
|
119
|
+
this.consecutive = 0;
|
|
120
|
+
this.open = false;
|
|
121
|
+
// A probe that got through means the target is back. Clear the probe budget
|
|
122
|
+
// so a later, unrelated outage gets the full allowance again rather than
|
|
123
|
+
// inheriting the last one's.
|
|
124
|
+
this.probes = 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Force it shut for a new phase of work.
|
|
129
|
+
*
|
|
130
|
+
* Cleanup needs this. An open breaker made teardown fail all five deletions
|
|
131
|
+
* instantly and left five invented accounts live in a customer's project —
|
|
132
|
+
* the safety mechanism causing the exact harm the product promises to avoid.
|
|
133
|
+
* Cleanup is when you most need to keep trying, so it starts with a fresh
|
|
134
|
+
* budget rather than inheriting the run's verdict on the network.
|
|
135
|
+
*/
|
|
136
|
+
reset() {
|
|
137
|
+
this.consecutive = 0;
|
|
138
|
+
this.open = false;
|
|
139
|
+
this.openedAfter = null;
|
|
140
|
+
this.openedAt = 0;
|
|
141
|
+
this.probes = 0;
|
|
142
|
+
this.abandoned = false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @returns {boolean} true when this failure was the one that opened it. */
|
|
146
|
+
recordTransportFailure(now = Date.now()) {
|
|
147
|
+
if (this.threshold <= 0) return false; // disabled
|
|
148
|
+
this.consecutive += 1;
|
|
149
|
+
if (!this.open && this.consecutive >= this.threshold) {
|
|
150
|
+
this.open = true;
|
|
151
|
+
this.openedAfter = this.consecutive;
|
|
152
|
+
this.openedAt = now;
|
|
153
|
+
this.trips += 1;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** An application error says the server IS answering, so the link is alive. */
|
|
160
|
+
recordApiFailure() {
|
|
161
|
+
this.recordSuccess();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export class TargetUnreachableError extends Error {
|
|
166
|
+
constructor(consecutive) {
|
|
167
|
+
super(
|
|
168
|
+
`Target became unreachable — ${consecutive} consecutive calls failed at the network ` +
|
|
169
|
+
`level with no response. Stopping rather than retrying for the rest of the run.`,
|
|
170
|
+
);
|
|
171
|
+
this.name = "TargetUnreachableError";
|
|
172
|
+
this.isTransport = true;
|
|
173
|
+
this.circuitOpen = true;
|
|
174
|
+
}
|
|
175
|
+
}
|