From cd3dfc0268210736f059cb7501173ec2a36ad7ef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 04:55:05 +0200 Subject: [PATCH] eureka-state: an encode failure is not retryable, and a non-UTF-8 endpoint is pinned A request that fails to encode is a defect in this client, not a transport failure, so it is ClientError::Unencodable, typed Unencodable at the tool, instead of the retryable Unavailable. A test reports a non-UTF-8 HUGINN_ENDPOINT as such. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/lib.rs | 12 ++++++++---- crates/eureka-state/src/main.rs | 5 +++++ crates/eureka-state/tests/client.rs | 4 ++-- crates/eureka-state/tests/mcp.rs | 13 +++++++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/eureka-state/src/lib.rs b/crates/eureka-state/src/lib.rs index 1ff2bb7..b6208e8 100644 --- a/crates/eureka-state/src/lib.rs +++ b/crates/eureka-state/src/lib.rs @@ -45,13 +45,15 @@ pub const MAX_REQUEST_BYTES: usize = 65_507 - 36 - "schema".len(); /// the only one worth retrying: the daemon could not be reached, went quiet /// past the call's deadline, or answered out of protocol. `Rejected` is the /// daemon refusing the envelope itself, and `TooLarge` is the client refusing -/// a request that no single send can carry, before sending it; a retry of the -/// same call cannot change either. +/// a request that no single send can carry, before sending it; `Unencodable` is +/// the request failing to encode at all, a defect in this client. A retry of the +/// same call cannot change any of those three. #[derive(Debug)] pub enum ClientError { Unavailable { endpoint: SocketAddr, detail: String }, Rejected { endpoint: SocketAddr, code: String, detail: String }, TooLarge { bytes: usize, limit: usize }, + Unencodable { detail: String }, } impl fmt::Display for ClientError { @@ -66,6 +68,7 @@ impl fmt::Display for ClientError { Self::TooLarge { bytes, limit } => { write!(f, "the request encodes to {bytes} bytes and one send carries at most {limit}") } + Self::Unencodable { detail } => write!(f, "the request could not be encoded: {detail}"), } } } @@ -109,9 +112,10 @@ impl HuginnClient { let deadline = Instant::now() + self.timeout; let unavailable = |error: anyhow::Error| ClientError::Unavailable { endpoint: self.endpoint, detail: format!("{error:#}") }; - let message = encode_request(MESSAGE_ID, &request, None).map_err(unavailable)?; + let unencodable = |error: anyhow::Error| ClientError::Unencodable { detail: format!("{error:#}") }; + let message = encode_request(MESSAGE_ID, &request, None).map_err(unencodable)?; let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) - .map_err(unavailable)? + .map_err(unencodable)? .len(); if bytes > MAX_REQUEST_BYTES { return Err(ClientError::TooLarge { bytes, limit: MAX_REQUEST_BYTES }); diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs index d5a8bc5..d368a23 100644 --- a/crates/eureka-state/src/main.rs +++ b/crates/eureka-state/src/main.rs @@ -108,6 +108,7 @@ impl Trouble { Self::Client(ClientError::TooLarge { bytes, limit }) => { json!({ "error": "TooLarge", "bytes": bytes, "limit": limit }) } + Self::Client(ClientError::Unencodable { detail }) => json!({ "error": "Unencodable", "detail": detail }), Self::Internal(detail) => json!({ "error": "Internal", "detail": detail }), } } @@ -317,6 +318,10 @@ mod tests { Trouble::Client(ClientError::TooLarge { bytes: 70_000, limit: 65_000 }).body(), json!({ "error": "TooLarge", "bytes": 70_000, "limit": 65_000 }) ); + assert_eq!( + Trouble::Client(ClientError::Unencodable { detail: "no".into() }).body(), + json!({ "error": "Unencodable", "detail": "no" }) + ); assert_eq!(Trouble::Misconfigured("bad".into()).body(), json!({ "error": "Misconfigured", "detail": "bad" })); } diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs index 6a054ea..3f34512 100644 --- a/crates/eureka-state/tests/client.rs +++ b/crates/eureka-state/tests/client.rs @@ -128,14 +128,14 @@ fn client(addr: SocketAddr) -> HuginnClient { fn unavailable(error: ClientError) -> (SocketAddr, String) { match error { ClientError::Unavailable { endpoint, detail } => (endpoint, detail), - other @ (ClientError::Rejected { .. } | ClientError::TooLarge { .. }) => panic!("expected Unavailable, got {other}"), + other @ (ClientError::Rejected { .. } | ClientError::TooLarge { .. } | ClientError::Unencodable { .. }) => panic!("expected Unavailable, got {other}"), } } fn rejected(error: ClientError) -> (SocketAddr, String, String) { match error { ClientError::Rejected { endpoint, code, detail } => (endpoint, code, detail), - other @ (ClientError::Unavailable { .. } | ClientError::TooLarge { .. }) => panic!("expected Rejected, got {other}"), + other @ (ClientError::Unavailable { .. } | ClientError::TooLarge { .. } | ClientError::Unencodable { .. }) => panic!("expected Rejected, got {other}"), } } diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index 3a51eb0..27ee9fd 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -495,6 +495,19 @@ fn a_non_utf8_instance_is_reported_as_such() { assert_eq!(whoami["instance"], Value::Null); } +/// Likewise an endpoint that is not UTF-8: reported as that, not as unset. +#[test] +fn a_non_utf8_endpoint_is_reported_as_such() { + let mut mcp = Mcp::spawn(|command| { + command.env("EUREKA_INSTANCE", INSTANCE).env("HUGINN_ENDPOINT", non_utf8()); + }); + let (error, whoami) = mcp.call("whoami", json!({})); + assert!(!error); + let detail = whoami["error"]["detail"].as_str().unwrap(); + assert!(detail.contains("HUGINN_ENDPOINT is not valid UTF-8"), "{whoami}"); + assert_eq!(whoami["endpoint"], Value::Null); +} + #[cfg(unix)] fn non_utf8() -> std::ffi::OsString { std::os::unix::ffi::OsStringExt::from_vec(vec![b'e', 0xff])