Skip to main content

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}