Skip to main content

netget/server/maven/
mod.rs

1//! Maven repository server implementation using hyper
2pub mod actions;
3
4use std::collections::HashMap;
5use std::convert::Infallible;
6use std::net::SocketAddr;
7use std::sync::Arc;
8
9use bytes::Bytes;
10use http_body_util::{BodyExt, Full};
11use hyper::body::Incoming;
12use hyper::server::conn::http1;
13use hyper::service::service_fn;
14use hyper::{Request, Response};
15use hyper_util::rt::TokioIo;
16use tokio::sync::mpsc;
17
18use crate::llm::action_helper::call_llm;
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ActionResult;
21use crate::logging::emit::Log;
22use crate::protocol::Event;
23use crate::server::accept_bounded::{
24    accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
25};
26use crate::server::connection::ConnectionId;
27use crate::server::MavenProtocol;
28use crate::state::app_state::AppState;
29use actions::MAVEN_ARTIFACT_REQUEST_EVENT;
30
31/// How long a peer that has produced nothing at all may hold this connection.
32///
33/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
34/// repository client sends it inside its dial path, so a peer that has connected and sent no byte
35/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
36/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
37///
38/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
39/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
40/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
41/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
42/// without consuming it, so the request line is still there for hyper afterwards.
43const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
44
45/// How long an established connection may sit **silent** between requests.
46///
47/// This bounds the silence and never a transfer. A request being answered holds
48/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
49/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
50/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
51/// ever runs on a connection with nothing in flight.
52///
53/// Five minutes. Maven interleaves downloads with the build itself, so a connection its resolver
54/// pool is holding can be legitimately unused while javac runs; five minutes is four times
55/// nginx's `keepalive_timeout` default of 75s and generous against that. It costs nothing when
56/// it is wrong: Apache HttpClient, which Maven's transport is built on, revalidates a pooled
57/// connection and reopens one the server has closed, which is the behaviour RFC 9112 requires of
58/// every client. An artifact download in flight is not silence and is not measured here.
59const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
60
61/// Concurrent connections this server admits.
62///
63/// Each admitted connection may hold one whole in-memory response body - a POM, a checksum or a
64/// jar - so the cap is what turns that per-connection bound into a total one. Maven resolves
65/// with a handful of connections per repository even under `-T`, so 256 is far above any real
66/// build and far below what socket exhaustion needs.
67const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
68
69/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
70///
71/// `503 Service Unavailable` with `Retry-After`, in Maven's own vocabulary:
72/// Maven's transport retries a 503 against the same repository
73/// rather than failing the build outright, and a human with `curl` reads it directly.
74/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
75/// reason, under `decision=fail_closed_connection_cap`.
76const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
77    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
78
79/// Maven repository server that delegates artifact requests to LLM
80pub struct MavenServer;
81
82impl MavenServer {
83    /// Spawn the Maven repository server with integrated LLM actions
84    pub async fn spawn_with_llm_actions(
85        listen_addr: SocketAddr,
86        llm_client: OllamaClient,
87        app_state: Arc<AppState>,
88        status_tx: mpsc::UnboundedSender<String>,
89        server_id: crate::state::ServerId,
90    ) -> anyhow::Result<SocketAddr> {
91        let listener =
92            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
93        let local_addr = listener.local_addr()?;
94        Log::new(Some(&status_tx)).info(format!(
95            "Maven repository server listening on {}",
96            local_addr
97        ));
98
99        let protocol = Arc::new(MavenProtocol::new());
100
101        // Spawn server loop
102        let task_registrar = app_state.clone();
103        let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
104        let accept_handle = tokio::spawn(async move {
105            loop {
106                match accept_bounded(
107                    &listener,
108                    &limiter,
109                    CONNECTION_CAP_REFUSAL,
110                    "MAVEN",
111                    Some(&status_tx),
112                )
113                .await
114                {
115                    Ok((stream, remote_addr, permit)) => {
116                        let connection_id =
117                            ConnectionId::new(app_state.get_next_unified_id().await);
118                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
119                        Log::new(Some(&status_tx)).info(format!(
120                            "Maven connection {} from {}",
121                            connection_id, remote_addr
122                        ));
123
124                        // Add connection to ServerInstance
125                        use crate::state::server::{
126                            ConnectionState as ServerConnectionState, ConnectionStatus,
127                            ProtocolConnectionInfo,
128                        };
129                        let now = crate::utils::clock::Instant::now();
130                        let conn_state = ServerConnectionState {
131                            id: connection_id,
132                            remote_addr,
133                            local_addr: local_addr_conn,
134                            bytes_sent: 0,
135                            bytes_received: 0,
136                            packets_sent: 0,
137                            packets_received: 0,
138                            last_activity: now,
139                            status: ConnectionStatus::Active,
140                            status_changed_at: now,
141                            protocol_info: ProtocolConnectionInfo::empty(),
142                        };
143                        app_state
144                            .add_connection_to_server(server_id, conn_state)
145                            .await;
146                        let _ = status_tx.send("__UPDATE_UI__".to_string());
147
148                        let llm_client_clone = llm_client.clone();
149                        let app_state_clone = app_state.clone();
150                        let status_tx_clone = status_tx.clone();
151                        let protocol_clone = protocol.clone();
152
153                        // Spawn a task to handle this connection
154                        // Tracked, not detached: stop_server must abort this task too.
155                        let task_owner = app_state.clone();
156                        task_owner
157                            .spawn_server_task(server_id, async move {
158                                // Held for the life of the connection, so the
159                                // cap counts live clients, not accepts.
160                                let _permit = permit;
161
162                                // The first-byte bound, before hyper sees the
163                                // socket. `peek` waits for data without
164                                // consuming it, so the request line is still
165                                // there for hyper afterwards, and it bounds
166                                // exactly the case that needs bounding: a peer
167                                // that connected and said nothing at all.
168                                let spoke = matches!(
169                                    tokio::time::timeout(
170                                        FIRST_BYTE_READ_TIMEOUT,
171                                        stream.peek(&mut [0u8; 1]),
172                                    )
173                                    .await,
174                                    Ok(Ok(n)) if n > 0
175                                );
176
177                                if spoke {
178                                    let io = TokioIo::new(stream);
179
180                                    // Clone for service closure
181                                    let status_for_service = status_tx_clone.clone();
182                                    let app_state_for_service = app_state_clone.clone();
183
184                                    // Whether this connection is answering
185                                    // anything. The watchdog below reads it, so
186                                    // only genuine silence - never work in
187                                    // flight - can close a connection.
188                                    let activity =
189                                        Arc::new(ConnectionActivity::new());
190                                    let activity_for_service = Arc::clone(&activity);
191                                    // Create a service that handles Maven requests with LLM
192                                    let service = service_fn(move |req: Request<Incoming>| {
193                                        let llm_clone = llm_client_clone.clone();
194                                        let state_clone = app_state_for_service.clone();
195                                        let status_clone = status_for_service.clone();
196                                        let protocol_clone = protocol_clone.clone();
197                                        // Busy for the whole of this request - the
198                                        // model round-trip included, and a `manual`
199                                        // rule parked for a human - so the idle
200                                        // watchdog can never close the connection an
201                                        // answer belongs to.
202                                        let activity = Arc::clone(&activity_for_service);
203                                        async move {
204                                            let _busy = activity.busy();
205                                            handle_maven_request_with_llm(
206                                                req,
207                                                connection_id,
208                                                server_id,
209                                                llm_clone,
210                                                state_clone,
211                                                status_clone,
212                                                protocol_clone,
213                                            )
214                                            .await
215                                        }
216                                    });
217
218                                    // Serve HTTP/1 on this connection
219                                    let conn = http1::Builder::new().serve_connection(io, service);
220                                    tokio::pin!(conn);
221                                    tokio::select! {
222                                        result = &mut conn => {
223                                            if let Err(err) = result {
224                                                // Non-fatal: this connection ends; the server continues.
225                                                Log::new(Some(&status_tx_clone))
226                                                    .warn(format!("Error serving Maven 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                                                "Maven 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                                        "Maven 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!("Maven connection {} closed", connection_id));
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 Maven connection: {}", e));
269                        break;
270                    }
271                }
272            }
273        });
274
275        task_registrar
276            .register_server_task(server_id, accept_handle)
277            .await;
278
279        Ok(local_addr)
280    }
281}
282
283/// How much of a request body is read before the request is refused with 413.
284///
285/// Deliberately a local constant rather than a reference to
286/// `http_common::handler::MAX_REQUEST_BODY_BYTES`, which carries the same value:
287/// `http_common` is gated on `#[cfg(any(feature = "http", ...))]` and `maven = []`
288/// pulls none of those, so borrowing it broke `cargo check --no-default-features
289/// --features maven` — exactly what CI's `single-feature` job exists to catch.
290/// A per-protocol constant is also what the decentralisation rule asks for.
291///
292/// `Incoming` has no default limit, so without a cap the buffer is whatever the peer
293/// chooses to send.
294pub const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;
295
296/// Parse Maven artifact path into components
297///
298/// Maven paths follow the pattern:
299/// /{groupId}/{artifactId}/{version}/{artifactId}-{version}[-{classifier}].{extension}
300///
301/// Examples:
302/// - /com/example/mylib/1.0.0/mylib-1.0.0.jar
303/// - /com/example/mylib/1.0.0/mylib-1.0.0.pom
304/// - /com/example/mylib/1.0.0/mylib-1.0.0-sources.jar
305/// - /com/example/mylib/maven-metadata.xml
306#[derive(Debug)]
307struct MavenArtifact {
308    group_id: String,
309    artifact_id: String,
310    version: Option<String>,
311    classifier: Option<String>,
312    extension: String,
313    is_metadata: bool,
314    is_checksum: bool,
315    checksum_type: Option<String>, // sha1, md5, sha256, sha512
316}
317
318impl MavenArtifact {
319    fn parse(uri: &str) -> Option<Self> {
320        let path = uri.trim_start_matches('/');
321        let parts: Vec<&str> = path.split('/').collect();
322
323        if parts.is_empty() {
324            return None;
325        }
326
327        // Check if this is a maven-metadata.xml request
328        if parts.last() == Some(&"maven-metadata.xml")
329            || parts.last() == Some(&"maven-metadata.xml.sha1")
330            || parts.last() == Some(&"maven-metadata.xml.md5")
331        {
332            let is_checksum = parts.last()?.contains(".sha1") || parts.last()?.contains(".md5");
333            let checksum_type = if parts.last()?.ends_with(".sha1") {
334                Some("sha1".to_string())
335            } else if parts.last()?.ends_with(".md5") {
336                Some("md5".to_string())
337            } else {
338                None
339            };
340
341            // Group ID is everything except last 2 elements (artifact_id and filename)
342            if parts.len() < 2 {
343                return None;
344            }
345            let group_id = parts[..parts.len() - 2].join(".");
346            let artifact_id = parts[parts.len() - 2].to_string();
347
348            return Some(Self {
349                group_id,
350                artifact_id,
351                version: None,
352                classifier: None,
353                extension: "xml".to_string(),
354                is_metadata: true,
355                is_checksum,
356                checksum_type,
357            });
358        }
359
360        // Regular artifact request - need at least 4 parts: group/artifact/version/file
361        if parts.len() < 4 {
362            return None;
363        }
364
365        let filename = parts.last()?;
366        let version = parts[parts.len() - 2];
367        let artifact_id = parts[parts.len() - 3];
368        let group_parts = &parts[..parts.len() - 3];
369        let group_id = group_parts.join(".");
370
371        // Parse filename: {artifactId}-{version}[-{classifier}].{extension}[.checksum]
372        // First check for checksums
373        let (filename, is_checksum, checksum_type) = if filename.ends_with(".sha1") {
374            (
375                filename.trim_end_matches(".sha1"),
376                true,
377                Some("sha1".to_string()),
378            )
379        } else if filename.ends_with(".md5") {
380            (
381                filename.trim_end_matches(".md5"),
382                true,
383                Some("md5".to_string()),
384            )
385        } else if filename.ends_with(".sha256") {
386            (
387                filename.trim_end_matches(".sha256"),
388                true,
389                Some("sha256".to_string()),
390            )
391        } else if filename.ends_with(".sha512") {
392            (
393                filename.trim_end_matches(".sha512"),
394                true,
395                Some("sha512".to_string()),
396            )
397        } else {
398            (*filename, false, None)
399        };
400
401        // Get extension
402        let extension = filename.split('.').last()?.to_string();
403
404        // Parse base filename (without extension)
405        let base = filename.trim_end_matches(&format!(".{}", extension));
406
407        // Expected format: {artifactId}-{version}[-{classifier}]
408        let expected_prefix = format!("{}-{}", artifact_id, version);
409
410        if !base.starts_with(&expected_prefix) {
411            return None;
412        }
413
414        // Extract classifier if present
415        let classifier = if base.len() > expected_prefix.len() {
416            let remainder = &base[expected_prefix.len()..];
417            if remainder.starts_with('-') {
418                Some(remainder[1..].to_string())
419            } else {
420                return None; // Invalid format
421            }
422        } else {
423            None
424        };
425
426        Some(Self {
427            group_id,
428            artifact_id: artifact_id.to_string(),
429            version: Some(version.to_string()),
430            classifier,
431            extension,
432            is_metadata: false,
433            is_checksum,
434            checksum_type,
435        })
436    }
437}
438
439/// Build the 502 sent when model or handler output cannot be turned into a
440/// valid HTTP response (bad status code, malformed header name, ...).
441fn bad_gateway() -> Response<Full<Bytes>> {
442    Response::builder()
443        .status(502)
444        .body(Full::new(Bytes::from(
445            "Bad Gateway: invalid Maven response",
446        )))
447        .expect("502 response with a literal body is always valid")
448}
449
450/// The 413 for a request body over `MAX_REQUEST_BODY_BYTES`.
451///
452/// Static text: the peer learns the fact and the limit, never why the read failed.
453fn payload_too_large() -> Response<Full<Bytes>> {
454    Response::builder()
455        .status(413)
456        .header("Content-Type", "text/plain; charset=utf-8")
457        .body(Full::new(Bytes::from(format!(
458            "Payload Too Large: request bodies are limited to {} bytes\n",
459            MAX_REQUEST_BODY_BYTES
460        ))))
461        .expect("413 response with a literal body is always valid")
462}
463
464/// Approximate the bytes this request cost on the wire.
465///
466/// hyper hands us a parsed `Request`, so the original head is gone; this
467/// reconstructs its size from the parts that survive. It is an estimate, and
468/// deliberately so — the alternative is a `↓` counter frozen at 0, which reads as
469/// "this peer sent nothing" rather than "we did not measure".
470fn approximate_request_bytes(req: &Request<Incoming>) -> u64 {
471    use hyper::body::Body;
472    let head: usize = req.method().as_str().len()
473        + req.uri().to_string().len()
474        + 12 // " HTTP/1.1\r\n" plus the blank line terminating the head
475        + req
476            .headers()
477            .iter()
478            .map(|(name, value)| name.as_str().len() + value.len() + 4)
479            .sum::<usize>();
480    head as u64 + req.body().size_hint().lower()
481}
482
483/// Record one request/response exchange against the connection's counters, then
484/// return the response unchanged.
485///
486/// `update_connection_stats` is what the dashboard rail's `↓ ↑` columns and the
487/// connection-scoped task prompts read, and it is what keeps `last_activity`
488/// moving. Without it a busy Maven server draws every peer as idle having sent
489/// and received nothing.
490#[allow(clippy::too_many_arguments)]
491async fn handle_maven_request_with_llm(
492    req: Request<Incoming>,
493    connection_id: ConnectionId,
494    server_id: crate::state::ServerId,
495    llm_client: OllamaClient,
496    app_state: Arc<AppState>,
497    status_tx: mpsc::UnboundedSender<String>,
498    protocol: Arc<MavenProtocol>,
499) -> Result<Response<Full<Bytes>>, Infallible> {
500    let bytes_received = approximate_request_bytes(&req);
501    let response = handle_maven_request_with_llm_inner(
502        req,
503        connection_id,
504        server_id,
505        llm_client,
506        app_state.clone(),
507        status_tx,
508        protocol,
509    )
510    .await;
511
512    let bytes_sent = response
513        .as_ref()
514        .ok()
515        .and_then(|resp| {
516            use hyper::body::Body;
517            resp.body().size_hint().exact()
518        })
519        .unwrap_or(0);
520    app_state
521        .update_connection_stats(
522            server_id,
523            connection_id,
524            Some(bytes_received),
525            Some(bytes_sent),
526            Some(1),
527            Some(1),
528        )
529        .await;
530
531    response
532}
533
534/// Handle a single Maven artifact request with integrated LLM actions
535async fn handle_maven_request_with_llm_inner(
536    req: Request<Incoming>,
537    connection_id: ConnectionId,
538    server_id: crate::state::ServerId,
539    llm_client: OllamaClient,
540    app_state: Arc<AppState>,
541    status_tx: mpsc::UnboundedSender<String>,
542    protocol: Arc<MavenProtocol>,
543) -> Result<Response<Full<Bytes>>, Infallible> {
544    let method = req.method().to_string();
545    let uri = req.uri().to_string();
546    let log = Log::new(Some(&status_tx));
547
548    // Extract headers
549    let mut headers = HashMap::new();
550    for (name, value) in req.headers() {
551        if let Ok(value_str) = value.to_str() {
552            headers.insert(name.to_string(), value_str.to_string());
553        }
554    }
555
556    // Drain the body, bounded. Maven GETs carry no body, but `Incoming` has no
557    // default limit and the body must still be consumed for keep-alive, so an
558    // unauthenticated peer could otherwise make this server buffer an arbitrary
559    // number of bytes it then throws away. `Limited` errors as soon as the cap is
560    // passed rather than after buffering the whole thing.
561    if let Err(e) = http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BODY_BYTES)
562        .collect()
563        .await
564    {
565        log.warn(format!(
566            "Maven {} {} decision=refused_body_too_large (limit {} bytes) -> 413: {}",
567            method, uri, MAX_REQUEST_BODY_BYTES, e
568        ));
569        return Ok(payload_too_large());
570    }
571
572    // Summary + full payload FileOnly: the maven_artifact_request event template
573    // renders the equivalent line to the TUI.
574    log.debug(format!(
575        "Maven request: {} {} from {:?}",
576        method, uri, connection_id
577    ));
578
579    // Parse Maven artifact from URI
580    let artifact = MavenArtifact::parse(&uri);
581
582    if let Some(ref art) = artifact {
583        log.trace(format!("Parsed Maven artifact: {:?}", art));
584    }
585
586    // Create Maven artifact request event
587    let event_params = if let Some(art) = artifact {
588        serde_json::json!({
589            "method": method,
590            "uri": uri,
591            "group_id": art.group_id,
592            "artifact_id": art.artifact_id,
593            "version": art.version,
594            "classifier": art.classifier,
595            "extension": art.extension,
596            "is_metadata": art.is_metadata,
597            "is_checksum": art.is_checksum,
598            "checksum_type": art.checksum_type,
599            "headers": headers,
600        })
601    } else {
602        // Invalid Maven path format
603        log.debug(format!("Invalid Maven artifact path: {}", uri));
604
605        return Ok(Response::builder()
606            .status(404)
607            .body(Full::new(Bytes::from("Invalid Maven artifact path")))
608            .unwrap());
609    };
610
611    let event = Event::new(&MAVEN_ARTIFACT_REQUEST_EVENT, event_params);
612
613    // Call LLM to handle Maven request
614    match call_llm(
615        &llm_client,
616        &app_state,
617        server_id,
618        Some(connection_id),
619        &event,
620        protocol.as_ref(),
621    )
622    .await
623    {
624        Ok(execution_result) => {
625            log.debug("LLM Maven response received");
626
627            // Display messages
628            for msg in execution_result.messages {
629                let _ = status_tx.send(msg);
630            }
631
632            // Extract Maven response from protocol results
633            // Default to 404 Not Found
634            let mut status_code = 404;
635            let mut response_headers = HashMap::new();
636            let mut response_body = Vec::new();
637            let mut produced_response = false;
638
639            for protocol_result in execution_result.protocol_results {
640                if let ActionResult::Output(output_data) = protocol_result {
641                    // Parse the output as JSON containing Maven response fields
642                    if let Ok(json_value) =
643                        serde_json::from_slice::<serde_json::Value>(&output_data)
644                    {
645                        produced_response = true;
646                        if let Some(status) = json_value.get("status").and_then(|v| v.as_u64()) {
647                            status_code = status as u16;
648                        }
649                        if let Some(headers_obj) =
650                            json_value.get("headers").and_then(|v| v.as_object())
651                        {
652                            for (k, v) in headers_obj {
653                                if let Some(v_str) = v.as_str() {
654                                    response_headers.insert(k.clone(), v_str.to_string());
655                                }
656                            }
657                        }
658
659                        // Handle both UTF-8 text and base64-encoded binary content.
660                        // body_base64 is checked first: it is only ever set by the
661                        // action executor, which has already validated the encoding.
662                        if let Some(encoded) =
663                            json_value.get("body_base64").and_then(|v| v.as_str())
664                        {
665                            use base64::Engine;
666                            match base64::engine::general_purpose::STANDARD.decode(encoded) {
667                                Ok(decoded) => response_body = decoded,
668                                Err(e) => {
669                                    // Fail closed: shipping the declared status with an
670                                    // empty body would look like a successful zero-byte
671                                    // artifact download and Maven would cache it.
672                                    tracing::error!(
673                                        "Maven {} {} decision=fail_closed_bad_body error={}",
674                                        method,
675                                        uri,
676                                        e
677                                    );
678                                    log.warn(format!(
679                                        "Maven {} {} decision=fail_closed_bad_body: response \
680                                         body_base64 is not valid base64: {}",
681                                        method, uri, e
682                                    ));
683                                    return Ok(bad_gateway());
684                                }
685                            }
686                        } else if let Some(body_str) =
687                            json_value.get("body").and_then(|v| v.as_str())
688                        {
689                            response_body = body_str.as_bytes().to_vec();
690                        }
691                    }
692                }
693            }
694
695            // Three outcomes stay distinguishable in the log: the model produced a
696            // response (`model_answer`), the model produced nothing usable and the
697            // default 404 stands (`model_no_action`), and the LLM call itself failed
698            // (`fail_closed_llm_*`, in the Err arm below).
699            let decision = if produced_response {
700                "model_answer"
701            } else {
702                "model_no_action"
703            };
704
705            // Response summary FileOnly: the send_maven_* action template already
706            // reports the send to the TUI.
707            log.debug(format!(
708                "Maven {} {} decision={} -> {} ({} bytes)",
709                method,
710                uri,
711                decision,
712                status_code,
713                response_body.len()
714            ));
715
716            // Build the HTTP response. Status and header names come from model or
717            // handler output, so an invalid one must not take the connection down.
718            let mut response = Response::builder().status(status_code);
719
720            // Add headers
721            for (name, value) in response_headers {
722                response = response.header(name, value);
723            }
724
725            match response.body(Full::new(Bytes::from(response_body))) {
726                Ok(resp) => Ok(resp),
727                Err(e) => {
728                    // Non-fatal: invalid model/handler output; the client gets a 502.
729                    log.warn(format!("Invalid Maven response ({}), sending 502", e));
730                    Ok(bad_gateway())
731                }
732            }
733        }
734        Err(e) => {
735            // The peer gets a category, the log gets the error. Nothing derived
736            // from `e` may reach the wire: the bodies below are literals and the
737            // status is chosen from the classification alone.
738            let failure = crate::utils::WireFailure::classify(&e);
739            let decision = if failure.is_overloaded() {
740                "fail_closed_llm_overloaded"
741            } else {
742                "fail_closed_llm_error"
743            };
744            tracing::error!("Maven {} {} decision={} error={}", method, uri, decision, e);
745            log.warn(format!(
746                "Maven {} {} decision={}: {}",
747                method, uri, decision, e
748            ));
749
750            if failure.is_overloaded() {
751                // Transient saturation. 503 + Retry-After tells Maven to back off
752                // and retry rather than record a permanent repository fault.
753                return Ok(Response::builder()
754                    .status(503)
755                    .header(hyper::header::RETRY_AFTER, "1")
756                    .body(Full::new(Bytes::from("Service Unavailable")))
757                    .expect("503 response with a literal body is always valid"));
758            }
759
760            Ok(Response::builder()
761                .status(500)
762                .body(Full::new(Bytes::from("Internal Server Error")))
763                .expect("500 response with a literal body is always valid"))
764        }
765    }
766}