From 843051da3114e9d4f07fb3915e9a10617e61a6f9 Mon Sep 17 00:00:00 2001 From: YUsrah Mohammed Date: Mon, 23 Feb 2026 01:23:40 +0100 Subject: [PATCH] feat: Implement structured error codes --- PR_CONNECTION_DRAINING.md | 23 +++++ PR_ERROR_CODES.md | 41 ++++++++ docs/error-catalog.md | 132 +++++++++++++++++++++++++ src/error.rs | 196 ++++++++++++++++++++++++++++++++++++++ src/handlers/mod.rs | 12 +++ src/lib.rs | 1 + src/main.rs | 1 + 7 files changed, 406 insertions(+) create mode 100644 PR_CONNECTION_DRAINING.md create mode 100644 PR_ERROR_CODES.md create mode 100644 docs/error-catalog.md diff --git a/PR_CONNECTION_DRAINING.md b/PR_CONNECTION_DRAINING.md new file mode 100644 index 0000000..5a1f613 --- /dev/null +++ b/PR_CONNECTION_DRAINING.md @@ -0,0 +1,23 @@ +## Connection Draining for Zero-Downtime Deployments + +### Summary +Implements connection draining to enable zero-downtime rolling deployments by allowing in-flight requests to complete before shutdown. + +### Changes +- Added `/ready` endpoint (separate from `/health`) for Kubernetes readiness probes +- Returns 200 when accepting traffic, 503 during drain +- On SIGTERM: immediately stops accepting new connections, waits up to 30s for in-flight requests + +### New Files +- `src/readiness.rs` - AtomicBool-based readiness state +- `tests/readiness_unit_test.rs` - Unit tests +- `tests/connection_draining_test.rs` - Integration tests + +### Modified Files +- `src/config.rs` - Added `DRAIN_TIMEOUT_SECS` config +- `src/handlers/mod.rs` - Added `/ready` handler +- `src/lib.rs` - Wired readiness into AppState +- `src/main.rs` - Added SIGTERM handler with graceful shutdown + +### Configuration +- `DRAIN_TIMEOUT_SECS` - Drain timeout in seconds (default: 30) diff --git a/PR_ERROR_CODES.md b/PR_ERROR_CODES.md new file mode 100644 index 0000000..a1f15c6 --- /dev/null +++ b/PR_ERROR_CODES.md @@ -0,0 +1,41 @@ +## Structured Error Codes & Error Catalog + +### Summary +Added machine-readable error codes to enable API consumers to programmatically handle specific failure scenarios. + +### Changes +- Every AppError variant now has a unique stable error code (e.g., ERR_VALIDATION_001) +- JSON error responses now include the code field: `{ "error": "message", "code": "ERR_VALIDATION_001", "status": 400 }` +- Added GET /errors endpoint returning error catalog as JSON +- Created static error catalog at docs/error-catalog.md + +### New Files +- `docs/error-catalog.md` - Static error catalog with all 19 error codes + +### Modified Files +- `src/error.rs` - Added code() method, error code constants, ErrorCode struct +- `src/handlers/mod.rs` - Added error_catalog handler +- `src/main.rs` - Added /errors route +- `src/lib.rs` - Added /errors route + +### Error Codes (19 total) +- ERR_DATABASE_001/002 - Database errors +- ERR_VALIDATION_001 - Validation errors +- ERR_NOT_FOUND_001 - Not found errors +- ERR_INTERNAL_001 - Internal errors +- ERR_BAD_REQUEST_001 - Bad request errors +- ERR_AUTH_001/002 - Authentication errors +- ERR_UNAUTHORIZED_001 - Unauthorized errors +- ERR_TRANSACTION_001-005 - Transaction errors +- ERR_WEBHOOK_001/002 - Webhook errors +- ERR_SETTLEMENT_001/002 - Settlement errors +- ERR_RATE_LIMIT_001 - Rate limiting + +### Example Response +```json +{ + "error": "Validation error: invalid email", + "code": "ERR_VALIDATION_001", + "status": 400 +} +``` diff --git a/docs/error-catalog.md b/docs/error-catalog.md new file mode 100644 index 0000000..64fe127 --- /dev/null +++ b/docs/error-catalog.md @@ -0,0 +1,132 @@ +# Error Catalog + +This document lists all stable error codes used by the Synapse Core API. Error codes are stable and should never be renamed or reused for different errors. + +## Error Response Format + +All error responses follow this format: + +```json +{ + "error": "Human readable error message", + "code": "ERR_CATEGORY_NNN", + "status": 400 +} +``` + +## Error Codes + +### Database Errors (ERR_DATABASE_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_DATABASE_001 | 500 | Database connection error | +| ERR_DATABASE_002 | 500 | Database query execution error | + +### Validation Errors (ERR_VALIDATION_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_VALIDATION_001 | 400 | Validation error - invalid input | + +### Not Found Errors (ERR_NOT_FOUND_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_NOT_FOUND_001 | 404 | Resource not found | + +### Internal Errors (ERR_INTERNAL_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_INTERNAL_001 | 500 | Internal server error | + +### Bad Request Errors (ERR_BAD_REQUEST_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_BAD_REQUEST_001 | 400 | Bad request - invalid parameters | + +### Authentication Errors (ERR_AUTH_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_AUTH_001 | 401 | Invalid authentication credentials | +| ERR_AUTH_002 | 403 | Insufficient permissions | + +### Unauthorized Errors (ERR_UNAUTHORIZED_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_UNAUTHORIZED_001 | 401 | Unauthorized - authentication required | + +### Transaction Errors (ERR_TRANSACTION_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_TRANSACTION_001 | 400 | Invalid transaction amount | +| ERR_TRANSACTION_002 | 400 | Transaction amount below minimum | +| ERR_TRANSACTION_003 | 400 | Invalid Stellar address | +| ERR_TRANSACTION_004 | 409 | Transaction already processed (idempotency) | +| ERR_TRANSACTION_005 | 400 | Invalid transaction status transition | + +### Webhook Errors (ERR_WEBHOOK_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_WEBHOOK_001 | 401 | Invalid webhook signature | +| ERR_WEBHOOK_002 | 400 | Malformed webhook payload | + +### Settlement Errors (ERR_SETTLEMENT_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_SETTLEMENT_001 | 400 | Invalid settlement amount | +| ERR_SETTLEMENT_002 | 409 | Settlement already exists | + +### Rate Limiting Errors (ERR_RATE_LIMIT_xxx) + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| ERR_RATE_LIMIT_001 | 429 | Rate limit exceeded | + +## Using Error Codes + +### Programmatic Retry Logic + +Clients can use error codes to implement intelligent retry logic: + +```python +# Example: Retry on transient errors +TRANSIENT_ERRORS = ["ERR_DATABASE_001", "ERR_INTERNAL_001"] + +def handle_error(response): + error_code = response["code"] + if error_code in TRANSIENT_ERRORS: + # Retry with exponential backoff + return retry_with_backoff() + elif error_code == "ERR_RATE_LIMIT_001": + # Retry after waiting for rate limit reset + return retry_after_delay() + else: + # Don't retry for client errors + return handle_failure(response) +``` + +### Idempotency Handling + +Clients can detect idempotent operations that have already been processed: + +```python +if response["code"] == "ERR_TRANSACTION_004": + # Transaction was already processed + return get_existing_result() +``` + +## Version + +This error catalog is version 1.0.0. API consumers can retrieve the latest version via the `/errors` endpoint. + +## Changelog + +- 1.0.0 - Initial error catalog with 19 error codes diff --git a/src/error.rs b/src/error.rs index 580044d..f1ea878 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,9 +3,80 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use serde::{Deserialize, Serialize}; use serde_json::json; use thiserror::Error; +/// Error codes for programmatic error handling +/// These codes are stable and should never be renamed or reused +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorCode { + pub code: &'static str, + pub http_status: u16, + pub description: &'static str, +} + +/// Error code constants +pub mod codes { + //! Stable error codes for the API + //! Format: ERR__ + + pub const DATABASE_001: (&str, u16, &str) = ("ERR_DATABASE_001", 500, "Database connection error"); + pub const DATABASE_002: (&str, u16, &str) = ("ERR_DATABASE_002", 500, "Database query execution error"); + pub const VALIDATION_001: (&str, u16, &str) = ("ERR_VALIDATION_001", 400, "Validation error - invalid input"); + pub const NOT_FOUND_001: (&str, u16, &str) = ("ERR_NOT_FOUND_001", 404, "Resource not found"); + pub const INTERNAL_001: (&str, u16, &str) = ("ERR_INTERNAL_001", 500, "Internal server error"); + pub const BAD_REQUEST_001: (&str, u16, &str) = ("ERR_BAD_REQUEST_001", 400, "Bad request - invalid parameters"); + pub const UNAUTHORIZED_001: (&str, u16, &str) = ("ERR_UNAUTHORIZED_001", 401, "Unauthorized - authentication required"); + + // Authentication specific errors + pub const AUTH_001: (&str, u16, &str) = ("ERR_AUTH_001", 401, "Invalid authentication credentials"); + pub const AUTH_002: (&str, u16, &str) = ("ERR_AUTH_002", 403, "Insufficient permissions"); + + // Transaction specific errors + pub const TRANSACTION_001: (&str, u16, &str) = ("ERR_TRANSACTION_001", 400, "Invalid transaction amount"); + pub const TRANSACTION_002: (&str, u16, &str) = ("ERR_TRANSACTION_002", 400, "Transaction amount below minimum"); + pub const TRANSACTION_003: (&str, u16, &str) = ("ERR_TRANSACTION_003", 400, "Invalid Stellar address"); + pub const TRANSACTION_004: (&str, u16, &str) = ("ERR_TRANSACTION_004", 409, "Transaction already processed (idempotency)"); + pub const TRANSACTION_005: (&str, u16, &str) = ("ERR_TRANSACTION_005", 400, "Invalid transaction status transition"); + + // Webhook specific errors + pub const WEBHOOK_001: (&str, u16, &str) = ("ERR_WEBHOOK_001", 401, "Invalid webhook signature"); + pub const WEBHOOK_002: (&str, u16, &str) = ("ERR_WEBHOOK_002", 400, "Malformed webhook payload"); + + // Settlement specific errors + pub const SETTLEMENT_001: (&str, u16, &str) = ("ERR_SETTLEMENT_001", 400, "Invalid settlement amount"); + pub const SETTLEMENT_002: (&str, u16, &str) = ("ERR_SETTLEMENT_002", 409, "Settlement already exists"); + + // Rate limiting + pub const RATE_LIMIT_001: (&str, u16, &str) = ("ERR_RATE_LIMIT_001", 429, "Rate limit exceeded"); +} + +/// Get all error codes as a vector for catalog generation +pub fn get_all_error_codes() -> Vec { + vec![ + ErrorCode { code: codes::DATABASE_001.0, http_status: codes::DATABASE_001.1, description: codes::DATABASE_001.2 }, + ErrorCode { code: codes::DATABASE_002.0, http_status: codes::DATABASE_002.1, description: codes::DATABASE_002.2 }, + ErrorCode { code: codes::VALIDATION_001.0, http_status: codes::VALIDATION_001.1, description: codes::VALIDATION_001.2 }, + ErrorCode { code: codes::NOT_FOUND_001.0, http_status: codes::NOT_FOUND_001.1, description: codes::NOT_FOUND_001.2 }, + ErrorCode { code: codes::INTERNAL_001.0, http_status: codes::INTERNAL_001.1, description: codes::INTERNAL_001.2 }, + ErrorCode { code: codes::BAD_REQUEST_001.0, http_status: codes::BAD_REQUEST_001.1, description: codes::BAD_REQUEST_001.2 }, + ErrorCode { code: codes::UNAUTHORIZED_001.0, http_status: codes::UNAUTHORIZED_001.1, description: codes::UNAUTHORIZED_001.2 }, + ErrorCode { code: codes::AUTH_001.0, http_status: codes::AUTH_001.1, description: codes::AUTH_001.2 }, + ErrorCode { code: codes::AUTH_002.0, http_status: codes::AUTH_002.1, description: codes::AUTH_002.2 }, + ErrorCode { code: codes::TRANSACTION_001.0, http_status: codes::TRANSACTION_001.1, description: codes::TRANSACTION_001.2 }, + ErrorCode { code: codes::TRANSACTION_002.0, http_status: codes::TRANSACTION_002.1, description: codes::TRANSACTION_002.2 }, + ErrorCode { code: codes::TRANSACTION_003.0, http_status: codes::TRANSACTION_003.1, description: codes::TRANSACTION_003.2 }, + ErrorCode { code: codes::TRANSACTION_004.0, http_status: codes::TRANSACTION_004.1, description: codes::TRANSACTION_004.2 }, + ErrorCode { code: codes::TRANSACTION_005.0, http_status: codes::TRANSACTION_005.1, description: codes::TRANSACTION_005.2 }, + ErrorCode { code: codes::WEBHOOK_001.0, http_status: codes::WEBHOOK_001.1, description: codes::WEBHOOK_001.2 }, + ErrorCode { code: codes::WEBHOOK_002.0, http_status: codes::WEBHOOK_002.1, description: codes::WEBHOOK_002.2 }, + ErrorCode { code: codes::SETTLEMENT_001.0, http_status: codes::SETTLEMENT_001.1, description: codes::SETTLEMENT_001.2 }, + ErrorCode { code: codes::SETTLEMENT_002.0, http_status: codes::SETTLEMENT_002.1, description: codes::SETTLEMENT_002.2 }, + ErrorCode { code: codes::RATE_LIMIT_001.0, http_status: codes::RATE_LIMIT_001.1, description: codes::RATE_LIMIT_001.2 }, + ] +} + #[derive(Error, Debug)] pub enum AppError { #[error("Database error: {0}")] @@ -28,9 +99,47 @@ pub enum AppError { #[error("Unauthorized: {0}")] Unauthorized(String), + + // Custom errors with specific codes + #[error("Invalid transaction amount: {0}")] + InvalidTransactionAmount(String), + + #[error("Amount below minimum: {0}")] + AmountBelowMinimum(String), + + #[error("Invalid Stellar address: {0}")] + InvalidStellarAddress(String), + + #[error("Transaction already processed: {0}")] + TransactionAlreadyProcessed(String), + + #[error("Invalid status transition: {0}")] + InvalidStatusTransition(String), + + #[error("Invalid webhook signature")] + InvalidWebhookSignature, + + #[error("Malformed webhook payload: {0}")] + MalformedWebhookPayload(String), + + #[error("Invalid settlement amount: {0}")] + InvalidSettlementAmount(String), + + #[error("Settlement already exists: {0}")] + SettlementAlreadyExists(String), + + #[error("Rate limit exceeded")] + RateLimitExceeded, + + #[error("Authentication failed: {0}")] + AuthenticationFailed(String), + + #[error("Insufficient permissions: {0}")] + InsufficientPermissions(String), } impl AppError { + /// Get the HTTP status code for this error fn status_code(&self) -> StatusCode { match self { AppError::Database(_) | AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, @@ -39,6 +148,44 @@ impl AppError { AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::BadRequest(_) => StatusCode::BAD_REQUEST, AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + AppError::InvalidTransactionAmount(_) => StatusCode::BAD_REQUEST, + AppError::AmountBelowMinimum(_) => StatusCode::BAD_REQUEST, + AppError::InvalidStellarAddress(_) => StatusCode::BAD_REQUEST, + AppError::TransactionAlreadyProcessed(_) => StatusCode::CONFLICT, + AppError::InvalidStatusTransition(_) => StatusCode::BAD_REQUEST, + AppError::InvalidWebhookSignature => StatusCode::UNAUTHORIZED, + AppError::MalformedWebhookPayload(_) => StatusCode::BAD_REQUEST, + AppError::InvalidSettlementAmount(_) => StatusCode::BAD_REQUEST, + AppError::SettlementAlreadyExists(_) => StatusCode::CONFLICT, + AppError::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS, + AppError::AuthenticationFailed(_) => StatusCode::UNAUTHORIZED, + AppError::InsufficientPermissions(_) => StatusCode::FORBIDDEN, + } + } + + /// Get the stable error code for this error + /// These codes are stable and should never be renamed or reused + pub fn code(&self) -> &'static str { + match self { + AppError::Database(_) => codes::DATABASE_001.0, + AppError::DatabaseError(_) => codes::DATABASE_002.0, + AppError::Validation(_) => codes::VALIDATION_001.0, + AppError::NotFound(_) => codes::NOT_FOUND_001.0, + AppError::Internal(_) => codes::INTERNAL_001.0, + AppError::BadRequest(_) => codes::BAD_REQUEST_001.0, + AppError::Unauthorized(_) => codes::UNAUTHORIZED_001.0, + AppError::InvalidTransactionAmount(_) => codes::TRANSACTION_001.0, + AppError::AmountBelowMinimum(_) => codes::TRANSACTION_002.0, + AppError::InvalidStellarAddress(_) => codes::TRANSACTION_003.0, + AppError::TransactionAlreadyProcessed(_) => codes::TRANSACTION_004.0, + AppError::InvalidStatusTransition(_) => codes::TRANSACTION_005.0, + AppError::InvalidWebhookSignature => codes::WEBHOOK_001.0, + AppError::MalformedWebhookPayload(_) => codes::WEBHOOK_002.0, + AppError::InvalidSettlementAmount(_) => codes::SETTLEMENT_001.0, + AppError::SettlementAlreadyExists(_) => codes::SETTLEMENT_002.0, + AppError::RateLimitExceeded => codes::RATE_LIMIT_001.0, + AppError::AuthenticationFailed(_) => codes::AUTH_001.0, + AppError::InsufficientPermissions(_) => codes::AUTH_002.0, } } } @@ -48,6 +195,7 @@ impl IntoResponse for AppError { let status = self.status_code(); let body = Json(json!({ "error": self.to_string(), + "code": self.code(), "status": status.as_u16(), })); @@ -55,6 +203,21 @@ impl IntoResponse for AppError { } } +/// Error response structure for JSON serialization +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: String, + pub code: String, + pub status: u16, +} + +/// Catalog response structure +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorCatalogResponse { + pub errors: Vec, + pub version: String, +} + #[cfg(test)] mod tests { use super::*; @@ -118,4 +281,37 @@ mod tests { assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); } + + #[test] + fn test_error_codes() { + // Test that all error types return correct codes + assert_eq!(AppError::Validation("test".to_string()).code(), codes::VALIDATION_001.0); + assert_eq!(AppError::NotFound("test".to_string()).code(), codes::NOT_FOUND_001.0); + assert_eq!(AppError::BadRequest("test".to_string()).code(), codes::BAD_REQUEST_001.0); + assert_eq!(AppError::Unauthorized("test".to_string()).code(), codes::UNAUTHORIZED_001.0); + assert_eq!(AppError::Internal("test".to_string()).code(), codes::INTERNAL_001.0); + assert_eq!(AppError::Database(sqlx::Error::RowNotFound).code(), codes::DATABASE_001.0); + assert_eq!(AppError::DatabaseError("test".to_string()).code(), codes::DATABASE_002.0); + + // Custom errors + assert_eq!(AppError::InvalidTransactionAmount("test".to_string()).code(), codes::TRANSACTION_001.0); + assert_eq!(AppError::AmountBelowMinimum("test".to_string()).code(), codes::TRANSACTION_002.0); + assert_eq!(AppError::InvalidStellarAddress("test".to_string()).code(), codes::TRANSACTION_003.0); + assert_eq!(AppError::TransactionAlreadyProcessed("test".to_string()).code(), codes::TRANSACTION_004.0); + assert_eq!(AppError::InvalidStatusTransition("test".to_string()).code(), codes::TRANSACTION_005.0); + assert_eq!(AppError::InvalidWebhookSignature.code(), codes::WEBHOOK_001.0); + assert_eq!(AppError::MalformedWebhookPayload("test".to_string()).code(), codes::WEBHOOK_002.0); + assert_eq!(AppError::InvalidSettlementAmount("test".to_string()).code(), codes::SETTLEMENT_001.0); + assert_eq!(AppError::SettlementAlreadyExists("test".to_string()).code(), codes::SETTLEMENT_002.0); + assert_eq!(AppError::RateLimitExceeded.code(), codes::RATE_LIMIT_001.0); + assert_eq!(AppError::AuthenticationFailed("test".to_string()).code(), codes::AUTH_001.0); + assert_eq!(AppError::InsufficientPermissions("test".to_string()).code(), codes::AUTH_002.0); + } + + #[test] + fn test_error_catalog_size() { + let catalog = get_all_error_codes(); + // Verify we have all expected error codes + assert!(catalog.len() >= 19, "Error catalog should have at least 19 codes"); + } } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 5a6e3b8..4098811 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -56,3 +56,15 @@ pub struct ReadinessResponse { pub status: String, pub draining: bool, } + +/// Error catalog endpoint +/// Returns all available error codes and their descriptions +pub async fn error_catalog() -> impl IntoResponse { + let errors = crate::error::get_all_error_codes(); + let catalog = crate::error::ErrorCatalogResponse { + errors, + version: "1.0.0".to_string(), + }; + + (StatusCode::OK, Json(catalog)) +} diff --git a/src/lib.rs b/src/lib.rs index 4855d17..bd76f75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ pub fn create_app(app_state: AppState) -> Router { Router::new() .route("/health", get(handlers::health)) .route("/ready", get(handlers::ready)) + .route("/errors", get(handlers::error_catalog)) .route("/settlements", get(handlers::settlements::list_settlements)) .route("/settlements/:id", get(handlers::settlements::get_settlement)) .route("/callback", post(handlers::webhook::callback)) diff --git a/src/main.rs b/src/main.rs index f79f111..7ab1f95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -371,6 +371,7 @@ async fn serve(config: config::Config) -> anyhow::Result<()> { let app = Router::new() .route("/health", get(handlers::health)) .route("/ready", get(handlers::ready)) + .route("/errors", get(handlers::error_catalog)) .merge(search_routes) .route("/settlements", get(handlers::settlements::list_settlements)) .route("/settlements/:id", get(handlers::settlements::get_settlement))