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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 04:55:05 +02:00
commit cd3dfc0268
4 changed files with 28 additions and 6 deletions

View file

@ -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 });

View file

@ -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" }));
}

View file

@ -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}"),
}
}

View file

@ -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])