@askalf/dario 5.4.30 → 5.5.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/dist/health-response.d.ts +67 -1
- package/dist/health-response.js +72 -3
- package/dist/live-fingerprint.d.ts +1 -1
- package/dist/live-fingerprint.js +1 -1
- package/dist/proxy.js +25 -2
- package/dist/request-queue.d.ts +41 -2
- package/dist/request-queue.js +34 -2
- package/dist/serving-probe.d.ts +120 -0
- package/dist/serving-probe.js +204 -0
- package/docs/usage.md +55 -0
- package/package.json +1 -1
|
@@ -45,7 +45,35 @@ export interface HealthStatusLike {
|
|
|
45
45
|
queued: number;
|
|
46
46
|
maxConcurrent: number;
|
|
47
47
|
maxQueued: number;
|
|
48
|
+
/**
|
|
49
|
+
* Epoch ms since the queue has been at capacity with NO slot released
|
|
50
|
+
* (dario#905). Null when not at capacity; reset by any release, so
|
|
51
|
+
* sustained legitimate load never accumulates age here. See
|
|
52
|
+
* request-queue.ts for why turnover — not depth — is the wedge signal.
|
|
53
|
+
*/
|
|
54
|
+
stalledSince?: number | null;
|
|
48
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* Verdict from the opt-in serving probe (`/health?probe=1`), when the caller
|
|
58
|
+
* asked for one and was trusted enough to be given it. Absent otherwise —
|
|
59
|
+
* a plain /health never spends a token, so its absence means "not asked
|
|
60
|
+
* for", never "failed".
|
|
61
|
+
*/
|
|
62
|
+
probe?: ServingProbeLike;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The subset of serving-probe.ts's ProbeResult that /health renders. Declared
|
|
66
|
+
* structurally rather than imported so this module stays free of the probe's
|
|
67
|
+
* network machinery and remains unit-testable as a pure function.
|
|
68
|
+
*/
|
|
69
|
+
export interface ServingProbeLike {
|
|
70
|
+
ok: boolean;
|
|
71
|
+
reason: string;
|
|
72
|
+
checkedAt: number;
|
|
73
|
+
latencyMs: number;
|
|
74
|
+
model: string;
|
|
75
|
+
status?: number;
|
|
76
|
+
detail?: string;
|
|
49
77
|
}
|
|
50
78
|
export interface HealthResponse {
|
|
51
79
|
httpStatus: number;
|
|
@@ -65,7 +93,45 @@ export interface PoolDerivedStatus {
|
|
|
65
93
|
accounts: number;
|
|
66
94
|
}
|
|
67
95
|
export declare function derivePoolStatus(accounts: readonly PoolAccountStatusLike[], now: number, adminEnabled: boolean): PoolDerivedStatus;
|
|
68
|
-
export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, includeInternal: boolean): HealthResponse;
|
|
96
|
+
export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, includeInternal: boolean, now?: number): HealthResponse;
|
|
97
|
+
/**
|
|
98
|
+
* Did this caller ask for a serving probe? (`/health?probe=1`, dario#905.)
|
|
99
|
+
*
|
|
100
|
+
* Accepts `probe=1` / `probe=true` / bare `probe`, rejects `probe=0` and
|
|
101
|
+
* `probe=false` — a monitor templating the flag from a boolean config should
|
|
102
|
+
* get the behaviour it wrote, not a probe on every poll because the parameter
|
|
103
|
+
* was merely present. Anything unparseable is treated as "not asked": the
|
|
104
|
+
* failure direction for a token-spending flag has to be off.
|
|
105
|
+
*/
|
|
106
|
+
export declare function probeRequested(url: string | undefined): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Decide whether to actually RUN a serving probe for this caller (dario#905).
|
|
109
|
+
*
|
|
110
|
+
* Deliberately stricter than shouldDiscloseHealthInternals, because this is
|
|
111
|
+
* not a disclosure decision — it spends the operator's money.
|
|
112
|
+
*
|
|
113
|
+
* The disclosure gate treats "authenticated" as sufficient, and
|
|
114
|
+
* `authenticateRequest` returns TRUE when no DARIO_API_KEY is configured at
|
|
115
|
+
* all. That is a reasonable convenience for the common loopback setup, and
|
|
116
|
+
* harmless for a read-only field. It is not harmless here: an unkeyed dario
|
|
117
|
+
* published through a Cloudflare tunnel would otherwise expose `?probe=1` as a
|
|
118
|
+
* button any anonymous caller could press to bill the operator, once per TTL,
|
|
119
|
+
* forever.
|
|
120
|
+
*
|
|
121
|
+
* So the probe additionally refuses anything that arrived through the tunnel,
|
|
122
|
+
* whatever `authenticated` says. This only ever DENIES — it cannot widen
|
|
123
|
+
* access — and it makes the spend path independent of whether an API key
|
|
124
|
+
* happens to be configured.
|
|
125
|
+
*
|
|
126
|
+
* Accepted trade-off: an operator who authenticates THROUGH the tunnel is also
|
|
127
|
+
* refused, and has to probe from beside the proxy instead. For a flag whose
|
|
128
|
+
* failure direction is "silently spends money", off is the right default.
|
|
129
|
+
*/
|
|
130
|
+
export declare function shouldRunServingProbe(opts: {
|
|
131
|
+
requested: boolean;
|
|
132
|
+
discloseInternals: boolean;
|
|
133
|
+
viaCfRay: boolean;
|
|
134
|
+
}): boolean;
|
|
69
135
|
/**
|
|
70
136
|
* Decide whether a /health caller may see the OAuth internals (#642).
|
|
71
137
|
*
|
package/dist/health-response.js
CHANGED
|
@@ -59,10 +59,28 @@ export function derivePoolStatus(accounts, now, adminEnabled) {
|
|
|
59
59
|
expiresIn: formatMsLeft(earliest - now),
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Render `stalledSince` as an elapsed duration alongside the raw stamp. The
|
|
64
|
+
* stamp alone forces every consumer to subtract against its own clock, which
|
|
65
|
+
* is exactly the arithmetic a shell-based healthcheck can't do — and #905's
|
|
66
|
+
* reporter was writing his monitor in bash.
|
|
67
|
+
*/
|
|
68
|
+
function withStalledFor(q, now) {
|
|
69
|
+
const { stalledSince, ...rest } = q;
|
|
70
|
+
if (stalledSince === null || stalledSince === undefined)
|
|
71
|
+
return { ...rest, stalledSince: null };
|
|
72
|
+
return { ...rest, stalledSince, stalledForMs: Math.max(0, now - stalledSince) };
|
|
73
|
+
}
|
|
74
|
+
export function buildHealthResponse(s, requestCount, includeInternal, now = Date.now()) {
|
|
75
|
+
const structurallyDead = s.status === 'broken' ||
|
|
64
76
|
s.status === 'none' ||
|
|
65
77
|
(s.status === 'expired' && s.canRefresh === false);
|
|
78
|
+
// A failed round-trip is authoritative over a clean structural read: the
|
|
79
|
+
// whole point of the probe (dario#905) is the state where local inspection
|
|
80
|
+
// says healthy and every real request fails. When one was run and it came
|
|
81
|
+
// back false, /health must say degraded — that is what makes an existing
|
|
82
|
+
// status-code-only uptime monitor start seeing the outage it used to miss.
|
|
83
|
+
const dead = structurallyDead || s.probe?.ok === false;
|
|
66
84
|
const httpStatus = dead ? 503 : 200;
|
|
67
85
|
const liveness = { status: dead ? 'degraded' : 'ok' };
|
|
68
86
|
// Only trusted callers (authenticated, or bare loopback not via the CF
|
|
@@ -82,12 +100,63 @@ export function buildHealthResponse(s, requestCount, includeInternal) {
|
|
|
82
100
|
expiresIn: s.expiresIn,
|
|
83
101
|
requests: requestCount,
|
|
84
102
|
...(s.sessions ? { sessions: s.sessions } : {}),
|
|
85
|
-
...(s.queue ? { queue: s.queue } : {}),
|
|
103
|
+
...(s.queue ? { queue: withStalledFor(s.queue, now) } : {}),
|
|
104
|
+
...(s.probe ? { probe: { ...s.probe, ageMs: Math.max(0, now - s.probe.checkedAt) } } : {}),
|
|
86
105
|
...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
|
|
87
106
|
}
|
|
88
107
|
: liveness;
|
|
89
108
|
return { httpStatus, body };
|
|
90
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Did this caller ask for a serving probe? (`/health?probe=1`, dario#905.)
|
|
112
|
+
*
|
|
113
|
+
* Accepts `probe=1` / `probe=true` / bare `probe`, rejects `probe=0` and
|
|
114
|
+
* `probe=false` — a monitor templating the flag from a boolean config should
|
|
115
|
+
* get the behaviour it wrote, not a probe on every poll because the parameter
|
|
116
|
+
* was merely present. Anything unparseable is treated as "not asked": the
|
|
117
|
+
* failure direction for a token-spending flag has to be off.
|
|
118
|
+
*/
|
|
119
|
+
export function probeRequested(url) {
|
|
120
|
+
const q = url?.indexOf('?') ?? -1;
|
|
121
|
+
if (q < 0)
|
|
122
|
+
return false;
|
|
123
|
+
const v = new URLSearchParams(url.slice(q + 1)).get('probe');
|
|
124
|
+
if (v === null)
|
|
125
|
+
return false;
|
|
126
|
+
if (v === '')
|
|
127
|
+
return true; // bare `?probe`
|
|
128
|
+
return v === '1' || v.toLowerCase() === 'true';
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Decide whether to actually RUN a serving probe for this caller (dario#905).
|
|
132
|
+
*
|
|
133
|
+
* Deliberately stricter than shouldDiscloseHealthInternals, because this is
|
|
134
|
+
* not a disclosure decision — it spends the operator's money.
|
|
135
|
+
*
|
|
136
|
+
* The disclosure gate treats "authenticated" as sufficient, and
|
|
137
|
+
* `authenticateRequest` returns TRUE when no DARIO_API_KEY is configured at
|
|
138
|
+
* all. That is a reasonable convenience for the common loopback setup, and
|
|
139
|
+
* harmless for a read-only field. It is not harmless here: an unkeyed dario
|
|
140
|
+
* published through a Cloudflare tunnel would otherwise expose `?probe=1` as a
|
|
141
|
+
* button any anonymous caller could press to bill the operator, once per TTL,
|
|
142
|
+
* forever.
|
|
143
|
+
*
|
|
144
|
+
* So the probe additionally refuses anything that arrived through the tunnel,
|
|
145
|
+
* whatever `authenticated` says. This only ever DENIES — it cannot widen
|
|
146
|
+
* access — and it makes the spend path independent of whether an API key
|
|
147
|
+
* happens to be configured.
|
|
148
|
+
*
|
|
149
|
+
* Accepted trade-off: an operator who authenticates THROUGH the tunnel is also
|
|
150
|
+
* refused, and has to probe from beside the proxy instead. For a flag whose
|
|
151
|
+
* failure direction is "silently spends money", off is the right default.
|
|
152
|
+
*/
|
|
153
|
+
export function shouldRunServingProbe(opts) {
|
|
154
|
+
if (!opts.requested)
|
|
155
|
+
return false;
|
|
156
|
+
if (opts.viaCfRay)
|
|
157
|
+
return false;
|
|
158
|
+
return opts.discloseInternals;
|
|
159
|
+
}
|
|
91
160
|
/**
|
|
92
161
|
* Decide whether a /health caller may see the OAuth internals (#642).
|
|
93
162
|
*
|
|
@@ -354,7 +354,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
|
|
|
354
354
|
*/
|
|
355
355
|
export declare const SUPPORTED_CC_RANGE: {
|
|
356
356
|
readonly min: "1.0.0";
|
|
357
|
-
readonly maxTested: "2.1.
|
|
357
|
+
readonly maxTested: "2.1.224";
|
|
358
358
|
};
|
|
359
359
|
/**
|
|
360
360
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
package/dist/live-fingerprint.js
CHANGED
|
@@ -969,7 +969,7 @@ export function detectDrift(t, installedOverride) {
|
|
|
969
969
|
*/
|
|
970
970
|
export const SUPPORTED_CC_RANGE = {
|
|
971
971
|
min: '1.0.0',
|
|
972
|
-
maxTested: '2.1.
|
|
972
|
+
maxTested: '2.1.224',
|
|
973
973
|
};
|
|
974
974
|
/**
|
|
975
975
|
* Compare two dotted-numeric version strings. Returns negative if `a<b`,
|
package/dist/proxy.js
CHANGED
|
@@ -6,7 +6,8 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import { setDefaultResultOrder } from 'node:dns';
|
|
7
7
|
import { arch, platform } from 'node:process';
|
|
8
8
|
import { getAccessToken, getStatus, ignoreCcCredentials } from './oauth.js';
|
|
9
|
-
import { buildHealthResponse, derivePoolStatus, shouldDiscloseHealthInternals } from './health-response.js';
|
|
9
|
+
import { buildHealthResponse, derivePoolStatus, probeRequested, shouldDiscloseHealthInternals, shouldRunServingProbe } from './health-response.js';
|
|
10
|
+
import { getServingProbe } from './serving-probe.js';
|
|
10
11
|
import { darioVersion } from './version.js';
|
|
11
12
|
import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
|
|
12
13
|
import { stampCch, hasCchSeed } from './cch.js';
|
|
@@ -1697,14 +1698,36 @@ export async function startProxy(opts = {}) {
|
|
|
1697
1698
|
// key on the presence of the client-suppliable `cf-ray` header and failed
|
|
1698
1699
|
// OPEN — a direct non-tunnel caller omits it and got the full internal
|
|
1699
1700
|
// view. Now: authenticated, OR bare loopback that did not arrive via CF.
|
|
1701
|
+
const viaCfRay = req.headers['cf-ray'] !== undefined;
|
|
1700
1702
|
const includeInternal = shouldDiscloseHealthInternals({
|
|
1701
1703
|
authenticated: authenticateRequest(req.headers, apiKeyBuf),
|
|
1702
1704
|
loopback: isLoopbackAddr(req.socket?.remoteAddress),
|
|
1703
|
-
viaCfRay
|
|
1705
|
+
viaCfRay,
|
|
1704
1706
|
});
|
|
1707
|
+
// Opt-in serving probe (#905). A real upstream round-trip costs a real
|
|
1708
|
+
// (tiny) billed request, so it runs only when explicitly asked for and
|
|
1709
|
+
// only for callers that clear shouldRunServingProbe — which is stricter
|
|
1710
|
+
// than the disclosure gate on purpose (see its docstring: an unkeyed
|
|
1711
|
+
// dario authenticates everyone, which must not turn `?probe=1` into a
|
|
1712
|
+
// spend button for the public internet). Cached and single-flighted
|
|
1713
|
+
// inside getServingProbe, so a monitor polling every second still costs
|
|
1714
|
+
// at most one probe per TTL.
|
|
1715
|
+
const wantsProbe = shouldRunServingProbe({
|
|
1716
|
+
requested: probeRequested(req.url),
|
|
1717
|
+
discloseInternals: includeInternal,
|
|
1718
|
+
viaCfRay,
|
|
1719
|
+
});
|
|
1720
|
+
const probe = wantsProbe
|
|
1721
|
+
? await getServingProbe({
|
|
1722
|
+
fetchImpl: upstreamFetch,
|
|
1723
|
+
getToken: catalogDeps.getToken,
|
|
1724
|
+
upstreamApiKey: upstreamApiKey || undefined,
|
|
1725
|
+
})
|
|
1726
|
+
: undefined;
|
|
1705
1727
|
const { httpStatus, body } = buildHealthResponse({
|
|
1706
1728
|
...s,
|
|
1707
1729
|
version: darioVersion(),
|
|
1730
|
+
...(probe ? { probe } : {}),
|
|
1708
1731
|
// pool.size === 0 is single-account mode (session-id registry drives
|
|
1709
1732
|
// the SESSION_ID slot); a loaded pool routes via sticky bindings.
|
|
1710
1733
|
sessions: pool.size === 0
|
package/dist/request-queue.d.ts
CHANGED
|
@@ -28,6 +28,31 @@ export interface QueueState {
|
|
|
28
28
|
maxConcurrent: number;
|
|
29
29
|
maxQueued: number;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* QueueState plus the one derived field a monitor actually needs (dario#905).
|
|
33
|
+
*
|
|
34
|
+
* #910 put `active` / `queued` on /health so slot exhaustion stopped being
|
|
35
|
+
* invisible. But a raw sample cannot distinguish the 14h wedge from a healthy
|
|
36
|
+
* one-second burst — both read `active === maxConcurrent, queued > 0`. Polling
|
|
37
|
+
* fast enough to tell them apart is the monitor's problem, and it shouldn't be.
|
|
38
|
+
*
|
|
39
|
+
* The distinguishing signal is TURNOVER, not depth. A busy dario runs at its
|
|
40
|
+
* cap with a backlog all day and is perfectly healthy, because slots keep
|
|
41
|
+
* being released. The #905 wedge held `active` at `maxConcurrent` for hours
|
|
42
|
+
* with no release at all.
|
|
43
|
+
*
|
|
44
|
+
* So `stalledSince` is the epoch ms since which the queue has been at capacity
|
|
45
|
+
* with requests waiting AND NOT ONE SLOT HAS BEEN RELEASED. Any release resets
|
|
46
|
+
* it. Null when the queue isn't at capacity. A non-null value older than a
|
|
47
|
+
* request could plausibly take means slots are not turning over — one sample
|
|
48
|
+
* is enough to see it, and sustained legitimate load never trips it.
|
|
49
|
+
*
|
|
50
|
+
* This is also why the serving probe deliberately doesn't take a slot: the
|
|
51
|
+
* concurrency axis is covered here, for free and without false positives.
|
|
52
|
+
*/
|
|
53
|
+
export interface QueueSnapshot extends QueueState {
|
|
54
|
+
stalledSince: number | null;
|
|
55
|
+
}
|
|
31
56
|
export type AdmitDecision = {
|
|
32
57
|
action: 'admit';
|
|
33
58
|
} | {
|
|
@@ -60,6 +85,8 @@ export interface RequestQueueOptions {
|
|
|
60
85
|
* test is waiting for never arrives.
|
|
61
86
|
*/
|
|
62
87
|
unrefTimers?: boolean;
|
|
88
|
+
/** Clock source for `saturatedSince`. Injectable so tests need no timers. */
|
|
89
|
+
now?: () => number;
|
|
63
90
|
}
|
|
64
91
|
export declare const DEFAULT_MAX_CONCURRENT = 10;
|
|
65
92
|
export declare const DEFAULT_MAX_QUEUED = 128;
|
|
@@ -71,7 +98,19 @@ export declare class RequestQueue {
|
|
|
71
98
|
readonly unrefTimers: boolean;
|
|
72
99
|
private active;
|
|
73
100
|
private queue;
|
|
101
|
+
private readonly now;
|
|
102
|
+
private stalledSince;
|
|
74
103
|
constructor(opts?: RequestQueueOptions);
|
|
104
|
+
/**
|
|
105
|
+
* Re-evaluate the stall stamp. Called after every state change, so it marks
|
|
106
|
+
* when the stall BEGAN rather than when it was last observed — a caller that
|
|
107
|
+
* never polls still reads an accurate duration.
|
|
108
|
+
*
|
|
109
|
+
* Arrivals must NOT refresh the stamp: under a steady arrival rate that
|
|
110
|
+
* would reset the clock continuously and hide a permanent wedge. Only
|
|
111
|
+
* `release()` refreshes it, by clearing first (see there).
|
|
112
|
+
*/
|
|
113
|
+
private updateStall;
|
|
75
114
|
/**
|
|
76
115
|
* Acquire a concurrency slot. Resolves when admitted; throws
|
|
77
116
|
* `QueueFullError` when the queue is at its `maxQueued` cap, throws
|
|
@@ -81,6 +120,6 @@ export declare class RequestQueue {
|
|
|
81
120
|
acquire(): Promise<void>;
|
|
82
121
|
/** Release a slot. The next queued entry (if any) is admitted in FIFO order. */
|
|
83
122
|
release(): void;
|
|
84
|
-
/** Snapshot of queue state — exposed for /analytics + tests. */
|
|
85
|
-
snapshot():
|
|
123
|
+
/** Snapshot of queue state — exposed for /health + /analytics + tests. */
|
|
124
|
+
snapshot(): QueueSnapshot;
|
|
86
125
|
}
|
package/dist/request-queue.js
CHANGED
|
@@ -50,11 +50,32 @@ export class RequestQueue {
|
|
|
50
50
|
unrefTimers;
|
|
51
51
|
active = 0;
|
|
52
52
|
queue = [];
|
|
53
|
+
now;
|
|
54
|
+
stalledSince = null;
|
|
53
55
|
constructor(opts = {}) {
|
|
54
56
|
this.maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
55
57
|
this.maxQueued = opts.maxQueued ?? DEFAULT_MAX_QUEUED;
|
|
56
58
|
this.queueTimeoutMs = opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
|
|
57
59
|
this.unrefTimers = opts.unrefTimers ?? true;
|
|
60
|
+
this.now = opts.now ?? Date.now;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Re-evaluate the stall stamp. Called after every state change, so it marks
|
|
64
|
+
* when the stall BEGAN rather than when it was last observed — a caller that
|
|
65
|
+
* never polls still reads an accurate duration.
|
|
66
|
+
*
|
|
67
|
+
* Arrivals must NOT refresh the stamp: under a steady arrival rate that
|
|
68
|
+
* would reset the clock continuously and hide a permanent wedge. Only
|
|
69
|
+
* `release()` refreshes it, by clearing first (see there).
|
|
70
|
+
*/
|
|
71
|
+
updateStall() {
|
|
72
|
+
const atCapacity = this.active >= this.maxConcurrent && this.queue.length > 0;
|
|
73
|
+
if (!atCapacity) {
|
|
74
|
+
this.stalledSince = null;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (this.stalledSince === null)
|
|
78
|
+
this.stalledSince = this.now();
|
|
58
79
|
}
|
|
59
80
|
/**
|
|
60
81
|
* Acquire a concurrency slot. Resolves when admitted; throws
|
|
@@ -66,17 +87,19 @@ export class RequestQueue {
|
|
|
66
87
|
const decision = decideAdmit(this.snapshot());
|
|
67
88
|
if (decision.action === 'admit') {
|
|
68
89
|
this.active++;
|
|
90
|
+
this.updateStall();
|
|
69
91
|
return;
|
|
70
92
|
}
|
|
71
93
|
if (decision.action === 'reject') {
|
|
72
94
|
throw new QueueFullError();
|
|
73
95
|
}
|
|
74
96
|
return new Promise((resolve, reject) => {
|
|
75
|
-
const enqueuedAt =
|
|
97
|
+
const enqueuedAt = this.now();
|
|
76
98
|
const timeoutHandle = setTimeout(() => {
|
|
77
99
|
const idx = this.queue.indexOf(entry);
|
|
78
100
|
if (idx >= 0) {
|
|
79
101
|
this.queue.splice(idx, 1);
|
|
102
|
+
this.updateStall();
|
|
80
103
|
reject(new QueueTimeoutError());
|
|
81
104
|
}
|
|
82
105
|
}, this.queueTimeoutMs);
|
|
@@ -87,6 +110,7 @@ export class RequestQueue {
|
|
|
87
110
|
timeoutHandle.unref?.();
|
|
88
111
|
const entry = { resolve, reject, enqueuedAt, timeoutHandle };
|
|
89
112
|
this.queue.push(entry);
|
|
113
|
+
this.updateStall();
|
|
90
114
|
});
|
|
91
115
|
}
|
|
92
116
|
/** Release a slot. The next queued entry (if any) is admitted in FIFO order. */
|
|
@@ -99,14 +123,22 @@ export class RequestQueue {
|
|
|
99
123
|
this.active++;
|
|
100
124
|
next.resolve();
|
|
101
125
|
}
|
|
126
|
+
// A release IS turnover — the thing whose absence defines the wedge — so
|
|
127
|
+
// clear the stamp unconditionally before re-evaluating. A queue that is
|
|
128
|
+
// still at capacity immediately starts a FRESH stall window, which is why
|
|
129
|
+
// a saturated-but-flowing dario never accumulates age here while a
|
|
130
|
+
// genuinely wedged one does.
|
|
131
|
+
this.stalledSince = null;
|
|
132
|
+
this.updateStall();
|
|
102
133
|
}
|
|
103
|
-
/** Snapshot of queue state — exposed for /analytics + tests. */
|
|
134
|
+
/** Snapshot of queue state — exposed for /health + /analytics + tests. */
|
|
104
135
|
snapshot() {
|
|
105
136
|
return {
|
|
106
137
|
active: this.active,
|
|
107
138
|
queued: this.queue.length,
|
|
108
139
|
maxConcurrent: this.maxConcurrent,
|
|
109
140
|
maxQueued: this.maxQueued,
|
|
141
|
+
stalledSince: this.stalledSince,
|
|
110
142
|
};
|
|
111
143
|
}
|
|
112
144
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* serving-probe.ts — the "can dario actually serve a request?" check.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (dario#905, dario#921)
|
|
5
|
+
*
|
|
6
|
+
* dario's existing health surfaces are both STRUCTURAL — they inspect state,
|
|
7
|
+
* they never prove anything end-to-end:
|
|
8
|
+
*
|
|
9
|
+
* /livez the HTTP server is accepting connections. Always 200.
|
|
10
|
+
* /health credentials/pool look sane: token not expired, refresh not broken,
|
|
11
|
+
* accounts not all in auth-cooldown, queue snapshot attached.
|
|
12
|
+
*
|
|
13
|
+
* Neither can catch the failure class that actually took a user down for ~14h
|
|
14
|
+
* across four episodes (#905): dario looking perfectly healthy from the outside
|
|
15
|
+
* while every real request failed. The reporter's remediation was an external
|
|
16
|
+
* watchdog that sends a real inference call and restarts the service when it
|
|
17
|
+
* doesn't come back — i.e. the user had to write the liveness check dario
|
|
18
|
+
* should own. This module is that check, brought in-house.
|
|
19
|
+
*
|
|
20
|
+
* It also covers a class /health provably cannot see: an account whose token is
|
|
21
|
+
* unexpired and refreshable (so `status: healthy`) but whose accountUuid has
|
|
22
|
+
* drifted, so upstream 401s every request. Structural inspection says fine;
|
|
23
|
+
* only a round-trip says otherwise.
|
|
24
|
+
*
|
|
25
|
+
* WHAT IT PROVES, AND WHAT IT DELIBERATELY DOES NOT
|
|
26
|
+
*
|
|
27
|
+
* The probe is a minimal `POST /v1/messages` (max_tokens 1) sent DIRECTLY to
|
|
28
|
+
* api.anthropic.com with the same auth the request path uses — pool bearer, or
|
|
29
|
+
* `x-api-key` in upstream-API-key mode. That proves: a token can be acquired,
|
|
30
|
+
* the network path is up, and upstream accepts our credential.
|
|
31
|
+
*
|
|
32
|
+
* It deliberately does NOT go through dario's own proxy path:
|
|
33
|
+
* - No recursion, no self-deadlock, no port/auth assumptions.
|
|
34
|
+
* - It must not take a concurrency slot. A probe that queues behind real
|
|
35
|
+
* traffic would report "unhealthy" during a legitimate burst and hand a
|
|
36
|
+
* watchdog a reason to restart a busy-but-fine dario. Slot exhaustion is
|
|
37
|
+
* covered instead by `queue.saturatedSince` (request-queue.ts), which is
|
|
38
|
+
* free, needs no tokens, and cannot false-positive on a short burst.
|
|
39
|
+
*
|
|
40
|
+
* So: the probe covers the credential/network axis, the queue snapshot covers
|
|
41
|
+
* the concurrency axis. Neither claims to cover the transform path.
|
|
42
|
+
*
|
|
43
|
+
* COST AND EXPOSURE
|
|
44
|
+
*
|
|
45
|
+
* A probe is a real billed request. Three guards, all load-bearing:
|
|
46
|
+
* 1. OPT-IN. Only runs when a caller explicitly asks (`/health?probe=1`).
|
|
47
|
+
* A plain /health never spends a token — existing docker healthchecks and
|
|
48
|
+
* uptime monitors keep their current cost profile, which is zero.
|
|
49
|
+
* 2. TRUSTED CALLERS ONLY. proxy.ts honours `?probe=1` only for callers that
|
|
50
|
+
* already pass shouldDiscloseHealthInternals. A world-readable /health
|
|
51
|
+
* behind a Cloudflare tunnel bypass must not be a button the internet can
|
|
52
|
+
* press to spend the operator's tokens.
|
|
53
|
+
* 3. CACHED + SINGLE-FLIGHTED. Results are reused for `ttlMs` (default 60s)
|
|
54
|
+
* and concurrent callers share one in-flight request, so a monitor polling
|
|
55
|
+
* every second still costs at most one probe per minute.
|
|
56
|
+
*
|
|
57
|
+
* VERDICT SEMANTICS — why 429 is NOT a failure
|
|
58
|
+
*
|
|
59
|
+
* `ok` answers "would a restart or an alert help?", not "did the call return
|
|
60
|
+
* 200". Rate-limited (429) and upstream-overloaded (529) are healthy states:
|
|
61
|
+
* dario is working, the answer is legitimately "not right now", and a watchdog
|
|
62
|
+
* that restarts on them just thrashes. That is the same lesson /livez already
|
|
63
|
+
* encodes in proxy.ts — a shared-refresh-family outage once had dario restart
|
|
64
|
+
* -looping for 4h+ because the healthcheck keyed on a condition a restart
|
|
65
|
+
* cannot fix. Auth rejection, 5xx, network failure and timeout DO set ok=false.
|
|
66
|
+
*/
|
|
67
|
+
/** Cheapest family, and the one doctor's own probe already uses. */
|
|
68
|
+
export declare const DEFAULT_PROBE_MODEL = "claude-haiku-4-5";
|
|
69
|
+
export declare const DEFAULT_PROBE_TTL_MS = 60000;
|
|
70
|
+
export declare const DEFAULT_PROBE_TIMEOUT_MS = 15000;
|
|
71
|
+
/**
|
|
72
|
+
* Why the round-trip ended the way it did. Carried on both verdicts so an
|
|
73
|
+
* operator reading a green probe can still tell "served" from "rate-limited".
|
|
74
|
+
*/
|
|
75
|
+
export type ProbeReason = 'served' | 'rate-limited' | 'upstream-overloaded' | 'no-token' | 'auth-rejected' | 'upstream-error' | 'timeout' | 'network-error';
|
|
76
|
+
export interface ProbeResult {
|
|
77
|
+
/** False only for conditions where dario is the problem — see module header. */
|
|
78
|
+
ok: boolean;
|
|
79
|
+
reason: ProbeReason;
|
|
80
|
+
/** Epoch ms the round-trip completed. */
|
|
81
|
+
checkedAt: number;
|
|
82
|
+
/** Wall time of the round-trip, including token acquisition. */
|
|
83
|
+
latencyMs: number;
|
|
84
|
+
model: string;
|
|
85
|
+
/** Upstream HTTP status, when upstream answered at all. */
|
|
86
|
+
status?: number;
|
|
87
|
+
/** Short failure detail. Never carries a token or a raw body. */
|
|
88
|
+
detail?: string;
|
|
89
|
+
}
|
|
90
|
+
export interface ProbeDeps {
|
|
91
|
+
fetchImpl?: typeof fetch;
|
|
92
|
+
/** OAuth bearer source. Ignored when upstreamApiKey is set. */
|
|
93
|
+
getToken?: () => Promise<string>;
|
|
94
|
+
/** Per-token API mode — forwarded as x-api-key, mirroring request-path auth. */
|
|
95
|
+
upstreamApiKey?: string;
|
|
96
|
+
now?: () => number;
|
|
97
|
+
model?: string;
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
ttlMs?: number;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Map an upstream HTTP status onto a verdict. Pure, so the whole policy
|
|
103
|
+
* ("which statuses mean dario is broken") is testable without a network.
|
|
104
|
+
*/
|
|
105
|
+
export declare function classifyProbeStatus(status: number): {
|
|
106
|
+
ok: boolean;
|
|
107
|
+
reason: ProbeReason;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* The cached verdict, refreshing it when stale. Never throws — a health
|
|
111
|
+
* surface that can 500 is worse than useless, so every failure path becomes a
|
|
112
|
+
* `ok: false` verdict with a reason instead of an exception.
|
|
113
|
+
*
|
|
114
|
+
* Concurrent callers past the TTL share one in-flight probe; the loser of the
|
|
115
|
+
* race gets the same result rather than sending a second billed request.
|
|
116
|
+
*/
|
|
117
|
+
export declare function getServingProbe(deps?: ProbeDeps): Promise<ProbeResult>;
|
|
118
|
+
/** Age of the cached verdict, for the `ageMs` field callers see. */
|
|
119
|
+
export declare function probeAgeMs(result: ProbeResult, now: number): number;
|
|
120
|
+
export declare function _resetServingProbeForTest(): void;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* serving-probe.ts — the "can dario actually serve a request?" check.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (dario#905, dario#921)
|
|
5
|
+
*
|
|
6
|
+
* dario's existing health surfaces are both STRUCTURAL — they inspect state,
|
|
7
|
+
* they never prove anything end-to-end:
|
|
8
|
+
*
|
|
9
|
+
* /livez the HTTP server is accepting connections. Always 200.
|
|
10
|
+
* /health credentials/pool look sane: token not expired, refresh not broken,
|
|
11
|
+
* accounts not all in auth-cooldown, queue snapshot attached.
|
|
12
|
+
*
|
|
13
|
+
* Neither can catch the failure class that actually took a user down for ~14h
|
|
14
|
+
* across four episodes (#905): dario looking perfectly healthy from the outside
|
|
15
|
+
* while every real request failed. The reporter's remediation was an external
|
|
16
|
+
* watchdog that sends a real inference call and restarts the service when it
|
|
17
|
+
* doesn't come back — i.e. the user had to write the liveness check dario
|
|
18
|
+
* should own. This module is that check, brought in-house.
|
|
19
|
+
*
|
|
20
|
+
* It also covers a class /health provably cannot see: an account whose token is
|
|
21
|
+
* unexpired and refreshable (so `status: healthy`) but whose accountUuid has
|
|
22
|
+
* drifted, so upstream 401s every request. Structural inspection says fine;
|
|
23
|
+
* only a round-trip says otherwise.
|
|
24
|
+
*
|
|
25
|
+
* WHAT IT PROVES, AND WHAT IT DELIBERATELY DOES NOT
|
|
26
|
+
*
|
|
27
|
+
* The probe is a minimal `POST /v1/messages` (max_tokens 1) sent DIRECTLY to
|
|
28
|
+
* api.anthropic.com with the same auth the request path uses — pool bearer, or
|
|
29
|
+
* `x-api-key` in upstream-API-key mode. That proves: a token can be acquired,
|
|
30
|
+
* the network path is up, and upstream accepts our credential.
|
|
31
|
+
*
|
|
32
|
+
* It deliberately does NOT go through dario's own proxy path:
|
|
33
|
+
* - No recursion, no self-deadlock, no port/auth assumptions.
|
|
34
|
+
* - It must not take a concurrency slot. A probe that queues behind real
|
|
35
|
+
* traffic would report "unhealthy" during a legitimate burst and hand a
|
|
36
|
+
* watchdog a reason to restart a busy-but-fine dario. Slot exhaustion is
|
|
37
|
+
* covered instead by `queue.saturatedSince` (request-queue.ts), which is
|
|
38
|
+
* free, needs no tokens, and cannot false-positive on a short burst.
|
|
39
|
+
*
|
|
40
|
+
* So: the probe covers the credential/network axis, the queue snapshot covers
|
|
41
|
+
* the concurrency axis. Neither claims to cover the transform path.
|
|
42
|
+
*
|
|
43
|
+
* COST AND EXPOSURE
|
|
44
|
+
*
|
|
45
|
+
* A probe is a real billed request. Three guards, all load-bearing:
|
|
46
|
+
* 1. OPT-IN. Only runs when a caller explicitly asks (`/health?probe=1`).
|
|
47
|
+
* A plain /health never spends a token — existing docker healthchecks and
|
|
48
|
+
* uptime monitors keep their current cost profile, which is zero.
|
|
49
|
+
* 2. TRUSTED CALLERS ONLY. proxy.ts honours `?probe=1` only for callers that
|
|
50
|
+
* already pass shouldDiscloseHealthInternals. A world-readable /health
|
|
51
|
+
* behind a Cloudflare tunnel bypass must not be a button the internet can
|
|
52
|
+
* press to spend the operator's tokens.
|
|
53
|
+
* 3. CACHED + SINGLE-FLIGHTED. Results are reused for `ttlMs` (default 60s)
|
|
54
|
+
* and concurrent callers share one in-flight request, so a monitor polling
|
|
55
|
+
* every second still costs at most one probe per minute.
|
|
56
|
+
*
|
|
57
|
+
* VERDICT SEMANTICS — why 429 is NOT a failure
|
|
58
|
+
*
|
|
59
|
+
* `ok` answers "would a restart or an alert help?", not "did the call return
|
|
60
|
+
* 200". Rate-limited (429) and upstream-overloaded (529) are healthy states:
|
|
61
|
+
* dario is working, the answer is legitimately "not right now", and a watchdog
|
|
62
|
+
* that restarts on them just thrashes. That is the same lesson /livez already
|
|
63
|
+
* encodes in proxy.ts — a shared-refresh-family outage once had dario restart
|
|
64
|
+
* -looping for 4h+ because the healthcheck keyed on a condition a restart
|
|
65
|
+
* cannot fix. Auth rejection, 5xx, network failure and timeout DO set ok=false.
|
|
66
|
+
*/
|
|
67
|
+
const ANTHROPIC_MESSAGES = 'https://api.anthropic.com/v1/messages';
|
|
68
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
69
|
+
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
70
|
+
/** Cheapest family, and the one doctor's own probe already uses. */
|
|
71
|
+
export const DEFAULT_PROBE_MODEL = 'claude-haiku-4-5';
|
|
72
|
+
export const DEFAULT_PROBE_TTL_MS = 60_000;
|
|
73
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
|
|
74
|
+
let cache = null;
|
|
75
|
+
let inflight = null;
|
|
76
|
+
function envInt(name, dflt) {
|
|
77
|
+
const v = Number(process.env[name]);
|
|
78
|
+
return Number.isFinite(v) && v > 0 ? v : dflt;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Map an upstream HTTP status onto a verdict. Pure, so the whole policy
|
|
82
|
+
* ("which statuses mean dario is broken") is testable without a network.
|
|
83
|
+
*/
|
|
84
|
+
export function classifyProbeStatus(status) {
|
|
85
|
+
if (status >= 200 && status < 300)
|
|
86
|
+
return { ok: true, reason: 'served' };
|
|
87
|
+
// Serving fine, just throttled — restarting cannot help, so do not fail.
|
|
88
|
+
if (status === 429)
|
|
89
|
+
return { ok: true, reason: 'rate-limited' };
|
|
90
|
+
if (status === 529)
|
|
91
|
+
return { ok: true, reason: 'upstream-overloaded' };
|
|
92
|
+
// The credential is being rejected even though local state looks valid.
|
|
93
|
+
// This is the accountUuid-drift signature /health structurally cannot see.
|
|
94
|
+
if (status === 401 || status === 403)
|
|
95
|
+
return { ok: false, reason: 'auth-rejected' };
|
|
96
|
+
return { ok: false, reason: 'upstream-error' };
|
|
97
|
+
}
|
|
98
|
+
async function runProbe(deps) {
|
|
99
|
+
const f = deps.fetchImpl ?? fetch;
|
|
100
|
+
const now = deps.now ?? Date.now;
|
|
101
|
+
const model = deps.model ?? process.env.DARIO_PROBE_MODEL ?? DEFAULT_PROBE_MODEL;
|
|
102
|
+
const timeoutMs = deps.timeoutMs ?? envInt('DARIO_PROBE_TIMEOUT_MS', DEFAULT_PROBE_TIMEOUT_MS);
|
|
103
|
+
const startedAt = now();
|
|
104
|
+
// Arm the deadline BEFORE token acquisition so a wedged refresh is bounded
|
|
105
|
+
// too, not just the fetch — the same trap model-catalog.ts hit in #642: a
|
|
106
|
+
// getToken() that never settles would leave `inflight` non-null forever and
|
|
107
|
+
// permanently wedge every future probe on a stale cached verdict.
|
|
108
|
+
const ctl = new AbortController();
|
|
109
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
110
|
+
const finish = (r) => ({
|
|
111
|
+
...r,
|
|
112
|
+
model,
|
|
113
|
+
checkedAt: now(),
|
|
114
|
+
latencyMs: now() - startedAt,
|
|
115
|
+
});
|
|
116
|
+
try {
|
|
117
|
+
const headers = {
|
|
118
|
+
'content-type': 'application/json',
|
|
119
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
120
|
+
};
|
|
121
|
+
if (deps.upstreamApiKey) {
|
|
122
|
+
headers['x-api-key'] = deps.upstreamApiKey;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
if (!deps.getToken)
|
|
126
|
+
return finish({ ok: false, reason: 'no-token', detail: 'no token source configured' });
|
|
127
|
+
let token;
|
|
128
|
+
try {
|
|
129
|
+
token = await Promise.race([
|
|
130
|
+
deps.getToken(),
|
|
131
|
+
new Promise((_, rej) => {
|
|
132
|
+
ctl.signal.addEventListener('abort', () => rej(new Error('token acquisition timed out')), { once: true });
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
// An empty pool lands here — the message already says so (pool.ts /
|
|
138
|
+
// catalogDeps phrase it for the operator), so surface it verbatim.
|
|
139
|
+
return finish({ ok: false, reason: 'no-token', detail: errText(err) });
|
|
140
|
+
}
|
|
141
|
+
headers['authorization'] = `Bearer ${token}`;
|
|
142
|
+
headers['anthropic-beta'] = OAUTH_BETA;
|
|
143
|
+
}
|
|
144
|
+
const res = await f(ANTHROPIC_MESSAGES, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers,
|
|
147
|
+
body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }),
|
|
148
|
+
signal: ctl.signal,
|
|
149
|
+
});
|
|
150
|
+
// Drain so the socket is released back to the agent pool. The body is
|
|
151
|
+
// never inspected: a 200 is the whole signal, and the content could carry
|
|
152
|
+
// model output we have no reason to log.
|
|
153
|
+
await res.text().catch(() => '');
|
|
154
|
+
const verdict = classifyProbeStatus(res.status);
|
|
155
|
+
return finish({ ...verdict, status: res.status });
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const aborted = ctl.signal.aborted || err?.name === 'AbortError';
|
|
159
|
+
return finish({
|
|
160
|
+
ok: false,
|
|
161
|
+
reason: aborted ? 'timeout' : 'network-error',
|
|
162
|
+
detail: aborted ? `no upstream response within ${timeoutMs}ms` : errText(err),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function errText(err) {
|
|
170
|
+
return err instanceof Error ? err.message : String(err);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The cached verdict, refreshing it when stale. Never throws — a health
|
|
174
|
+
* surface that can 500 is worse than useless, so every failure path becomes a
|
|
175
|
+
* `ok: false` verdict with a reason instead of an exception.
|
|
176
|
+
*
|
|
177
|
+
* Concurrent callers past the TTL share one in-flight probe; the loser of the
|
|
178
|
+
* race gets the same result rather than sending a second billed request.
|
|
179
|
+
*/
|
|
180
|
+
export async function getServingProbe(deps = {}) {
|
|
181
|
+
const now = (deps.now ?? Date.now)();
|
|
182
|
+
const ttl = deps.ttlMs ?? envInt('DARIO_PROBE_TTL_MS', DEFAULT_PROBE_TTL_MS);
|
|
183
|
+
if (cache !== null && now - cache.checkedAt < ttl)
|
|
184
|
+
return cache;
|
|
185
|
+
if (inflight !== null)
|
|
186
|
+
return inflight;
|
|
187
|
+
inflight = runProbe(deps)
|
|
188
|
+
.then((r) => {
|
|
189
|
+
cache = r;
|
|
190
|
+
return r;
|
|
191
|
+
})
|
|
192
|
+
.finally(() => {
|
|
193
|
+
inflight = null;
|
|
194
|
+
});
|
|
195
|
+
return inflight;
|
|
196
|
+
}
|
|
197
|
+
/** Age of the cached verdict, for the `ageMs` field callers see. */
|
|
198
|
+
export function probeAgeMs(result, now) {
|
|
199
|
+
return Math.max(0, now - result.checkedAt);
|
|
200
|
+
}
|
|
201
|
+
export function _resetServingProbeForTest() {
|
|
202
|
+
cache = null;
|
|
203
|
+
inflight = null;
|
|
204
|
+
}
|
package/docs/usage.md
CHANGED
|
@@ -121,3 +121,58 @@ const backends = await listBackends();
|
|
|
121
121
|
```bash
|
|
122
122
|
curl http://localhost:3456/health
|
|
123
123
|
```
|
|
124
|
+
|
|
125
|
+
dario has three health surfaces, and they answer different questions:
|
|
126
|
+
|
|
127
|
+
| Endpoint | Answers | Costs |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `GET /livez` | Is the HTTP server accepting connections? Always 200. | nothing |
|
|
130
|
+
| `GET /health` | Do credentials and the pool look serviceable? 503 when not. | nothing |
|
|
131
|
+
| `GET /health?probe=1` | Did a real request to Anthropic just succeed? | one tiny billed request per TTL |
|
|
132
|
+
|
|
133
|
+
`/health` inspects state; it does not prove anything end-to-end. Adding
|
|
134
|
+
`?probe=1` sends a real `max_tokens: 1` request upstream and folds the verdict
|
|
135
|
+
into the response, so a proxy whose credentials look fine but whose requests all
|
|
136
|
+
fail returns 503 instead of `ok`:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"status": "degraded",
|
|
141
|
+
"oauth": "valid",
|
|
142
|
+
"probe": { "ok": false, "reason": "auth-rejected", "status": 401,
|
|
143
|
+
"latencyMs": 233, "ageMs": 4812, "model": "claude-haiku-4-5" },
|
|
144
|
+
"queue": { "active": 10, "queued": 4, "maxConcurrent": 10,
|
|
145
|
+
"stalledSince": 1754790000000, "stalledForMs": 28800000 }
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Notes that matter in production:
|
|
150
|
+
|
|
151
|
+
- **The probe is opt-in and never runs on a plain `/health`.** Existing docker
|
|
152
|
+
healthchecks and uptime monitors keep costing nothing.
|
|
153
|
+
- **Only trusted callers can trigger it** — authenticated, or loopback that did
|
|
154
|
+
not arrive through a Cloudflare tunnel (the same gate that governs the OAuth
|
|
155
|
+
internals). A world-readable `/health` is not a button for spending tokens.
|
|
156
|
+
- **Results are cached and single-flighted** (`DARIO_PROBE_TTL_MS`, default
|
|
157
|
+
60000), so polling every second still costs at most one probe per minute.
|
|
158
|
+
- **A rate-limited or overloaded upstream is not an outage.** 429 and 529 keep
|
|
159
|
+
`ok: true`; restarting dario cannot help either, and a watchdog that keys on
|
|
160
|
+
them just thrashes. Only auth rejection, 5xx, network failure and timeout set
|
|
161
|
+
`ok: false`.
|
|
162
|
+
- **`queue.stalledForMs` is the slot-exhaustion signal**, not `active`/`queued`.
|
|
163
|
+
A busy proxy legitimately sits at its concurrency cap with a backlog; the
|
|
164
|
+
failure mode in dario#905 was slots that stopped turning over entirely. Any
|
|
165
|
+
release resets the stall clock, so sustained load never trips it.
|
|
166
|
+
|
|
167
|
+
A watchdog wants the probe; a container healthcheck usually does not:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# liveness — restart only if the process itself is gone
|
|
171
|
+
curl -sf http://localhost:3456/livez
|
|
172
|
+
|
|
173
|
+
# real serving check, e.g. every 5 minutes
|
|
174
|
+
curl -sf 'http://localhost:3456/health?probe=1' >/dev/null || alert
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Knobs: `DARIO_PROBE_MODEL` (default `claude-haiku-4-5`),
|
|
178
|
+
`DARIO_PROBE_TTL_MS` (default `60000`), `DARIO_PROBE_TIMEOUT_MS` (default `15000`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|