From d59ab285d09b67526de93800fb4011b6d4fc5448 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Tue, 10 Nov 2026 19:59:44 -0500 Subject: [PATCH] fix: Record a client that leaves before the head as client_canceled When the client leaves before the response head, and Praxis writes nothing to it, Praxis 0.8.0 gives the deferred-record hook status 0, not 499. That case went to the catch-all and made a gateway_refused row, so agentop showed a gateway failure for a client that stopped. Status 0 now gives client_canceled (499), as in direct mode. Found in the #1725 live check: a Claude Code process exited while the hop waited for the head of GET /mcp-registry/v0/servers. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- praxis/filters/src/filters/observe.rs | 2 +- praxis/filters/src/record/row.rs | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/praxis/filters/src/filters/observe.rs b/praxis/filters/src/filters/observe.rs index aabfc6942..f6c7831bb 100644 --- a/praxis/filters/src/filters/observe.rs +++ b/praxis/filters/src/filters/observe.rs @@ -259,7 +259,7 @@ impl HttpFilter for CortexObserve { } o.done = true; // Before the response head, Praxis 0.8.0 gives a client that went - // away as status 499. After the head, it gives the written status and + // away as status 499, or as 0 when it wrote nothing. After the head, it gives the written status and // `upstream_reached` for both a client and an upstream that went away. // shortcut: a stream cut after the head is always `client_canceled` // (the frequent case: a user stop or a Cortex idle cut), so an diff --git a/praxis/filters/src/record/row.rs b/praxis/filters/src/record/row.rs index 56d9535bf..f20e80f26 100644 --- a/praxis/filters/src/record/row.rs +++ b/praxis/filters/src/record/row.rs @@ -544,7 +544,9 @@ impl Exchange { None => format!("request body over the {MAX_REQUEST_BODY}-byte buffer limit"), }; (413, ("proxy_error", String::new(), message)) - } else if downstream_gone || status == 499 { + // Status 0: Praxis wrote nothing to the client, so the client left + // before any answer (an upstream failure is written as 502). + } else if downstream_gone || status == 499 || status == 0 { (499, ("client_canceled", code, CLIENT_GONE.to_owned())) } else if upstream_gone { ( @@ -1138,6 +1140,21 @@ mod tests { } } + // Seen in the #1725 live check: the agent exited while the hop waited for + // the head, so Praxis wrote nothing and gave status 0. + #[test] + fn deferred_nothing_written_is_client_canceled() { + let f = fixture(); + let mut ex = exchange("GET", "api.example.com", &f.target, &f.req_headers); + let rows: Vec = ex.deferred(0, false, false).iter().map(json_of).collect(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1]["event"]["statusCode"], 499); + assert_eq!( + rows[1]["event"]["error"], + json!({"kind": "client_canceled", "message": CLIENT_GONE}) + ); + } + #[test] fn deferred_upstream_gone_is_upstream_error() { let rows = streaming_exchange().deferred(200, false, true);