netget/server/sqs/mod.rs
1//! AWS SQS (Simple Queue Service) compatible server implementation
2//!
3//! Implements an SQS-compatible HTTP/JSON API on port 9324.
4//! The LLM controls all queue operations and maintains "virtual" queues through conversation context.
5
6pub mod actions;
7
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http_body_util::{BodyExt, Full, Limited};
14use hyper::body::{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::error;
21
22use crate::llm::ollama_client::OllamaClient;
23use crate::llm::ActionResult;
24use crate::logging::emit::Log;
25use crate::server::accept_bounded::{
26 accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
27};
28use crate::server::connection::ConnectionId;
29use crate::server::SqsProtocol;
30use crate::state::app_state::AppState;
31use crate::{console_error, console_info};
32
33/// Largest request body this server will buffer.
34///
35/// The body was read with an unbounded `req.into_body().collect()`, so a single request
36/// could grow the process without limit. A real SQS message is capped at 256 KiB and a
37/// SendMessageBatch carries ten of them, so 4 MiB is well clear of anything an SDK sends.
38pub const MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
39
40/// How long a peer that has produced nothing at all may hold this connection.
41///
42/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
43/// SQS client sends it inside its dial path, so a peer that has connected and sent no byte
44/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
45/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
46///
47/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
48/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
49/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
50/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
51/// without consuming it, so the request line is still there for hyper afterwards.
52const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
53
54/// How long an established connection may sit **silent** between requests.
55///
56/// This bounds the silence and never a transfer. A request being answered holds
57/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
58/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
59/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
60/// ever runs on a connection with nothing in flight.
61///
62/// Three minutes, and long polling is the reason to say what this does *not* bound:
63/// `ReceiveMessage` with `WaitTimeSeconds` legitimately holds a request open for up to 20
64/// seconds, and that is a request in flight, held busy, outside this clock. What is measured is
65/// a consumer that has stopped asking altogether. Three minutes is nine times SQS's own maximum
66/// long-poll wait and three times the 60-second idle timeout AWS's load balancers ship, so any
67/// SDK reaching it has already been built to reopen the connection.
68const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
69
70/// Concurrent connections this server admits.
71///
72/// Each admitted connection may hold one whole in-memory response body, so the cap is what turns
73/// that per-connection bound into a total one. A queue is drained by a bounded consumer pool
74/// (the AWS SDKs default to 50 connections per endpoint), so 256 is far above any real consumer
75/// set and far below what socket exhaustion needs.
76const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
77
78/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
79///
80/// `503 Service Unavailable` with `Retry-After`, in SQS's own vocabulary:
81/// a 503 with `Retry-After` is what every AWS SDK's throttling
82/// path already understands, and a human with `curl` reads it directly.
83/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
84/// reason, under `decision=fail_closed_connection_cap`.
85const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
86 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
87
88/// SQS server that delegates queue operations to LLM
89pub struct SqsServer;
90
91impl SqsServer {
92 /// Spawn the SQS server with integrated LLM actions
93 pub async fn spawn_with_llm_actions(
94 listen_addr: SocketAddr,
95 llm_client: OllamaClient,
96 app_state: Arc<AppState>,
97 status_tx: mpsc::UnboundedSender<String>,
98 _send_first: bool,
99 server_id: crate::state::ServerId,
100 ) -> anyhow::Result<SocketAddr> {
101 let listener =
102 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
103 let local_addr = listener.local_addr()?;
104 console_info!(status_tx, "SQS server listening on {}", local_addr);
105
106 let protocol = Arc::new(SqsProtocol::new());
107
108 // Spawn server loop
109 let task_registrar = app_state.clone();
110 let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
111 let accept_handle = tokio::spawn(async move {
112 loop {
113 match accept_bounded(
114 &listener,
115 &limiter,
116 CONNECTION_CAP_REFUSAL,
117 "SQS",
118 Some(&status_tx),
119 )
120 .await
121 {
122 Ok((stream, remote_addr, permit)) => {
123 let connection_id =
124 ConnectionId::new(app_state.get_next_unified_id().await);
125 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
126 Log::new(Some(&status_tx)).info(format!(
127 "SQS connection {} from {}",
128 connection_id, remote_addr
129 ));
130
131 // Add connection to ServerInstance
132 use crate::state::server::{
133 ConnectionState as ServerConnectionState, ConnectionStatus,
134 ProtocolConnectionInfo,
135 };
136 let now = crate::utils::clock::Instant::now();
137 let conn_state = ServerConnectionState {
138 id: connection_id,
139 remote_addr,
140 local_addr: local_addr_conn,
141 bytes_sent: 0,
142 bytes_received: 0,
143 packets_sent: 0,
144 packets_received: 0,
145 last_activity: now,
146 status: ConnectionStatus::Active,
147 status_changed_at: now,
148 protocol_info: ProtocolConnectionInfo::empty(),
149 };
150 app_state
151 .add_connection_to_server(server_id, conn_state)
152 .await;
153 let _ = status_tx.send("__UPDATE_UI__".to_string());
154
155 let llm_client_clone = llm_client.clone();
156 let app_state_clone = app_state.clone();
157 let status_tx_clone = status_tx.clone();
158 let protocol_clone = protocol.clone();
159
160 // Spawn a task to handle this connection
161 // Tracked, not detached: stop_server must abort this task too.
162 let task_owner = app_state.clone();
163 task_owner
164 .spawn_server_task(server_id, async move {
165 // Held for the life of the connection, so the
166 // cap counts live clients, not accepts.
167 let _permit = permit;
168
169 // The first-byte bound, before hyper sees the
170 // socket. `peek` waits for data without
171 // consuming it, so the request line is still
172 // there for hyper afterwards, and it bounds
173 // exactly the case that needs bounding: a peer
174 // that connected and said nothing at all.
175 let spoke = matches!(
176 tokio::time::timeout(
177 FIRST_BYTE_READ_TIMEOUT,
178 stream.peek(&mut [0u8; 1]),
179 )
180 .await,
181 Ok(Ok(n)) if n > 0
182 );
183
184 if spoke {
185 let io = TokioIo::new(stream);
186
187 // Clone for service closure
188 let status_for_service = status_tx_clone.clone();
189 let app_state_for_service = app_state_clone.clone();
190
191 // Whether this connection is answering
192 // anything. The watchdog below reads it, so
193 // only genuine silence - never work in
194 // flight - can close a connection.
195 let activity = Arc::new(ConnectionActivity::new());
196 let activity_for_service = Arc::clone(&activity);
197 // Create a service that handles SQS requests with LLM
198 let service = service_fn(move |req: Request<Incoming>| {
199 let llm_clone = llm_client_clone.clone();
200 let state_clone = app_state_for_service.clone();
201 let status_clone = status_for_service.clone();
202 let protocol_clone = protocol_clone.clone();
203 // Busy for the whole of this request - the
204 // model round-trip included, and a `manual`
205 // rule parked for a human - so the idle
206 // watchdog can never close the connection an
207 // answer belongs to.
208 let activity = Arc::clone(&activity_for_service);
209 async move {
210 let _busy = activity.busy();
211 handle_sqs_request_with_llm(
212 req,
213 connection_id,
214 llm_clone,
215 state_clone,
216 status_clone,
217 protocol_clone,
218 server_id,
219 )
220 .await
221 }
222 });
223
224 // Serve HTTP/1 on this connection
225 let conn = http1::Builder::new().serve_connection(io, service);
226 tokio::pin!(conn);
227 tokio::select! {
228 result = &mut conn => {
229 if let Err(err) = result {
230 error!("Error serving SQS connection: {:?}", err);
231 }
232 }
233 // The idle bound, over `ConnectionActivity`
234 // rather than over a read: hyper owns every
235 // read once `serve_connection` starts, and
236 // keeps polling for frames while a request is
237 // being answered, so a deadline on those reads
238 // would fire mid-answer. A connection with work
239 // in flight is not idle at all.
240 _ = watch_idle(
241 Arc::clone(&activity),
242 IDLE_BETWEEN_REQUESTS_TIMEOUT,
243 ) => {
244 Log::new(Some(&status_tx_clone)).debug(format!(
245 "SQS connection {} idle for {}s; closing",
246 connection_id,
247 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
248 ));
249 }
250 }
251 } else {
252 Log::new(Some(&status_tx_clone)).debug(format!(
253 "SQS peer {} sent no request within {}s; closing \
254 decision=fail_closed_first_byte_timeout",
255 remote_addr,
256 FIRST_BYTE_READ_TIMEOUT.as_secs()
257 ));
258 }
259
260 // Mark connection as closed
261 app_state_clone
262 .close_connection_on_server(server_id, connection_id)
263 .await;
264 Log::new(Some(&status_tx_clone))
265 .info(format!("SQS connection {} closed", connection_id));
266 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
267 })
268 .await;
269 }
270 Err(e) => {
271 console_error!(status_tx, "Failed to accept SQS connection: {}", e);
272 break;
273 }
274 }
275 }
276 });
277
278 // Register the accept loop so stop_server can abort it and release the port.
279 // Without this the handle was dropped on the floor: the task kept running and the
280 // socket stayed bound after the server was closed, so restarting on the same port
281 // failed with EADDRINUSE while the UI reported the server as stopped.
282 task_registrar
283 .register_server_task(server_id, accept_handle)
284 .await;
285
286 Ok(local_addr)
287 }
288}
289
290/// Handle a single SQS request with LLM, then record what crossed the wire.
291///
292/// The rail's byte counters and the connection-scoped task prompts read
293/// `bytes_received`/`bytes_sent`, and this server left both at the zero it registered the
294/// connection with, so every SQS peer showed no traffic at all however much it moved.
295async fn handle_sqs_request_with_llm(
296 req: Request<Incoming>,
297 connection_id: ConnectionId,
298 llm_client: OllamaClient,
299 app_state: Arc<AppState>,
300 status_tx: mpsc::UnboundedSender<String>,
301 protocol: Arc<SqsProtocol>,
302 server_id: crate::state::ServerId,
303) -> Result<Response<Full<Bytes>>, Infallible> {
304 let received = req.body().size_hint().lower();
305 let response = handle_sqs_request_inner(
306 req,
307 llm_client,
308 app_state.clone(),
309 status_tx.clone(),
310 protocol,
311 server_id,
312 )
313 .await;
314
315 let sent = response
316 .as_ref()
317 .ok()
318 .and_then(|resp| resp.body().size_hint().exact())
319 .unwrap_or(0);
320 app_state
321 .update_connection_stats(
322 server_id,
323 connection_id,
324 Some(received),
325 Some(sent),
326 Some(1),
327 Some(1),
328 )
329 .await;
330 let _ = status_tx.send("__UPDATE_UI__".to_string());
331
332 response
333}
334
335async fn handle_sqs_request_inner(
336 req: Request<Incoming>,
337 llm_client: OllamaClient,
338 app_state: Arc<AppState>,
339 status_tx: mpsc::UnboundedSender<String>,
340 protocol: Arc<SqsProtocol>,
341 server_id: crate::state::ServerId,
342) -> Result<Response<Full<Bytes>>, Infallible> {
343 // Extract request details
344 let method = req.method().to_string();
345 let uri = req.uri().to_string();
346
347 // Extract SQS operation from x-amz-target header
348 // Format: "AmazonSQS.SendMessage", "AmazonSQS.ReceiveMessage", etc.
349 let operation = req
350 .headers()
351 .get("x-amz-target")
352 .and_then(|v| v.to_str().ok())
353 .and_then(|s| s.split('.').nth(1))
354 .unwrap_or("Unknown")
355 .to_string();
356
357 // Read the JSON body, capped at MAX_REQUEST_BYTES. Refuse rather than continue with an
358 // empty body: an operation whose parameters were truncated away used to reach the model
359 // as a well-formed request with no arguments, and be answered as one.
360 let body_bytes = match Limited::new(req.into_body(), MAX_REQUEST_BYTES)
361 .collect()
362 .await
363 {
364 Ok(collected) => collected.to_bytes(),
365 Err(e) => {
366 Log::new(Some(&status_tx)).warn(format!(
367 "SQS {} decision=fail_closed_body_rejected (limit {} bytes): {}",
368 operation, MAX_REQUEST_BYTES, e
369 ));
370 return Ok(build_sqs_response(
371 413,
372 &new_request_id(),
373 serde_json::json!({
374 "__type": "InvalidParameterValue",
375 "message": "request body rejected",
376 })
377 .to_string(),
378 ));
379 }
380 };
381
382 let body_str = String::from_utf8_lossy(&body_bytes).to_string();
383
384 Log::new(Some(&status_tx)).debug(format!(
385 "SQS request: {} {} operation={} ({} bytes)",
386 method,
387 uri,
388 operation,
389 body_bytes.len()
390 ));
391
392 // Try to extract queue URL from JSON body
393 let queue_url = if !body_str.is_empty() {
394 serde_json::from_str::<serde_json::Value>(&body_str)
395 .ok()
396 .and_then(|v| v.get("QueueUrl").and_then(|q| q.as_str()).map(String::from))
397 } else {
398 None
399 };
400
401 Log::new(Some(&status_tx)).trace(format!("SQS request body: {}", body_str));
402
403 // Create SQS request event
404 let event = crate::protocol::Event::new(
405 &actions::SQS_REQUEST_EVENT,
406 serde_json::json!({
407 "operation": operation,
408 "queue_url": queue_url,
409 "request_body": body_str,
410 }),
411 );
412
413 let llm_result = crate::llm::action_helper::call_llm(
414 &llm_client,
415 &app_state,
416 server_id,
417 None, // Connection ID not needed for stateless HTTP
418 &event,
419 protocol.as_ref(),
420 )
421 .await;
422
423 // Process action results to build HTTP response
424 match llm_result {
425 Ok(execution_result) => {
426 // Look for SQS-specific response actions
427 for result in execution_result.protocol_results {
428 match result {
429 ActionResult::Custom { name, data } => {
430 if name == "sqs_response" {
431 let status =
432 data.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
433 let body = data.get("body").and_then(|v| v.as_str()).unwrap_or("{}");
434
435 // `decision=` is a stable grep target, as in `src/server/radius/`:
436 // an operator has to be able to tell the model answering from
437 // netget failing to reach one.
438 let decision = if (400..=599).contains(&status) {
439 "model_reject"
440 } else {
441 "model_answer"
442 };
443 let log = Log::new(Some(&status_tx));
444 log.debug(format!(
445 "SQS {} decision={} status={}",
446 operation, decision, status
447 ));
448 log.trace(format!("SQS response body: {}", body));
449
450 let request_id = new_request_id();
451
452 return Ok(build_sqs_response(status, &request_id, body.to_string()));
453 }
454 }
455 _ => {
456 // Other actions don't affect HTTP response
457 }
458 }
459 }
460
461 // The handler ran but produced no `sqs_response` — a model that refused, a static
462 // handler with an empty action list, or an answer whose actions were all
463 // unrecognised.
464 //
465 // This used to answer 200 `{}`, at debug level. For SendMessage or DeleteMessage
466 // that is a successful call as far as any AWS SDK is concerned, so a declined send
467 // was reported as delivered and a declined delete as removed; for ReceiveMessage it
468 // reads as "the queue is empty", a claim about the queue nothing supports.
469 //
470 // Fail closed with the same AWS error envelope the backend-error arm below uses.
471 Log::new(Some(&status_tx)).warn(format!(
472 "SQS {} decision=fail_closed_no_action (no sqs_response action produced); \
473 answering 500 rather than an empty 200",
474 operation
475 ));
476
477 let request_id = new_request_id();
478
479 Ok(build_sqs_response(
480 500,
481 &request_id,
482 serde_json::json!({
483 "__type": "InternalFailure",
484 "message": crate::utils::WireFailure::Unavailable.prefixed_text(),
485 })
486 .to_string(),
487 ))
488 }
489 Err(e) => {
490 // netget could not reach a decision at all. The peer gets a category, the log
491 // gets the error — never the backend URL, the model name or netget's own retry
492 // machinery. The two categories map onto different codes so an SDK backs off
493 // instead of recording a permanent fault: `ServiceUnavailable` + 503 +
494 // Retry-After is retryable in every AWS SDK's default policy, `InternalFailure`
495 // + 500 is not. Collapsing both onto 500, as this used to, makes a transient
496 // backend saturation look permanent.
497 let failure = crate::utils::WireFailure::classify(&e);
498 Log::new(Some(&status_tx)).warn(format!(
499 "SQS {} decision=fail_closed_llm_error category={:?}: {}",
500 operation, failure, e
501 ));
502
503 let (status, aws_type) = match failure {
504 crate::utils::WireFailure::Overloaded => (503, "ServiceUnavailable"),
505 crate::utils::WireFailure::Unavailable => (500, "InternalFailure"),
506 };
507
508 let mut response = build_sqs_response(
509 status,
510 &new_request_id(),
511 serde_json::json!({
512 "__type": aws_type,
513 "message": failure.text(),
514 })
515 .to_string(),
516 );
517 if failure == crate::utils::WireFailure::Overloaded {
518 response
519 .headers_mut()
520 .insert("Retry-After", hyper::header::HeaderValue::from_static("5"));
521 }
522 Ok(response)
523 }
524 }
525}
526
527/// Build an AWS-JSON response.
528///
529/// `status` originates in model output. `Response::builder().status()` rejects anything
530/// outside 100-999 and the previous `.unwrap()` turned that into a panic, killing the
531/// hyper connection task and leaving the client waiting on a socket that never answers.
532/// `SqsProtocol::execute_action` already rejects out-of-range values with a message the
533/// model sees; this is the belt-and-braces path.
534fn build_sqs_response(status: u16, request_id: &str, body: String) -> Response<Full<Bytes>> {
535 let status = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
536 error!("Invalid SQS status code {}, sending 500 instead", status);
537 hyper::StatusCode::INTERNAL_SERVER_ERROR
538 });
539
540 Response::builder()
541 .status(status)
542 .header("Content-Type", "application/x-amz-json-1.0")
543 .header("x-amzn-RequestId", request_id)
544 .body(Full::new(Bytes::from(body)))
545 .unwrap_or_else(|_| Response::new(Full::new(Bytes::from("{}"))))
546}
547
548/// Timestamp-derived request id, echoed in `x-amzn-RequestId`.
549fn new_request_id() -> String {
550 format!(
551 "{:x}",
552 crate::utils::clock::SystemTime::now()
553 .duration_since(crate::utils::clock::UNIX_EPOCH)
554 .map(|d| d.as_nanos())
555 .unwrap_or(0)
556 )
557}