use serde::{Deserialize, Serialize};
use super::mob::{WireHostRef, WireScopeDeniedDetail};
use crate::error::ErrorCode;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct WireHostUnavailableDetail {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<WireHostRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct WireStaleCursorDetail {
pub watermark: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct WireStaleFenceDetail {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actual: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WireMobErrorDetail {
ScopeDenied(WireScopeDeniedDetail),
HostUnavailable(WireHostUnavailableDetail),
StaleCursor(WireStaleCursorDetail),
StaleFence(WireStaleFenceDetail),
}
impl WireMobErrorDetail {
pub const fn code(&self) -> ErrorCode {
match self {
Self::ScopeDenied(_) => ErrorCode::ScopeDenied,
Self::HostUnavailable(_) => ErrorCode::HostUnavailable,
Self::StaleCursor(_) => ErrorCode::StaleCursor,
Self::StaleFence(_) => ErrorCode::StaleFence,
}
}
pub fn detail_value(&self) -> Result<serde_json::Value, serde_json::Error> {
match self {
Self::ScopeDenied(detail) => serde_json::to_value(detail),
Self::HostUnavailable(detail) => serde_json::to_value(detail),
Self::StaleCursor(detail) => serde_json::to_value(detail),
Self::StaleFence(detail) => serde_json::to_value(detail),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum WireConversionError {
#[error("invalid member-history page projection: {debug}")]
MemberHistoryPage { debug: String },
#[error("unknown wire transport variant: {debug}")]
Transport { debug: String },
#[error("unknown wire observation variant: {debug}")]
Observation { debug: String },
#[error("unknown wire continuity-mode variant: {debug}")]
Continuity { debug: String },
#[error("unknown wire response-modality variant: {debug}")]
ResponseModality { debug: String },
#[error("unknown wire adapter-status variant: {debug}")]
Status { debug: String },
#[error("unknown wire adapter-error-code variant: {debug}")]
ErrorCode { debug: String },
#[error("unknown wire config-rejection-reason variant: {debug}")]
ConfigRejectionReason { debug: String },
#[error("unknown wire transcript-source variant: {debug}")]
TranscriptSource { debug: String },
#[error("unknown wire assistant-block variant: {debug}")]
AssistantBlock { debug: String },
#[error("unknown wire provider variant: {debug}")]
Provider { debug: String },
#[error("invalid transcript rewrite message: {debug}")]
TranscriptMessage { debug: String },
#[error("transcript role is not host-mintable via rewrite ingress: {debug}")]
TranscriptRole { debug: String },
#[error("unknown wire degradation-reason variant: {debug}")]
DegradationReason { debug: String },
#[error("internal realtime user-content event has no public wire representation")]
InternalRealtimeUserContent,
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::wire::mob::WireControlScope;
#[test]
fn wire_mob_error_detail_code_pairing_and_bare_serialization() {
let scope = WireMobErrorDetail::ScopeDenied(WireScopeDeniedDetail {
required: WireControlScope::AdminHost,
presented: vec![WireControlScope::List],
});
let host = WireMobErrorDetail::HostUnavailable(WireHostUnavailableDetail {
host: Some(WireHostRef("host-peer-1".to_string())),
timeout_ms: Some(15_000),
});
let cursor = WireMobErrorDetail::StaleCursor(WireStaleCursorDetail {
watermark: 42,
generation: Some(3),
requested: None,
});
let fence = WireMobErrorDetail::StaleFence(WireStaleFenceDetail {
runtime_id: Some("worker#2".to_string()),
expected: Some(2),
actual: Some(1),
});
assert_eq!(scope.code(), ErrorCode::ScopeDenied);
assert_eq!(host.code(), ErrorCode::HostUnavailable);
assert_eq!(cursor.code(), ErrorCode::StaleCursor);
assert_eq!(fence.code(), ErrorCode::StaleFence);
assert_eq!(
scope.detail_value().expect("scope detail serializes"),
serde_json::json!({
"required": "admin_host",
"presented": ["list"],
})
);
let sparse = WireMobErrorDetail::HostUnavailable(WireHostUnavailableDetail {
host: None,
timeout_ms: None,
});
assert_eq!(
sparse.detail_value().expect("sparse detail serializes"),
serde_json::json!({})
);
assert_eq!(
cursor.detail_value().expect("cursor detail serializes"),
serde_json::json!({ "watermark": 42, "generation": 3 })
);
let host_detail = WireHostUnavailableDetail {
host: Some(WireHostRef("host-peer-1".to_string())),
timeout_ms: Some(15_000),
};
let round: WireHostUnavailableDetail =
serde_json::from_value(serde_json::to_value(&host_detail).expect("encode host detail"))
.expect("decode host detail");
assert_eq!(round, host_detail);
let cursor_detail = WireStaleCursorDetail {
watermark: 42,
generation: None,
requested: Some(41),
};
let round: WireStaleCursorDetail = serde_json::from_value(
serde_json::to_value(&cursor_detail).expect("encode cursor detail"),
)
.expect("decode cursor detail");
assert_eq!(round, cursor_detail);
let fence_detail = WireStaleFenceDetail {
runtime_id: None,
expected: None,
actual: None,
};
let round: WireStaleFenceDetail = serde_json::from_value(
serde_json::to_value(&fence_detail).expect("encode fence detail"),
)
.expect("decode fence detail");
assert_eq!(round, fence_detail);
assert!(
serde_json::from_value::<WireHostUnavailableDetail>(
serde_json::json!({ "host": "h", "surprise": true })
)
.is_err(),
"WireHostUnavailableDetail must reject unknown fields"
);
assert!(
serde_json::from_value::<WireStaleCursorDetail>(
serde_json::json!({ "watermark": 1, "surprise": true })
)
.is_err(),
"WireStaleCursorDetail must reject unknown fields"
);
assert!(
serde_json::from_value::<WireStaleFenceDetail>(serde_json::json!({ "surprise": 1 }))
.is_err(),
"WireStaleFenceDetail must reject unknown fields"
);
}
#[test]
fn wire_mob_error_detail_codes_agree_with_const_maps() {
let details = [
(
WireMobErrorDetail::ScopeDenied(WireScopeDeniedDetail {
required: WireControlScope::AdminHost,
presented: Vec::new(),
}),
-32025,
403,
45,
),
(
WireMobErrorDetail::HostUnavailable(WireHostUnavailableDetail {
host: None,
timeout_ms: None,
}),
-32026,
503,
46,
),
(
WireMobErrorDetail::StaleCursor(WireStaleCursorDetail {
watermark: 0,
generation: None,
requested: None,
}),
-32027,
410,
47,
),
(
WireMobErrorDetail::StaleFence(WireStaleFenceDetail {
runtime_id: None,
expected: None,
actual: None,
}),
-32028,
409,
48,
),
];
for (detail, jsonrpc, http, cli) in details {
let code = detail.code();
assert_eq!(code.jsonrpc_code(), jsonrpc, "{code:?} jsonrpc agreement");
assert_eq!(
ErrorCode::from_jsonrpc_code(jsonrpc),
Some(code),
"{code:?} jsonrpc round-trip"
);
assert_eq!(code.http_status(), http, "{code:?} http agreement");
assert_eq!(code.cli_exit_code(), cli, "{code:?} cli agreement");
}
}
}