Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)_ |
Expand Down
46 changes: 46 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -46,6 +48,7 @@ impl RateLimitState {
pub fn router(state: Arc<AppState>) -> 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" }))
Expand Down Expand Up @@ -92,6 +95,10 @@ pub fn router(state: Arc<AppState>) -> axum::Router {
rate_limit_middleware,
))
.layer(cors)
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
request_timeout,
))
.with_state(state)
}

Expand Down Expand Up @@ -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();
}
}
34 changes: 13 additions & 21 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)?,
Expand All @@ -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()?;
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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."
));
}

Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -533,6 +524,7 @@ mod tests {
listener_mode: ListenerMode::Stream,
webhook_allow_private_targets: false,
admin_provisioning_secret: String::new(),
request_timeout_secs: 30,
}
}

Expand Down
1 change: 1 addition & 0 deletions tests/api_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
1 change: 1 addition & 0 deletions tests/concurrency_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ fn make_state(pool: db::Db, _webhook_url: Option<String>) -> Arc<AppState> {
// 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(),
Expand Down
1 change: 1 addition & 0 deletions tests/rate_limit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
1 change: 1 addition & 0 deletions tests/webhook_dispatch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
Loading