@miller-tech/uap 1.172.9 → 1.172.11
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/config/llama-profiles/gemma4-26b-a4b-mtp.env +8 -1
- package/config/llama-profiles/qwen36-35b-a3b.env +20 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/deliver-detach.d.ts.map +1 -1
- package/dist/cli/deliver-detach.js +11 -1
- package/dist/cli/deliver-detach.js.map +1 -1
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +6 -0
- package/dist/cli/deliver.js.map +1 -1
- package/dist/delivery/orphan-guard.d.ts +59 -0
- package/dist/delivery/orphan-guard.d.ts.map +1 -0
- package/dist/delivery/orphan-guard.js +122 -0
- package/dist/delivery/orphan-guard.js.map +1 -0
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +85 -0
- package/tools/agents/tests/test_client_disconnect.py +99 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop a detached deliver run once the session that ordered it is gone.
|
|
3
|
+
*
|
|
4
|
+
* `uap deliver` detaches on purpose (see deliver-detach.ts): a mission must
|
|
5
|
+
* outlive the agent's bash tool call, which is a short-lived, process-group-
|
|
6
|
+
* killed container. That is correct and this must not undo it.
|
|
7
|
+
*
|
|
8
|
+
* What it must not outlive is the SESSION. Observed live: an agent client
|
|
9
|
+
* exited, and two detached deliver runs kept driving the model for over an hour
|
|
10
|
+
* — holding both slots, generating tokens for a conversation nobody would ever
|
|
11
|
+
* read — while the operator reasonably reported "I have nothing running". Only
|
|
12
|
+
* killing them by pid stopped it.
|
|
13
|
+
*
|
|
14
|
+
* WHY NOT THE OBVIOUS CHECKS
|
|
15
|
+
* - `ppid === 1`: wrong on any modern Linux desktop. Both orphans observed here
|
|
16
|
+
* had ppid 11681 — the systemd --user manager, which registers as a child
|
|
17
|
+
* subreaper, so orphans are re-parented to IT and never to init.
|
|
18
|
+
* - "re-parented since start": fires immediately on every detached run, because
|
|
19
|
+
* being re-parented is exactly what detaching does. It would kill the feature.
|
|
20
|
+
*
|
|
21
|
+
* So the guard watches a specific OWNER pid — the nearest ancestor that is an
|
|
22
|
+
* agent client — resolved once at detach time and inherited by the child.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
/** Env var carrying the resolved owner pid across the detach boundary. */
|
|
26
|
+
export const OWNER_PID_ENV = 'UAP_DELIVER_OWNER_PID';
|
|
27
|
+
/** Poll interval. Slow on purpose — a janitor, not a latency path. */
|
|
28
|
+
const DEFAULT_INTERVAL_MS = 20_000;
|
|
29
|
+
/**
|
|
30
|
+
* Process names that own a deliver run. A deliver started by one of these is
|
|
31
|
+
* work on behalf of a live session; when that session goes, so does the reason
|
|
32
|
+
* for the run. Anything else (a plain shell, CI, systemd) is deliberately NOT
|
|
33
|
+
* matched — the guard then stays off rather than guessing.
|
|
34
|
+
*/
|
|
35
|
+
const CLIENT_COMMS = ['opencode', 'claude', 'cursor', 'codex', 'windsurf'];
|
|
36
|
+
/** `comm` and `ppid` for a pid, or null if it is gone / unreadable. */
|
|
37
|
+
export function readProcInfo(pid, procRoot = '/proc') {
|
|
38
|
+
try {
|
|
39
|
+
// /proc/<pid>/stat: "pid (comm) state ppid ..." — comm can contain spaces and
|
|
40
|
+
// parens, so split on the LAST ')' rather than tokenising the whole line.
|
|
41
|
+
const stat = readFileSync(`${procRoot}/${pid}/stat`, 'utf8');
|
|
42
|
+
const close = stat.lastIndexOf(')');
|
|
43
|
+
const open = stat.indexOf('(');
|
|
44
|
+
if (open < 0 || close < 0 || close < open)
|
|
45
|
+
return null;
|
|
46
|
+
const comm = stat.slice(open + 1, close);
|
|
47
|
+
const rest = stat.slice(close + 2).trim().split(/\s+/);
|
|
48
|
+
const ppid = Number(rest[1]);
|
|
49
|
+
return Number.isFinite(ppid) ? { comm, ppid } : null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null; // gone or unreadable — treat as absent
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Nearest ancestor that is an agent client, or undefined.
|
|
57
|
+
*
|
|
58
|
+
* Undefined is a first-class answer: a deliver run from a plain shell or from CI
|
|
59
|
+
* has no session to outlive, so it gets no guard at all.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveOwnerPid(startPid = process.ppid, opts = {}) {
|
|
62
|
+
const procRoot = opts.procRoot ?? '/proc';
|
|
63
|
+
let pid = startPid;
|
|
64
|
+
for (let depth = 0; depth < (opts.maxDepth ?? 12); depth++) {
|
|
65
|
+
if (pid <= 1)
|
|
66
|
+
return undefined;
|
|
67
|
+
const info = readProcInfo(pid, procRoot);
|
|
68
|
+
if (!info)
|
|
69
|
+
return undefined;
|
|
70
|
+
const comm = info.comm.toLowerCase();
|
|
71
|
+
if (CLIENT_COMMS.some((c) => comm === c || comm.startsWith(c)))
|
|
72
|
+
return pid;
|
|
73
|
+
pid = info.ppid;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/** Is that pid still around? */
|
|
78
|
+
export function pidAlive(pid) {
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 0); // signal 0: existence check, delivers nothing
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
// EPERM means it exists but belongs to someone else — alive for our purpose.
|
|
85
|
+
return err?.code === 'EPERM';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Exit when the owning session exits. No-op when there is no owner, or when the
|
|
90
|
+
* operator opted out with UAP_ALLOW_ORPHAN=1 (a deliberately detached run — say,
|
|
91
|
+
* a long mission started over SSH).
|
|
92
|
+
*
|
|
93
|
+
* Returns a stop function. The timer is unref'd: a watchdog that by itself kept
|
|
94
|
+
* the process alive would be its own bug.
|
|
95
|
+
*/
|
|
96
|
+
export function guardAgainstOwnerExit(opts = {}) {
|
|
97
|
+
const optOut = (process.env.UAP_ALLOW_ORPHAN ?? '').toLowerCase();
|
|
98
|
+
if (['1', 'true', 'on', 'yes'].includes(optOut))
|
|
99
|
+
return () => { };
|
|
100
|
+
const ownerPid = Number(process.env[OWNER_PID_ENV]);
|
|
101
|
+
if (!Number.isFinite(ownerPid) || ownerPid <= 1)
|
|
102
|
+
return () => { };
|
|
103
|
+
const isAlive = opts.isAlive ?? pidAlive;
|
|
104
|
+
const timer = setInterval(() => {
|
|
105
|
+
if (isAlive(ownerPid))
|
|
106
|
+
return;
|
|
107
|
+
clearInterval(timer);
|
|
108
|
+
if (opts.onOwnerGone) {
|
|
109
|
+
opts.onOwnerGone(ownerPid);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Say why on the way out: a run that vanishes silently is the same
|
|
113
|
+
// debugging problem as one that never stops.
|
|
114
|
+
console.error(`\nuap: the session that started this run (pid ${ownerPid}) has exited — ` +
|
|
115
|
+
`stopping rather than holding a model slot for output nobody will read. ` +
|
|
116
|
+
`Set ${'UAP_ALLOW_ORPHAN'}=1 to keep detached runs alive.`);
|
|
117
|
+
process.exit(130); // 128 + SIGINT: ended by circumstance, not by failing
|
|
118
|
+
}, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
119
|
+
timer.unref?.();
|
|
120
|
+
return () => clearInterval(timer);
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=orphan-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"orphan-guard.js","sourceRoot":"","sources":["../../src/delivery/orphan-guard.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -83,6 +83,7 @@ Dependencies
|
|
|
83
83
|
|
|
84
84
|
import asyncio
|
|
85
85
|
import contextvars
|
|
86
|
+
from typing import Awaitable, Callable
|
|
86
87
|
import copy
|
|
87
88
|
import hashlib
|
|
88
89
|
import json
|
|
@@ -2958,6 +2959,47 @@ _current_request_session: contextvars.ContextVar[str | None] = contextvars.Conte
|
|
|
2958
2959
|
"uap_current_request_session", default=None
|
|
2959
2960
|
)
|
|
2960
2961
|
|
|
2962
|
+
# Is the DOWNSTREAM client still there?
|
|
2963
|
+
#
|
|
2964
|
+
# Nothing used to ask. Once a turn was accepted, the guardrail loops kept
|
|
2965
|
+
# retrying and re-POSTing upstream on their own — so when the client died the
|
|
2966
|
+
# work carried on regardless. Observed live: two `uap deliver` runs were killed,
|
|
2967
|
+
# no socket remained on :4000, and the proxy still drove llama for minutes,
|
|
2968
|
+
# generating 32k tokens per orphaned turn that nobody would ever read. Only a
|
|
2969
|
+
# proxy restart stopped it.
|
|
2970
|
+
#
|
|
2971
|
+
# Same ContextVar trick as the session above: request-local without threading a
|
|
2972
|
+
# Request through every signature. Holds Starlette's `request.is_disconnected`.
|
|
2973
|
+
_current_client_gone: contextvars.ContextVar[
|
|
2974
|
+
Callable[[], Awaitable[bool]] | None
|
|
2975
|
+
] = contextvars.ContextVar("uap_current_client_gone", default=None)
|
|
2976
|
+
|
|
2977
|
+
|
|
2978
|
+
class ClientGoneError(Exception):
|
|
2979
|
+
"""The downstream client disconnected; abandon the turn.
|
|
2980
|
+
|
|
2981
|
+
Raised instead of returning a value so the whole in-flight chain unwinds at
|
|
2982
|
+
once — every guardrail loop, retry and recovery step above it stops without
|
|
2983
|
+
each needing its own check. Caught at the handler boundary and answered with
|
|
2984
|
+
a status nobody reads, since by definition there is no one left to read it.
|
|
2985
|
+
"""
|
|
2986
|
+
|
|
2987
|
+
|
|
2988
|
+
async def _client_gone() -> bool:
|
|
2989
|
+
"""True when the caller has hung up. Never raises, never blocks meaningfully.
|
|
2990
|
+
|
|
2991
|
+
A disconnect check that can itself fail or stall would be worse than no
|
|
2992
|
+
check: it sits in front of every upstream call. Any error means "assume the
|
|
2993
|
+
client is still there" — the pre-existing behaviour.
|
|
2994
|
+
"""
|
|
2995
|
+
probe = _current_client_gone.get()
|
|
2996
|
+
if probe is None:
|
|
2997
|
+
return False
|
|
2998
|
+
try:
|
|
2999
|
+
return bool(await probe())
|
|
3000
|
+
except Exception: # noqa: BLE001
|
|
3001
|
+
return False
|
|
3002
|
+
|
|
2961
3003
|
# Session admission state. _admitted_sessions maps session_id -> last-seen
|
|
2962
3004
|
# monotonic ts; OrderedDict insertion order is the LRU (oldest = front).
|
|
2963
3005
|
# Guarded by _admission_cond's lock (created lazily on the running event loop).
|
|
@@ -3289,6 +3331,12 @@ async def _post_with_retry(
|
|
|
3289
3331
|
# don't evict each other's KV (sticky, released by idle-TTL). No-op when
|
|
3290
3332
|
# PROXY_SESSION_ADMISSION is off. Runs BEFORE the per-request semaphore so a
|
|
3291
3333
|
# queued new session waits without holding a concurrency slot.
|
|
3334
|
+
# Every upstream call funnels through here, so one check covers all of them:
|
|
3335
|
+
# the guardrail retry loop, the completion-contract retries, recovery passes.
|
|
3336
|
+
# Placed BEFORE admission and the slot acquire so a dead client never takes a
|
|
3337
|
+
# concurrency slot away from a live one, and never queues behind one either.
|
|
3338
|
+
if await _client_gone():
|
|
3339
|
+
raise ClientGoneError("client disconnected before upstream call")
|
|
3292
3340
|
await _ensure_session_admitted(_current_request_session.get())
|
|
3293
3341
|
acquired = await _acquire_upstream_slot()
|
|
3294
3342
|
if not acquired:
|
|
@@ -3542,6 +3590,23 @@ app = FastAPI(
|
|
|
3542
3590
|
lifespan=lifespan,
|
|
3543
3591
|
)
|
|
3544
3592
|
|
|
3593
|
+
@app.exception_handler(ClientGoneError)
|
|
3594
|
+
async def _client_gone_handler(request: Request, exc: ClientGoneError):
|
|
3595
|
+
"""The caller hung up mid-turn: stop, and say so at INFO rather than ERROR.
|
|
3596
|
+
|
|
3597
|
+
499 is nginx's "client closed request" — chosen because no standard code
|
|
3598
|
+
means this and the body goes nowhere anyway; what matters is that the log
|
|
3599
|
+
line reads as an abandoned turn rather than a proxy fault, so this does not
|
|
3600
|
+
masquerade as an incident when someone kills an agent run.
|
|
3601
|
+
"""
|
|
3602
|
+
logger.info(
|
|
3603
|
+
"CLIENT GONE: abandoned %s mid-turn (session=%s) — no further upstream calls",
|
|
3604
|
+
request.url.path,
|
|
3605
|
+
(_current_request_session.get() or "?")[:24],
|
|
3606
|
+
)
|
|
3607
|
+
return Response(status_code=499)
|
|
3608
|
+
|
|
3609
|
+
|
|
3545
3610
|
@app.exception_handler(httpx.PoolTimeout)
|
|
3546
3611
|
async def _pool_timeout_handler(request: Request, exc: httpx.PoolTimeout):
|
|
3547
3612
|
"""Saturated upstream pool: answer 529 overloaded (Anthropic semantics —
|
|
@@ -10791,6 +10856,10 @@ async def messages(request: Request):
|
|
|
10791
10856
|
"""
|
|
10792
10857
|
global last_session_id
|
|
10793
10858
|
|
|
10859
|
+
# Publish this request's disconnect probe for the whole call tree. Set before
|
|
10860
|
+
# ANY upstream work so an already-dead client is caught on the first call.
|
|
10861
|
+
_current_client_gone.set(request.is_disconnected)
|
|
10862
|
+
|
|
10794
10863
|
body = await request.json()
|
|
10795
10864
|
is_stream = body.get("stream", False)
|
|
10796
10865
|
model = body.get("model", "default")
|
|
@@ -11067,6 +11136,8 @@ async def messages(request: Request):
|
|
|
11067
11136
|
strict_body,
|
|
11068
11137
|
{"Content-Type": "application/json"},
|
|
11069
11138
|
)
|
|
11139
|
+
except ClientGoneError:
|
|
11140
|
+
raise # control flow: never swallow into a broad handler
|
|
11070
11141
|
except Exception as exc:
|
|
11071
11142
|
# Check if upstream is hung before returning error
|
|
11072
11143
|
await _check_slot_hang(LLAMA_CPP_BASE.replace("/v1", "/slots"))
|
|
@@ -11099,6 +11170,8 @@ async def messages(request: Request):
|
|
|
11099
11170
|
strict_body,
|
|
11100
11171
|
{"Content-Type": "application/json"},
|
|
11101
11172
|
)
|
|
11173
|
+
except ClientGoneError:
|
|
11174
|
+
raise # control flow: never swallow into a broad handler
|
|
11102
11175
|
except Exception:
|
|
11103
11176
|
pass # fall through to next handler
|
|
11104
11177
|
if strict_resp.status_code != 200:
|
|
@@ -11116,6 +11189,8 @@ async def messages(request: Request):
|
|
|
11116
11189
|
strict_body,
|
|
11117
11190
|
{"Content-Type": "application/json"},
|
|
11118
11191
|
)
|
|
11192
|
+
except ClientGoneError:
|
|
11193
|
+
raise # control flow: never swallow into a broad handler
|
|
11119
11194
|
except Exception as exc:
|
|
11120
11195
|
return Response(
|
|
11121
11196
|
content=json.dumps(
|
|
@@ -11242,6 +11317,8 @@ async def messages(request: Request):
|
|
|
11242
11317
|
openai_resp = retry_data
|
|
11243
11318
|
else:
|
|
11244
11319
|
logger.info("DEGENERATE RETRY: retry insufficient, using truncated original")
|
|
11320
|
+
except ClientGoneError:
|
|
11321
|
+
raise # control flow: never swallow into a broad handler
|
|
11245
11322
|
except Exception as exc:
|
|
11246
11323
|
logger.warning("DEGENERATE RETRY: failed: %s", exc)
|
|
11247
11324
|
anthropic_resp = openai_to_anthropic_response(
|
|
@@ -11535,6 +11612,8 @@ async def messages(request: Request):
|
|
|
11535
11612
|
openai_body,
|
|
11536
11613
|
{"Content-Type": "application/json"},
|
|
11537
11614
|
)
|
|
11615
|
+
except ClientGoneError:
|
|
11616
|
+
raise # control flow: never swallow into a broad handler
|
|
11538
11617
|
except Exception as exc:
|
|
11539
11618
|
return Response(
|
|
11540
11619
|
content=json.dumps(
|
|
@@ -11565,6 +11644,8 @@ async def messages(request: Request):
|
|
|
11565
11644
|
openai_body,
|
|
11566
11645
|
{"Content-Type": "application/json"},
|
|
11567
11646
|
)
|
|
11647
|
+
except ClientGoneError:
|
|
11648
|
+
raise # control flow: never swallow into a broad handler
|
|
11568
11649
|
except Exception:
|
|
11569
11650
|
pass # fall through
|
|
11570
11651
|
if resp.status_code != 200:
|
|
@@ -11582,6 +11663,8 @@ async def messages(request: Request):
|
|
|
11582
11663
|
openai_body,
|
|
11583
11664
|
{"Content-Type": "application/json"},
|
|
11584
11665
|
)
|
|
11666
|
+
except ClientGoneError:
|
|
11667
|
+
raise # control flow: never swallow into a broad handler
|
|
11585
11668
|
except Exception as exc:
|
|
11586
11669
|
return Response(
|
|
11587
11670
|
content=json.dumps(
|
|
@@ -11699,6 +11782,8 @@ async def messages(request: Request):
|
|
|
11699
11782
|
openai_resp = retry_data
|
|
11700
11783
|
else:
|
|
11701
11784
|
logger.info("DEGENERATE RETRY (stream): no tool call, using truncated")
|
|
11785
|
+
except ClientGoneError:
|
|
11786
|
+
raise # control flow: never swallow into a broad handler
|
|
11702
11787
|
except Exception as exc:
|
|
11703
11788
|
logger.warning("DEGENERATE RETRY (stream): failed: %s", exc)
|
|
11704
11789
|
anthropic_resp = openai_to_anthropic_response(
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The proxy must abandon a turn when the downstream client disconnects.
|
|
3
|
+
|
|
4
|
+
Nothing used to ask whether the caller was still there. Once a turn was
|
|
5
|
+
accepted, the guardrail loops kept retrying and re-POSTing upstream on their
|
|
6
|
+
own — so when the client died the work carried on regardless. Observed live
|
|
7
|
+
(2026-07-29): two `uap deliver` runs were killed, no socket remained on :4000,
|
|
8
|
+
and the proxy still drove llama for minutes, generating ~32k tokens per
|
|
9
|
+
orphaned turn that nobody would ever read. Only a proxy restart stopped it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import importlib.util
|
|
14
|
+
import unittest
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_proxy():
|
|
19
|
+
p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
20
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy_disc", p)
|
|
21
|
+
assert spec is not None and spec.loader is not None
|
|
22
|
+
m = importlib.util.module_from_spec(spec)
|
|
23
|
+
spec.loader.exec_module(m)
|
|
24
|
+
return m
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
proxy = _load_proxy()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClientGoneProbeTest(unittest.TestCase):
|
|
31
|
+
def test_absent_probe_means_client_is_present(self):
|
|
32
|
+
"""No probe set (background task, test harness) must not read as gone —
|
|
33
|
+
that would abandon perfectly live turns."""
|
|
34
|
+
proxy._current_client_gone.set(None)
|
|
35
|
+
self.assertFalse(asyncio.run(proxy._client_gone()))
|
|
36
|
+
|
|
37
|
+
def test_reports_disconnect(self):
|
|
38
|
+
async def gone() -> bool:
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
proxy._current_client_gone.set(gone)
|
|
42
|
+
self.assertTrue(asyncio.run(proxy._client_gone()))
|
|
43
|
+
|
|
44
|
+
def test_reports_connected(self):
|
|
45
|
+
async def here() -> bool:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
proxy._current_client_gone.set(here)
|
|
49
|
+
self.assertFalse(asyncio.run(proxy._client_gone()))
|
|
50
|
+
|
|
51
|
+
def test_a_failing_probe_never_breaks_the_request(self):
|
|
52
|
+
"""This check sits in front of every upstream call. A probe that can
|
|
53
|
+
raise would be worse than no probe, so failure means 'still there'."""
|
|
54
|
+
async def broken() -> bool:
|
|
55
|
+
raise RuntimeError("receive channel exploded")
|
|
56
|
+
|
|
57
|
+
proxy._current_client_gone.set(broken)
|
|
58
|
+
self.assertFalse(asyncio.run(proxy._client_gone()))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ClientGoneIsNotSwallowedTest(unittest.TestCase):
|
|
62
|
+
"""The unwind only works if no broad handler eats it on the way out.
|
|
63
|
+
|
|
64
|
+
`messages()` has eight `except Exception` blocks. Any one of them catching
|
|
65
|
+
ClientGoneError would turn "client left" into a logged error and let the
|
|
66
|
+
turn continue — silently reinstating the bug while every other test passed.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def test_every_broad_except_in_messages_reraises_first(self):
|
|
70
|
+
src = (
|
|
71
|
+
Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
72
|
+
).read_text().split("\n")
|
|
73
|
+
start = next(i for i, l in enumerate(src) if l.startswith("async def messages(request: Request)"))
|
|
74
|
+
end = next(
|
|
75
|
+
(i for i in range(start + 1, len(src)) if src[i].startswith("async def ") or src[i].startswith("@app.")),
|
|
76
|
+
len(src),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
unguarded = []
|
|
80
|
+
for i in range(start, end):
|
|
81
|
+
stripped = src[i].lstrip()
|
|
82
|
+
if not stripped.startswith("except Exception") and not stripped.startswith("except BaseException"):
|
|
83
|
+
continue
|
|
84
|
+
# The two lines above must be the ClientGoneError re-raise.
|
|
85
|
+
prev = [src[i - 2].strip(), src[i - 1].strip()] if i >= 2 else []
|
|
86
|
+
if prev[:1] != ["except ClientGoneError:"] or prev[1:] and not prev[1].startswith("raise"):
|
|
87
|
+
unguarded.append((i + 1, stripped[:60]))
|
|
88
|
+
|
|
89
|
+
self.assertEqual(unguarded, [], f"broad handlers that would swallow the disconnect: {unguarded}")
|
|
90
|
+
|
|
91
|
+
def test_client_gone_is_an_exception_so_fastapi_can_handle_it(self):
|
|
92
|
+
"""Deliberately NOT a BaseException: Starlette's middleware only
|
|
93
|
+
dispatches Exception subclasses, so a BaseException would sail past the
|
|
94
|
+
registered handler and take down the worker instead of answering 499."""
|
|
95
|
+
self.assertTrue(issubclass(proxy.ClientGoneError, Exception))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
unittest.main()
|