From c29a91af6b7eb444e202f4724e3f677d2ef25466 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Mon, 10 Aug 2026 14:32:24 +0530 Subject: [PATCH] fix(node): map ipfs/arweave DB outages to 503 via bare ? (#251) Rebased onto current main. Keep budget-timeout wrappers on get_by_cid; convert sqlx failures with Into (not AppError::Internal) so pool closed/timeouts become 503 db_unavailable. Same for list_pins and arweave anchors, with closed-pool regressions. --- crates/gitlawb-node/src/api/arweave.rs | 51 ++++++++++++- crates/gitlawb-node/src/api/ipfs.rs | 102 +++++++++++++++++++++++-- crates/gitlawb-node/src/error.rs | 9 +++ 3 files changed, 153 insertions(+), 9 deletions(-) diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index 0d728c71..ad8f45a7 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -26,14 +26,61 @@ pub async fn list_anchors( Query(q): Query, ) -> Result> { let limit = q.limit.min(200); + // Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and + // map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251). let anchors = state .db .list_arweave_anchors(q.repo.as_deref(), limit) - .await - .map_err(crate::error::AppError::Internal)?; + .await?; Ok(Json(serde_json::json!({ "anchors": anchors, "count": anchors.len(), }))) } + +#[cfg(test)] +mod closed_pool_tests { + use super::*; + use axum::http::{Request, StatusCode}; + use axum::Router; + use serde_json::Value; + use sqlx::PgPool; + use tower::ServiceExt; + + /// #251: a closed pool on /api/v1/arweave/anchors must be 503 db_unavailable. + #[sqlx::test] + async fn list_anchors_closed_pool_returns_503_db_unavailable(pool: PgPool) { + let state = crate::test_support::test_state(pool.clone()).await; + pool.close().await; + + let resp = Router::new() + .route("/api/v1/arweave/anchors", axum::routing::get(list_anchors)) + .with_state(state) + .oneshot( + Request::builder() + .uri("/api/v1/arweave/anchors") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::SERVICE_UNAVAILABLE, + "closed-pool outage must be retryable 503, not 500" + ); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let v: Value = serde_json::from_slice(&bytes).expect("json body"); + assert_eq!( + v, + serde_json::json!({ + "error": crate::error::DB_UNAVAILABLE_CODE, + "message": crate::error::DB_UNAVAILABLE_MESSAGE, + }) + ); + } +} diff --git a/crates/gitlawb-node/src/api/ipfs.rs b/crates/gitlawb-node/src/api/ipfs.rs index f9e501ff..df7a42db 100644 --- a/crates/gitlawb-node/src/api/ipfs.rs +++ b/crates/gitlawb-node/src/api/ipfs.rs @@ -204,7 +204,9 @@ pub async fn get_by_cid( .await { Ok(Ok(repos)) => repos, - Ok(Err(e)) => return Err(AppError::Internal(e)), + // Bare conversion (not `AppError::Internal`) so connection-class sqlx + // failures downcast to `AppError::Db` → 503 `db_unavailable` (#251). + Ok(Err(e)) => return Err(e.into()), Err(_elapsed) => { tracing::warn!( budget_secs = state.config.ipfs_request_budget_secs, @@ -228,7 +230,8 @@ pub async fn get_by_cid( .await { Ok(Ok(rules)) => rules, - Ok(Err(e)) => return Err(AppError::Internal(e)), + // Same #251 downcast path as list_all_repos above. + Ok(Err(e)) => return Err(e.into()), // FAIL CLOSED (security-critical): a timeout on the access-control query must // DENY. Returning here — before the loop — means the scan can never fall // through and apply an empty rule map, which would serve an unfiltered listing @@ -681,11 +684,9 @@ pub async fn get_by_cid( /// objects received via push. Each entry includes the git SHA-256 hex, the /// CIDv1 string, and the timestamp when it was pinned. pub async fn list_pins(State(state): State) -> Result> { - let pins = state - .db - .list_pinned_cids() - .await - .map_err(AppError::Internal)?; + // Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and + // map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251). + let pins = state.db.list_pinned_cids().await?; Ok(Json(serde_json::json!({ "pins": pins, @@ -693,6 +694,93 @@ pub async fn list_pins(State(state): State) -> Result