Skip to main content

netget/server/pypi/
mod.rs

1//! PyPI (Python Package Index) server implementation
2//!
3//! Implements PEP 503 Simple Repository API for serving Python packages.
4
5pub mod actions;
6
7use std::collections::HashMap;
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http_body_util::{BodyExt, Full};
14use hyper::body::Incoming;
15use hyper::server::conn::http1;
16use hyper::service::service_fn;
17use hyper::{Request, Response};
18use hyper_util::rt::TokioIo;
19use tokio::sync::mpsc;
20use tracing::trace;
21
22use crate::llm::action_helper::call_llm;
23use crate::llm::ollama_client::OllamaClient;
24use crate::llm::ActionResult;
25use crate::logging::emit::Log;
26use crate::protocol::Event;
27use crate::server::accept_bounded::{
28    accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
29};
30use crate::server::connection::ConnectionId;
31use crate::server::PypiProtocol;
32use crate::state::app_state::AppState;
33use actions::PYPI_REQUEST_EVENT;
34
35/// How long a peer that has produced nothing at all may hold this connection.
36///
37/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
38/// installer sends it inside its dial path, so a peer that has connected and sent no byte
39/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
40/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
41///
42/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
43/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
44/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
45/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
46/// without consuming it, so the request line is still there for hyper afterwards.
47const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
48
49/// How long an established connection may sit **silent** between requests.
50///
51/// This bounds the silence and never a transfer. A request being answered holds
52/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
53/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
54/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
55/// ever runs on a connection with nothing in flight.
56///
57/// Five minutes: twenty times pip's own socket timeout (`--timeout`, 15 seconds by default), so
58/// a pip still waiting on us has long since given up before this fires, and four times nginx's
59/// `keepalive_timeout` default of 75s. What is left inside the window is the gap while pip
60/// resolves the next requirement or installs the wheel it just fetched - local work, on a
61/// connection it may reuse afterwards. RFC 9112 requires every client to retry a request on a
62/// persistent connection the server closed, and pip's urllib3 pool does exactly that.
63const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
64
65/// Concurrent connections this server admits.
66///
67/// Each admitted connection may hold one whole in-memory response body - a simple-index page or
68/// a wheel - so the cap is what turns that per-connection bound into a total one. pip fetches
69/// essentially serially, so 256 is far above any real installer and far below what socket
70/// exhaustion needs.
71const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
72
73/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
74///
75/// `503 Service Unavailable` with `Retry-After`, in PyPI's own vocabulary:
76/// pip treats a 503 as retryable and backs off, and a human
77/// with `curl` reads it directly.
78/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
79/// reason, under `decision=fail_closed_connection_cap`.
80const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
81    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
82
83/// PyPI server that delegates package serving to LLM
84pub struct PypiServer;
85
86impl PypiServer {
87    /// Spawn the PyPI server with integrated LLM actions
88    pub async fn spawn_with_llm_actions(
89        listen_addr: SocketAddr,
90        llm_client: OllamaClient,
91        app_state: Arc<AppState>,
92        status_tx: mpsc::UnboundedSender<String>,
93        server_id: crate::state::ServerId,
94    ) -> anyhow::Result<SocketAddr> {
95        let listener =
96            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
97        let local_addr = listener.local_addr()?;
98        Log::new(Some(&status_tx)).info(format!("PyPI server listening on {}", local_addr));
99
100        let protocol = Arc::new(PypiProtocol::new());
101
102        // Spawn server loop
103        let task_registrar = app_state.clone();
104        let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
105        let accept_handle = tokio::spawn(async move {
106            loop {
107                match accept_bounded(
108                    &listener,
109                    &limiter,
110                    CONNECTION_CAP_REFUSAL,
111                    "PYPI",
112                    Some(&status_tx),
113                )
114                .await
115                {
116                    Ok((stream, remote_addr, permit)) => {
117                        let connection_id =
118                            ConnectionId::new(app_state.get_next_unified_id().await);
119                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
120                        Log::new(Some(&status_tx)).info(format!(
121                            "Accepted PyPI connection {} from {}",
122                            connection_id, remote_addr
123                        ));
124
125                        // Add connection to ServerInstance
126                        use crate::state::server::{
127                            ConnectionState as ServerConnectionState, ConnectionStatus,
128                            ProtocolConnectionInfo,
129                        };
130                        let now = crate::utils::clock::Instant::now();
131                        let conn_state = ServerConnectionState {
132                            id: connection_id,
133                            remote_addr,
134                            local_addr: local_addr_conn,
135                            bytes_sent: 0,
136                            bytes_received: 0,
137                            packets_sent: 0,
138                            packets_received: 0,
139                            last_activity: now,
140                            status: ConnectionStatus::Active,
141                            status_changed_at: now,
142                            protocol_info: ProtocolConnectionInfo::empty(),
143                        };
144                        app_state
145                            .add_connection_to_server(server_id, conn_state)
146                            .await;
147                        let _ = status_tx.send("__UPDATE_UI__".to_string());
148
149                        let llm_client_clone = llm_client.clone();
150                        let app_state_clone = app_state.clone();
151                        let status_tx_clone = status_tx.clone();
152                        let protocol_clone = protocol.clone();
153
154                        // Spawn a task to handle this connection
155                        // Tracked, not detached: stop_server must abort this task too.
156                        let task_owner = app_state.clone();
157                        task_owner
158                            .spawn_server_task(server_id, async move {
159                                // Held for the life of the connection, so the
160                                // cap counts live clients, not accepts.
161                                let _permit = permit;
162
163                                // The first-byte bound, before hyper sees the
164                                // socket. `peek` waits for data without
165                                // consuming it, so the request line is still
166                                // there for hyper afterwards, and it bounds
167                                // exactly the case that needs bounding: a peer
168                                // that connected and said nothing at all.
169                                let spoke = matches!(
170                                    tokio::time::timeout(
171                                        FIRST_BYTE_READ_TIMEOUT,
172                                        stream.peek(&mut [0u8; 1]),
173                                    )
174                                    .await,
175                                    Ok(Ok(n)) if n > 0
176                                );
177
178                                if spoke {
179                                    let io = TokioIo::new(stream);
180
181                                    // Clone for service closure
182                                    let status_for_service = status_tx_clone.clone();
183                                    let app_state_for_service = app_state_clone.clone();
184
185                                    // Whether this connection is answering
186                                    // anything. The watchdog below reads it, so
187                                    // only genuine silence - never work in
188                                    // flight - can close a connection.
189                                    let activity =
190                                        Arc::new(ConnectionActivity::new());
191                                    let activity_for_service = Arc::clone(&activity);
192                                    // Create a service that handles requests with LLM
193                                    let service = service_fn(move |req: Request<Incoming>| {
194                                        let llm_clone = llm_client_clone.clone();
195                                        let state_clone = app_state_for_service.clone();
196                                        let status_clone = status_for_service.clone();
197                                        let protocol_clone = protocol_clone.clone();
198                                        // Busy for the whole of this request - the
199                                        // model round-trip included, and a `manual`
200                                        // rule parked for a human - so the idle
201                                        // watchdog can never close the connection an
202                                        // answer belongs to.
203                                        let activity = Arc::clone(&activity_for_service);
204                                        async move {
205                                            let _busy = activity.busy();
206                                            handle_pypi_request_with_llm_actions(
207                                                req,
208                                                connection_id,
209                                                server_id,
210                                                llm_clone,
211                                                state_clone,
212                                                status_clone,
213                                                protocol_clone,
214                                            )
215                                            .await
216                                        }
217                                    });
218
219                                    // Serve HTTP/1 on this connection
220                                    let conn = http1::Builder::new().serve_connection(io, service);
221                                    tokio::pin!(conn);
222                                    tokio::select! {
223                                        result = &mut conn => {
224                                            if let Err(err) = result {
225                                                Log::new(Some(&status_tx_clone))
226                                                    .error(format!("Error serving PyPI connection: {:?}", err));
227                                            }
228                                        }
229                                        // The idle bound, over `ConnectionActivity`
230                                        // rather than over a read: hyper owns every
231                                        // read once `serve_connection` starts, and
232                                        // keeps polling for frames while a request is
233                                        // being answered, so a deadline on those reads
234                                        // would fire mid-answer. A connection with work
235                                        // in flight is not idle at all.
236                                        _ = watch_idle(
237                                            Arc::clone(&activity),
238                                            IDLE_BETWEEN_REQUESTS_TIMEOUT,
239                                        ) => {
240                                            Log::new(Some(&status_tx_clone)).debug(format!(
241                                                "PyPI connection {} idle for {}s; closing",
242                                                connection_id,
243                                                IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
244                                            ));
245                                        }
246                                    }
247                                } else {
248                                    Log::new(Some(&status_tx_clone)).debug(format!(
249                                        "PyPI peer {} sent no request within {}s; closing \
250                                         decision=fail_closed_first_byte_timeout",
251                                        remote_addr,
252                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
253                                    ));
254                                }
255
256                                // Mark connection as closed
257                                app_state_clone
258                                    .close_connection_on_server(server_id, connection_id)
259                                    .await;
260                                Log::new(Some(&status_tx_clone))
261                                    .info(format!("PyPI connection {connection_id} closed"));
262                                let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
263                            })
264                            .await;
265                    }
266                    Err(e) => {
267                        Log::new(Some(&status_tx))
268                            .error(format!("Failed to accept PyPI connection: {}", e));
269                        break;
270                    }
271                }
272            }
273        });
274
275        // Register the accept loop so stop_server can abort it and release the port.
276        task_registrar
277            .register_server_task(server_id, accept_handle)
278            .await;
279
280        Ok(local_addr)
281    }
282}
283
284/// Build the 502 sent when model or handler output cannot be turned into a
285/// valid HTTP response (bad status code, malformed header name, no answer at all).
286///
287/// The peer gets a category, never a diagnosis: what exactly was wrong with the action
288/// output is netget's own business and goes to the log only.
289/// See `crate::utils::wire_failure`.
290fn bad_gateway() -> Response<Full<Bytes>> {
291    Response::builder()
292        .status(502)
293        .header("Content-Type", "text/plain; charset=utf-8")
294        .body(Full::new(Bytes::from(
295            crate::utils::WireFailure::Unavailable.prefixed_text(),
296        )))
297        .expect("502 response with a literal body is always valid")
298}
299
300/// The 413 for a request body over [`MAX_REQUEST_BODY_BYTES`].
301///
302/// Static text: the peer learns the fact and the limit, never why the read failed.
303fn payload_too_large() -> Response<Full<Bytes>> {
304    Response::builder()
305        .status(413)
306        .header("Content-Type", "text/plain; charset=utf-8")
307        .body(Full::new(Bytes::from(format!(
308            "Payload Too Large: request bodies are limited to {} bytes\n",
309            crate::server::http_common::handler::MAX_REQUEST_BODY_BYTES
310        ))))
311        .expect("413 response with a literal body is always valid")
312}
313
314/// Build the reply for an LLM/backend failure.
315///
316/// The two categories get distinct HTTP codes on purpose: pip retries a 503 with
317/// `Retry-After` and records a 500 as a hard failure, so collapsing them would make a
318/// transient overload look permanent. The body is a static category string — the error
319/// itself is logged, never written to the socket.
320fn llm_failure_response(failure: crate::utils::WireFailure) -> Response<Full<Bytes>> {
321    let mut builder = Response::builder()
322        .header("Content-Type", "text/plain; charset=utf-8")
323        .status(if failure.is_overloaded() { 503 } else { 500 });
324    if failure.is_overloaded() {
325        builder = builder.header("Retry-After", "5");
326    }
327    builder
328        .body(Full::new(Bytes::from(failure.prefixed_text())))
329        .expect("failure response with a literal body is always valid")
330}
331
332/// Approximate the bytes this request cost on the wire.
333///
334/// hyper hands us a parsed `Request`, so the original head is gone; this
335/// reconstructs its size from the parts that survive. It is an estimate, and
336/// deliberately so — the alternative is a `↓` counter frozen at 0, which reads as
337/// "this peer sent nothing" rather than "we did not measure".
338fn approximate_request_bytes(req: &Request<Incoming>) -> u64 {
339    use hyper::body::Body;
340    let head: usize = req.method().as_str().len()
341        + req.uri().to_string().len()
342        + 12 // " HTTP/1.1\r\n" plus the blank line terminating the head
343        + req
344            .headers()
345            .iter()
346            .map(|(name, value)| name.as_str().len() + value.len() + 4)
347            .sum::<usize>();
348    head as u64 + req.body().size_hint().lower()
349}
350
351/// Record one request/response exchange against the connection's counters, then
352/// return the response unchanged.
353///
354/// `update_connection_stats` is what the dashboard rail's `↓ ↑` columns and the
355/// connection-scoped task prompts read, and it is what keeps `last_activity`
356/// moving. Without it a busy PyPI server draws every peer as idle having sent
357/// and received nothing.
358#[allow(clippy::too_many_arguments)]
359async fn handle_pypi_request_with_llm_actions(
360    req: Request<Incoming>,
361    connection_id: ConnectionId,
362    server_id: crate::state::ServerId,
363    llm_client: OllamaClient,
364    app_state: Arc<AppState>,
365    status_tx: mpsc::UnboundedSender<String>,
366    protocol: Arc<PypiProtocol>,
367) -> Result<Response<Full<Bytes>>, Infallible> {
368    let bytes_received = approximate_request_bytes(&req);
369    let response = handle_pypi_request_with_llm_actions_inner(
370        req,
371        connection_id,
372        server_id,
373        llm_client,
374        app_state.clone(),
375        status_tx,
376        protocol,
377    )
378    .await;
379
380    let bytes_sent = response
381        .as_ref()
382        .ok()
383        .and_then(|resp| {
384            use hyper::body::Body;
385            resp.body().size_hint().exact()
386        })
387        .unwrap_or(0);
388    app_state
389        .update_connection_stats(
390            server_id,
391            connection_id,
392            Some(bytes_received),
393            Some(bytes_sent),
394            Some(1),
395            Some(1),
396        )
397        .await;
398
399    response
400}
401
402/// Handle a single PyPI request with integrated LLM actions
403async fn handle_pypi_request_with_llm_actions_inner(
404    req: Request<Incoming>,
405    connection_id: ConnectionId,
406    server_id: crate::state::ServerId,
407    llm_client: OllamaClient,
408    app_state: Arc<AppState>,
409    status_tx: mpsc::UnboundedSender<String>,
410    protocol: Arc<PypiProtocol>,
411) -> Result<Response<Full<Bytes>>, Infallible> {
412    // TRACE: Handler invoked (FileOnly)
413    Log::new(Some(&status_tx)).trace(format!(
414        "🔍 PyPI handler called for connection {}",
415        connection_id
416    ));
417
418    // Extract request details first for logging
419    let method = req.method().to_string();
420    let uri = req.uri().to_string();
421    let path = req.uri().path().to_string();
422
423    // Extract headers
424    let mut headers = HashMap::new();
425    for (name, value) in req.headers() {
426        if let Ok(value_str) = value.to_str() {
427            headers.insert(name.to_string(), value_str.to_string());
428        }
429    }
430
431    // Read the body, bounded. `Incoming` has no default limit, and this body is
432    // buffered whole *and* interpolated into the LLM prompt below, so an
433    // unauthenticated POST of arbitrary length would allocate twice over — the
434    // unbounded-upload shape. `Limited` errors as soon as the cap is passed rather
435    // than after buffering, so an oversized request costs at most the cap.
436    let body_bytes = match http_body_util::Limited::new(
437        req.into_body(),
438        crate::server::http_common::handler::MAX_REQUEST_BODY_BYTES,
439    )
440    .collect()
441    .await
442    {
443        Ok(collected) => collected.to_bytes(),
444        Err(e) => {
445            // Do NOT fall through to an empty body: a truncated request handed to the
446            // model looks like a complete one, and the model would answer a request it
447            // never saw. 413 is the honest reply, and it costs no LLM call.
448            Log::new(Some(&status_tx)).warn(format!(
449                "PyPI {} {} decision=refused_body_too_large (limit {} bytes) \u{2192} 413: {}",
450                method,
451                uri,
452                crate::server::http_common::handler::MAX_REQUEST_BODY_BYTES,
453                e
454            ));
455            return Ok(payload_too_large());
456        }
457    };
458
459    // Determine request type based on path
460    // PEP 503 project URLs end with '/', but pip and poetry both follow
461    // redirects, so a client may also ask for /simple/<name> without one.
462    let request_type = if path == "/" || path == "/simple" || path == "/simple/" {
463        "list_packages"
464    } else if path.starts_with("/simple/") {
465        "list_files"
466    } else if path.starts_with("/packages/") {
467        "download_file"
468    } else {
469        "unknown"
470    };
471
472    // Extract package name if applicable
473    let package_name = if request_type == "list_files" {
474        path.trim_start_matches("/simple/")
475            .trim_end_matches('/')
476            .to_string()
477    } else if request_type == "download_file" {
478        path.trim_start_matches("/packages/")
479            .split('/')
480            .last()
481            .unwrap_or("")
482            .to_string()
483    } else {
484        String::new()
485    };
486
487    // DEBUG: Log request summary (FileOnly)
488    Log::new(Some(&status_tx)).debug(format!(
489        "PyPI request: {} {} [{}] ({} bytes) from {:?}",
490        method,
491        uri,
492        request_type,
493        body_bytes.len(),
494        connection_id
495    ));
496
497    // TRACE: Log full request details
498    trace!("PyPI request headers:");
499    for (name, value) in &headers {
500        trace!("  {}: {}", name, value);
501    }
502
503    // Create PyPI request event (FileOnly)
504    Log::new(Some(&status_tx)).trace(format!(
505        "🔍 Creating PyPI event: path={}, request_type={}",
506        path, request_type
507    ));
508
509    let body_text = String::from_utf8_lossy(&body_bytes);
510    let event = Event::new(
511        &PYPI_REQUEST_EVENT,
512        serde_json::json!({
513            "method": method,
514            "uri": uri,
515            "path": path,
516            "headers": headers,
517            "body": body_text,
518            "request_type": request_type,
519            "package_name": package_name,
520        }),
521    );
522
523    Log::new(Some(&status_tx)).trace("🔍 Calling LLM for PyPI request");
524
525    // Call LLM to generate PyPI response
526    match call_llm(
527        &llm_client,
528        &app_state,
529        server_id,
530        Some(connection_id),
531        &event,
532        protocol.as_ref(),
533    )
534    .await
535    {
536        Ok(execution_result) => {
537            Log::new(Some(&status_tx)).debug(format!(
538                "LLM PyPI response received, {} protocol results",
539                execution_result.protocol_results.len()
540            ));
541
542            // Display messages
543            for msg in execution_result.messages {
544                let _ = status_tx.send(msg);
545            }
546
547            // Extract the PyPI response from the protocol results.
548            //
549            // There is deliberately no default here. This used to pre-set
550            // `status_code = 200` with an empty body, so a model that answered with no
551            // action at all — or a handler configured with `actions: []` — produced a
552            // `200 OK` with zero bytes. To pip that reads as "the project exists and has
553            // no distributions", i.e. silence became a successful answer. A no-answer is
554            // a failure and must look like one on the wire.
555            let mut status_code: Option<u16> = None;
556            let mut response_headers = HashMap::new();
557            let mut response_body: Vec<u8> = Vec::new();
558            let mut body_set = false;
559            let mut decode_failed = false;
560
561            for protocol_result in execution_result.protocol_results {
562                if let ActionResult::Output(output_data) = protocol_result {
563                    // Parse the output as JSON containing PyPI response fields
564                    if let Ok(json_value) =
565                        serde_json::from_slice::<serde_json::Value>(&output_data)
566                    {
567                        if let Some(status) = json_value.get("status").and_then(|v| v.as_u64()) {
568                            if (100..=599).contains(&status) {
569                                status_code = Some(status as u16);
570                            } else {
571                                Log::new(Some(&status_tx)).error(format!(
572                                    "PyPI response status {} is not a valid HTTP status code",
573                                    status
574                                ));
575                                decode_failed = true;
576                            }
577                        }
578                        if let Some(headers_obj) =
579                            json_value.get("headers").and_then(|v| v.as_object())
580                        {
581                            for (k, v) in headers_obj {
582                                if let Some(v_str) = v.as_str() {
583                                    response_headers.insert(k.clone(), v_str.to_string());
584                                }
585                            }
586                        }
587                        // body_base64 is only ever set by the action executor,
588                        // which has already validated the encoding.
589                        if let Some(encoded) =
590                            json_value.get("body_base64").and_then(|v| v.as_str())
591                        {
592                            use base64::Engine;
593                            match base64::engine::general_purpose::STANDARD.decode(encoded) {
594                                Ok(decoded) => {
595                                    response_body = decoded;
596                                    body_set = true;
597                                }
598                                Err(e) => {
599                                    // Serving 0 bytes here would hand pip a truncated
600                                    // distribution that fails its hash check much later,
601                                    // far from the cause. Refuse instead.
602                                    Log::new(Some(&status_tx)).error(format!(
603                                        "PyPI response body_base64 is not valid base64: {}",
604                                        e
605                                    ));
606                                    decode_failed = true;
607                                }
608                            }
609                        } else if let Some(body) = json_value.get("body").and_then(|v| v.as_str()) {
610                            response_body = body.as_bytes().to_vec();
611                            body_set = true;
612                        }
613                    }
614                }
615            }
616
617            if decode_failed {
618                // The model/handler answered, but the answer cannot be put on the wire.
619                Log::new(Some(&status_tx)).error(format!(
620                    "PyPI {} {} decision=fail_closed_bad_action → 502",
621                    method, uri
622                ));
623                return Ok(bad_gateway());
624            }
625
626            // `send_pypi_response` always yields a status plus exactly one of
627            // `body`/`body_base64` (see `execute_send_pypi_response`), so both being
628            // present is precisely "a PyPI response was produced".
629            let Some(status_code) = status_code.filter(|_| body_set) else {
630                // Distinct from an LLM error (below) and from a model that deliberately
631                // answered 404: nothing usable came back at all.
632                Log::new(Some(&status_tx)).warn(format!(
633                    "PyPI {} {} decision=fail_closed_no_action \
634                     (no send_pypi_response in the answer) → 502",
635                    method, uri
636                ));
637                return Ok(bad_gateway());
638            };
639
640            Log::new(Some(&status_tx)).info(format!(
641                "PyPI {} {} decision=model_response → {} ({} bytes)",
642                method,
643                uri,
644                status_code,
645                response_body.len()
646            ));
647
648            // Build the HTTP response. Status and header names come from model or
649            // handler output, so an invalid one must not take the connection down.
650            let mut response = Response::builder().status(status_code);
651
652            // Add headers
653            for (name, value) in response_headers {
654                response = response.header(name, value);
655            }
656
657            match response.body(Full::new(Bytes::from(response_body))) {
658                Ok(resp) => Ok(resp),
659                Err(e) => {
660                    Log::new(Some(&status_tx)).error(format!(
661                        "PyPI {} {} decision=fail_closed_bad_action: invalid response ({}) → 502",
662                        method, uri, e
663                    ));
664                    Ok(bad_gateway())
665                }
666            }
667        }
668        Err(e) => {
669            // The backend failed. The peer gets a category and a code it can act on; the
670            // error itself — backend URL, model name, anyhow chain — goes to the log only.
671            let failure = crate::utils::WireFailure::classify(&e);
672            let code = if failure.is_overloaded() { 503 } else { 500 };
673            Log::new(Some(&status_tx)).warn(format!(
674                "PyPI {} {} decision=fail_closed_llm_error → {} ({:?}): {}",
675                method, uri, code, failure, e
676            ));
677            Ok(llm_failure_response(failure))
678        }
679    }
680}