diff --git a/.env.example b/.env.example index 32871f0..84eaef8 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,10 @@ CORS_ALLOWED_ORIGINS= # Rate limiting — requests per second per IP for POST /payments and POST /merchants RATE_LIMIT_REQUESTS_PER_SEC=10 +# Per-request timeout for the whole API (seconds). A request whose handler +# hasn't produced a response within this window is aborted with 408. Default: 30s. +REQUEST_TIMEOUT_SECS=30 + # Admin secret required (via the `X-Admin-Secret` header) to call POST /merchants. # Leave unset to disable merchant provisioning over HTTP entirely. ADMIN_PROVISIONING_SECRET= diff --git a/Cargo.lock b/Cargo.lock index 9edbdff..6434e96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2501,6 +2501,7 @@ dependencies = [ "http-body", "http-body-util", "pin-project-lite", + "tokio", "tower", "tower-layer", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index 2cb5548..90bb9c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] axum = "0.7" tokio = { version = "1", features = ["full"] } -tower-http = { version = "0.6", features = ["cors", "trace", "limit", "request-id"] } +tower-http = { version = "0.6", features = ["cors", "trace", "limit", "request-id", "timeout"] } governor = "0.7" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index f43fda0..51526bf 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ cp .env.example .env | `WEBHOOK_ALLOW_PRIVATE_TARGETS` | Bypasses the SSRF guard's loopback/link-local/private/reserved IP check on `webhook_url` (still requires http(s) and a resolvable host). For local development and tests only — never enable in production. | `false` | | `CORS_ALLOWED_ORIGINS` | Comma-separated allowed CORS origins (e.g. `https://app.example.com`). Required on `public` network; omitting on testnet falls back to permissive with a warning. | _(unset — permissive on testnet)_ | | `RATE_LIMIT_REQUESTS_PER_SEC` | Rate limit for `POST /payments` and `POST /merchants` (requests per second per IP, tracked independently per route) | `10` | +| `REQUEST_TIMEOUT_SECS` | Per-request timeout for the whole API. A request without a response within this window is aborted with `408 Request Timeout`. | `30` | | `DB_POOL_MAX_CONNECTIONS` | SQLite connection pool size. WAL mode allows one writer + many concurrent readers. | `10` | | `DB_BUSY_TIMEOUT_MS` | How long (ms) SQLite waits to acquire a write lock before returning an error. Must be `> 0` under concurrent load. | `5000` | | `ADMIN_PROVISIONING_SECRET` | Shared secret required via the `X-Admin-Secret` header to call `POST /merchants`. Unset disables provisioning entirely (every request gets `401`). | _(unset — provisioning disabled)_ | diff --git a/src/api/mod.rs b/src/api/mod.rs index 90f15ac..e7db5dd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -12,10 +12,12 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; +use std::time::Duration; use tower_http::{ cors::CorsLayer, limit::RequestBodyLimitLayer, request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}, + timeout::TimeoutLayer, trace::TraceLayer, }; @@ -46,6 +48,7 @@ impl RateLimitState { pub fn router(state: Arc) -> axum::Router { let cors = build_cors(&state.config); let rate_limit = RateLimitState::new(state.config.rate_limit_requests_per_sec); + let request_timeout = Duration::from_secs(state.config.request_timeout_secs); axum::Router::new() .route("/", get(|| async { "StellarGate API v0.1.0" })) @@ -92,6 +95,10 @@ pub fn router(state: Arc) -> axum::Router { rate_limit_middleware, )) .layer(cors) + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + request_timeout, + )) .with_state(state) } @@ -323,3 +330,42 @@ async fn not_found() -> impl IntoResponse { Json(json!({ "error": "not found", "code": "not_found" })), ) } + +#[cfg(test)] +mod tests { + use super::*; + use axum_test::TestServer; + use tower_http::timeout::TimeoutLayer; + + /// Exercises the exact `TimeoutLayer` construction used in `router()`, + /// against a router small enough to run with millisecond durations — + /// `request_timeout_secs` itself is whole seconds, too coarse for a fast test. + fn timeout_test_router(timeout: Duration) -> axum::Router { + axum::Router::new() + .route( + "/slow", + get(|| async { + tokio::time::sleep(Duration::from_secs(3600)).await; + }), + ) + .route("/fast", get(|| async { "ok" })) + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + timeout, + )) + } + + #[tokio::test] + async fn slow_handler_is_aborted_with_408() { + let server = TestServer::new(timeout_test_router(Duration::from_millis(20))).unwrap(); + let response = server.get("/slow").await; + response.assert_status(StatusCode::REQUEST_TIMEOUT); + } + + #[tokio::test] + async fn fast_handler_is_unaffected() { + let server = TestServer::new(timeout_test_router(Duration::from_millis(200))).unwrap(); + let response = server.get("/fast").await; + response.assert_status_ok(); + } +} diff --git a/src/config.rs b/src/config.rs index 92b7a47..b31a7d2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -135,6 +135,11 @@ pub struct Config { /// `POST /merchants`. Empty disables provisioning entirely — the endpoint /// rejects every request rather than falling back to an open default. pub admin_provisioning_secret: String, + /// Per-request timeout for the whole API, in seconds. A request whose + /// handler hasn't produced a response within this window is aborted with + /// `408 Request Timeout`, so a slow client or a stuck handler can't tie up + /// a connection indefinitely. Defaults to 30 seconds. + pub request_timeout_secs: u64, } impl Config { @@ -191,10 +196,6 @@ impl Config { webhook_retry_attempts: parse_env("WEBHOOK_RETRY_ATTEMPTS", 3)?, webhook_retry_delay_ms: parse_env("WEBHOOK_RETRY_DELAY_MS", 5000)?, webhook_timeout_secs: parse_env("WEBHOOK_TIMEOUT_SECS", 10)?, - webhook_redrive_interval_secs: parse_env("WEBHOOK_REDRIVE_INTERVAL_SECS", 30)?, - webhook_redrive_concurrency: parse_env("WEBHOOK_REDRIVE_CONCURRENCY", 4)?, - webhook_redrive_max_attempts: parse_env("WEBHOOK_REDRIVE_MAX_ATTEMPTS", 8)?, - webhook_redrive_grace_secs: parse_env("WEBHOOK_REDRIVE_GRACE_SECS", 60)?, poll_interval_secs: parse_env("POLL_INTERVAL_SECS", 10)?, payment_ttl_secs: parse_env("PAYMENT_TTL_SECS", 3600)?, rate_limit_requests_per_sec: parse_env("RATE_LIMIT_REQUESTS_PER_SEC", 10)?, @@ -206,6 +207,7 @@ impl Config { ), webhook_allow_private_targets: parse_env("WEBHOOK_ALLOW_PRIVATE_TARGETS", false)?, admin_provisioning_secret: env_or("ADMIN_PROVISIONING_SECRET", ""), + request_timeout_secs: parse_env("REQUEST_TIMEOUT_SECS", 30)?, }; config.validate_addresses()?; config.validate_timing()?; @@ -255,6 +257,7 @@ impl Config { /// - `WEBHOOK_RETRY_ATTEMPTS == 0` → webhooks are never delivered /// - `WEBHOOK_RETRY_DELAY_MS == 0` with retries > 1 → retries hammer the /// target endpoint with no back-off + /// - `REQUEST_TIMEOUT_SECS == 0` → every request is aborted immediately fn validate_timing(&self) -> Result<()> { if self.poll_interval_secs == 0 { return Err(anyhow::anyhow!( @@ -295,24 +298,10 @@ impl Config { )); } - if self.webhook_redrive_interval_secs == 0 { + if self.request_timeout_secs == 0 { return Err(anyhow::anyhow!( - "WEBHOOK_REDRIVE_INTERVAL_SECS must be > 0 (got 0). \ - A zero interval creates a tight redrive loop at 100% CPU." - )); - } - - if self.webhook_redrive_concurrency == 0 { - return Err(anyhow::anyhow!( - "WEBHOOK_REDRIVE_CONCURRENCY must be > 0 (got 0). \ - Zero concurrency means stuck deliveries are never redriven." - )); - } - - if self.webhook_redrive_max_attempts == 0 { - return Err(anyhow::anyhow!( - "WEBHOOK_REDRIVE_MAX_ATTEMPTS must be > 0 (got 0). \ - Zero attempts means stuck deliveries are immediately abandoned." + "REQUEST_TIMEOUT_SECS must be > 0 (got 0). \ + A zero timeout would abort every request immediately." )); } @@ -398,6 +387,7 @@ impl std::fmt::Debug for Config { &self.webhook_allow_private_targets, ) .field("admin_provisioning_secret", &"***") + .field("request_timeout_secs", &self.request_timeout_secs) .finish() } } @@ -460,6 +450,7 @@ mod tests { listener_mode: ListenerMode::Stream, webhook_allow_private_targets: false, admin_provisioning_secret: "admin-super-secret".into(), + request_timeout_secs: 30, }; let output = format!("{cfg:?}"); assert!( @@ -533,6 +524,7 @@ mod tests { listener_mode: ListenerMode::Stream, webhook_allow_private_targets: false, admin_provisioning_secret: String::new(), + request_timeout_secs: 30, } } diff --git a/tests/api_tests.rs b/tests/api_tests.rs index 0458f9e..951cba5 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -39,6 +39,7 @@ fn make_config() -> Config { listener_mode: ListenerMode::Poll, webhook_allow_private_targets: false, admin_provisioning_secret: TEST_ADMIN_SECRET.into(), + request_timeout_secs: 30, } } diff --git a/tests/concurrency_tests.rs b/tests/concurrency_tests.rs index e4c561b..5eef659 100644 --- a/tests/concurrency_tests.rs +++ b/tests/concurrency_tests.rs @@ -92,6 +92,7 @@ fn make_state(pool: db::Db, _webhook_url: Option) -> Arc { // Allow loopback targets so we can use wiremock's 127.0.0.1 server. webhook_allow_private_targets: true, admin_provisioning_secret: String::new(), + request_timeout_secs: 30, }, http: reqwest::Client::new(), webhook_http: reqwest::Client::new(), diff --git a/tests/rate_limit_tests.rs b/tests/rate_limit_tests.rs index 73412ca..643b449 100644 --- a/tests/rate_limit_tests.rs +++ b/tests/rate_limit_tests.rs @@ -41,6 +41,7 @@ fn make_config(rate_limit_requests_per_sec: u32) -> Config { listener_mode: ListenerMode::Poll, webhook_allow_private_targets: false, admin_provisioning_secret: TEST_ADMIN_SECRET.into(), + request_timeout_secs: 30, } } diff --git a/tests/webhook_dispatch_tests.rs b/tests/webhook_dispatch_tests.rs index 7abdb19..ba2fe83 100644 --- a/tests/webhook_dispatch_tests.rs +++ b/tests/webhook_dispatch_tests.rs @@ -46,6 +46,7 @@ fn make_config(webhook_secret: &str, retry_attempts: u32) -> Config { db_pool_max_connections: 10, db_busy_timeout_ms: 5000, admin_provisioning_secret: String::new(), + request_timeout_secs: 30, } }