diff --git a/crates/trusted-server-adapter-fastly/src/error.rs b/crates/trusted-server-adapter-fastly/src/error.rs index 560a2e18..f3182ddc 100644 --- a/crates/trusted-server-adapter-fastly/src/error.rs +++ b/crates/trusted-server-adapter-fastly/src/error.rs @@ -17,3 +17,26 @@ pub fn to_error_response(report: &Report) -> Response { Response::from_status(root_error.status_code()) .with_body_text_plain(&format!("{}\n", root_error.user_message())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_store_unavailable_renders_503() { + // Locks the end-to-end mapping: a config-store read failure reaches the + // client as 503 via `status_code()` — not bypassed by the adapter. + let report = Report::new(TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "unavailable or not seeded".to_string(), + }); + + let response = to_error_response(&report); + + assert_eq!( + response.get_status(), + fastly::http::StatusCode::SERVICE_UNAVAILABLE, + "config-store read failure should render as 503 to the client" + ); + } +} diff --git a/crates/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 2804afec..ed934bb6 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -26,6 +26,11 @@ pub enum TrustedServerError { #[display("Configuration error: {message}")] Configuration { message: String }, + /// Config store could not be read (unseeded, transient backend, or a listed + /// key missing) — Settings cannot be loaded. Retryable / fix by seeding. + #[display("Config store unavailable: {store_name} - {message}")] + ConfigStoreUnavailable { store_name: String, message: String }, + /// Auction orchestration error. #[display("Auction error: {message}")] Auction { message: String }, @@ -123,6 +128,7 @@ impl IntoHttpResponse for TrustedServerError { Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, Self::InvalidUtf8 { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ConfigStoreUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, Self::Prebid { .. } => StatusCode::BAD_GATEWAY, Self::Integration { .. } => StatusCode::BAD_GATEWAY, Self::Proxy { .. } => StatusCode::BAD_GATEWAY, @@ -211,6 +217,13 @@ mod tests { }, StatusCode::SERVICE_UNAVAILABLE, ), + ( + TrustedServerError::ConfigStoreUnavailable { + store_name: String::from("app_config"), + message: String::from("store unavailable"), + }, + StatusCode::SERVICE_UNAVAILABLE, + ), ( TrustedServerError::Prebid { message: String::from("adapter error"), @@ -286,6 +299,9 @@ mod tests { TrustedServerError::RequestTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, TrustedServerError::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, TrustedServerError::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE, + TrustedServerError::ConfigStoreUnavailable { .. } => { + StatusCode::SERVICE_UNAVAILABLE + } TrustedServerError::Prebid { .. } => StatusCode::BAD_GATEWAY, TrustedServerError::Integration { .. } => StatusCode::BAD_GATEWAY, TrustedServerError::Proxy { .. } => StatusCode::BAD_GATEWAY, @@ -400,6 +416,25 @@ mod tests { assert_eq!(error.user_message(), "Invalid header value"); } + #[test] + fn config_store_unavailable_maps_to_503() { + let error = TrustedServerError::ConfigStoreUnavailable { + store_name: String::from("app_config"), + message: String::from("unavailable or not seeded"), + }; + assert_eq!( + error.status_code(), + StatusCode::SERVICE_UNAVAILABLE, + "config-store read failure should map to 503" + ); + // Detail stays server-side; the public body is the generic catch-all. + assert_eq!( + error.user_message(), + "An internal error occurred", + "503 client body must not leak internal config-store detail" + ); + } + #[test] fn status_code_maps_each_error_variant_to_expected_http_response() { // Compile-time guard: adding a TrustedServerError variant without @@ -413,6 +448,7 @@ mod tests { | TrustedServerError::InvalidUtf8 { .. } | TrustedServerError::InvalidHeaderValue { .. } | TrustedServerError::KvStore { .. } + | TrustedServerError::ConfigStoreUnavailable { .. } | TrustedServerError::Prebid { .. } | TrustedServerError::Integration { .. } | TrustedServerError::Proxy { .. } @@ -499,6 +535,13 @@ mod tests { }, StatusCode::SERVICE_UNAVAILABLE, ), + ( + TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "config store unavailable".to_string(), + }, + StatusCode::SERVICE_UNAVAILABLE, + ), ( TrustedServerError::Auction { message: "auction failed".to_string(), diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index bdf46a84..4ad7c9b1 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -36,9 +36,12 @@ struct FastlyChunkRef { /// /// # Errors /// -/// Returns [`TrustedServerError::Configuration`] when the config blob is -/// missing, cannot be read, fails envelope verification, or fails Trusted -/// Server settings validation. +/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when the +/// config blob (or a referenced chunk) cannot be read — store unseeded, +/// transient backend, or a chunk key missing. Returns +/// [`TrustedServerError::Configuration`] (HTTP 500) when the read succeeds but +/// the blob fails envelope/chunk verification or Trusted Server settings +/// validation. pub fn get_settings_from_services( services: &RuntimeServices, ) -> Result> { @@ -66,9 +69,10 @@ pub fn default_config_key() -> String { /// /// # Errors /// -/// Returns [`TrustedServerError::Configuration`] when the config blob is -/// missing, cannot be read, fails envelope verification, or fails Trusted -/// Server settings validation. +/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when the +/// config blob (or a referenced chunk) cannot be read, and +/// [`TrustedServerError::Configuration`] (HTTP 500) when the read succeeds but +/// envelope/chunk verification or settings validation fails. pub fn get_settings_from_config_store( config_store: &dyn PlatformConfigStore, store_name: &StoreName, @@ -84,12 +88,14 @@ fn read_config_entry( store_name: &StoreName, key: &str, ) -> Result> { - let message = format!( - "failed to read Trusted Server app config key `{key}` from config store `{store_name}`" - ); config_store .get(store_name, key) - .change_context(TrustedServerError::Configuration { message }) + .change_context(TrustedServerError::ConfigStoreUnavailable { + store_name: store_name.to_string(), + message: format!( + "unavailable or not seeded (failed to read `{key}`) — run `ts config push`" + ), + }) } fn resolve_fastly_chunk_pointer( @@ -160,6 +166,7 @@ fn configuration_error(message: String) -> Result>` (absence as a value, not an + error) would let the runtime distinguish **unseeded** (`Ok(None)`) from + **transient** (`Err`) precisely instead of classifying by call site. It is the + store-convergence direction (edgezero's own `ConfigStore::get` shape) and + touches every impl + caller across request-signing and DataDome — tracked as a + separate, cross-cutting change, not this PR. +- Non-Fastly adapter (cloudflare/spin/axum) parity rides the EdgeZero adapter + stack.