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}