Quellcode durchsuchen

Hardened dependency outage

Efren Yevale Varela vor 3 Wochen
Ursprung
Commit
9c21d36f53
1 geänderte Dateien mit 39 neuen und 2 gelöschten Zeilen
  1. 39 2
      src/error.rs

+ 39 - 2
src/error.rs

@@ -495,8 +495,9 @@ impl IntoResponse for AppError {
 
 impl From<sqlx::Error> for AppError {
   /// Map a database error to a client-safe error: connectivity failures
-  /// (pool timeout/close, transport I/O) render `503 temporarily_unavailable`
-  /// (fail closed, matching the Redis mapping); a unique violation on the
+  /// (pool timeout/close, transport I/O, and an outage SQLSTATE such as a
+  /// server shutdown mid-query) render `503 temporarily_unavailable` (fail
+  /// closed, matching the Redis mapping); a unique violation on the
   /// user-email constraints renders the payload-free [`AppError::DuplicateEmail`];
   /// a unique violation on any client mTLS identity constraint (legacy
   /// plaintext or blind-index) renders the payload-free
@@ -507,6 +508,9 @@ impl From<sqlx::Error> for AppError {
       sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::Io(_) => {
         Self::temporarily_unavailable()
       }
+      sqlx::Error::Database(db) if is_dependency_outage(db.code().as_deref()) => {
+        Self::temporarily_unavailable()
+      }
       sqlx::Error::Database(db)
         if db.code().as_deref() == Some("23505")
           && matches!(
@@ -533,6 +537,23 @@ impl From<sqlx::Error> for AppError {
   }
 }
 
+/// SQLSTATE codes proving the Postgres dependency is transiently unavailable
+/// (a failover, restart, or shutdown) rather than the statement being wrong.
+/// These must fail closed as `503 temporarily_unavailable`, never a masked
+/// `500`, matching the connectivity arms above and the Redis transient class.
+///
+/// - class `08` — Connection Exception (`08000`, `08003`, `08006`, `08P01`, …)
+/// - `57P01` admin_shutdown, `57P02` crash_shutdown, `57P03` cannot_connect_now,
+///   `57P04` database_dropped
+/// - `53300` too_many_connections
+fn is_dependency_outage(code: Option<&str>) -> bool {
+  let Some(code) = code else {
+    return false;
+  };
+  code.starts_with("08")
+    || matches!(code, "57P01" | "57P02" | "57P03" | "57P04" | "53300")
+}
+
 impl From<crate::crypto::field::FieldError> for AppError {
   /// Map a PII field failure fail-closed: a blob that fails to open becomes
   /// the distinguishable [`AppError::PiiDecrypt`] (503); a seal or RNG
@@ -788,6 +809,22 @@ mod tests {
     }
   }
 
+  #[test]
+  fn sqlx_outage_sqlstate_classification() {
+    for code in [
+      "08000", "08003", "08006", "08P01", "57P01", "57P02", "57P03", "57P04", "53300",
+    ] {
+      assert!(is_dependency_outage(Some(code)), "{code} must be an outage");
+    }
+    for code in ["23505", "42P01", "42601", "28P01", "57014", "40001"] {
+      assert!(
+        !is_dependency_outage(Some(code)),
+        "{code} must not be an outage"
+      );
+    }
+    assert!(!is_dependency_outage(None));
+  }
+
   #[tokio::test]
   async fn error_response_invalid_client_snapshot() {
     let (status, headers, body) = render(AppError::invalid_client_basic()).await;