RPC layer (quicprochat-rpc): - RpcClient now uses tokio::sync::Mutex<Connection> for safe reconnection - Auto-reconnect with exponential backoff + jitter on retriable errors - QUIC-level keepalive via quinn TransportConfig - subscribe_push() returns Option<PushFrame> with None sentinel on break - RpcError::is_retriable() classifies transient vs permanent errors - ConnectionState enum (Connected/Reconnecting/Disconnected) with Display - Configurable max_retries, base_delay, max_backoff, keepalive_secs SDK layer (quicprochat-sdk): - QpqClient wraps RpcClient in Arc for safe heartbeat task sharing - start_heartbeat() spawns background task checking connection every 30s - connection_state() exposes RPC-layer state to UI - Reconnecting event added to ClientEvent enum - disconnect() aborts heartbeat before closing connection Client UI (quicprochat-client): - TUI status bar shows Connected/Reconnecting.../Offline with color - TUI handles Reconnecting event with attempt count display - REPL event listener prints connection state changes - REPL /status shows connection state instead of bool - Both TUI and REPL call start_heartbeat() on startup
164 lines
4.8 KiB
Rust
164 lines
4.8 KiB
Rust
//! RPC error types.
|
|
|
|
/// Status codes for RPC responses.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[repr(u8)]
|
|
pub enum RpcStatus {
|
|
/// Request succeeded.
|
|
Ok = 0,
|
|
/// Client sent a malformed request.
|
|
BadRequest = 1,
|
|
/// Authentication required or token invalid.
|
|
Unauthorized = 2,
|
|
/// Caller lacks permission for this operation.
|
|
Forbidden = 3,
|
|
/// Requested resource not found.
|
|
NotFound = 4,
|
|
/// Rate limit exceeded.
|
|
RateLimited = 5,
|
|
/// Request deadline exceeded (server-side timeout).
|
|
DeadlineExceeded = 8,
|
|
/// Server is shutting down (draining).
|
|
Unavailable = 9,
|
|
/// Internal server error.
|
|
Internal = 10,
|
|
/// Method not recognized.
|
|
UnknownMethod = 11,
|
|
}
|
|
|
|
impl RpcStatus {
|
|
/// Decode a status byte. Returns `None` for unknown values.
|
|
pub fn from_u8(v: u8) -> Option<Self> {
|
|
match v {
|
|
0 => Some(Self::Ok),
|
|
1 => Some(Self::BadRequest),
|
|
2 => Some(Self::Unauthorized),
|
|
3 => Some(Self::Forbidden),
|
|
4 => Some(Self::NotFound),
|
|
5 => Some(Self::RateLimited),
|
|
8 => Some(Self::DeadlineExceeded),
|
|
9 => Some(Self::Unavailable),
|
|
10 => Some(Self::Internal),
|
|
11 => Some(Self::UnknownMethod),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Errors that can occur in the RPC layer.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum RpcError {
|
|
#[error("connection error: {0}")]
|
|
Connection(String),
|
|
|
|
#[error("encoding error: {0}")]
|
|
Encode(String),
|
|
|
|
#[error("decoding error: {0}")]
|
|
Decode(String),
|
|
|
|
#[error("server returned error status {status:?}: {message}")]
|
|
Server {
|
|
status: RpcStatus,
|
|
message: String,
|
|
},
|
|
|
|
#[error("request timed out")]
|
|
Timeout,
|
|
|
|
#[error("stream closed unexpectedly")]
|
|
StreamClosed,
|
|
|
|
#[error("payload too large: {size} bytes (max {max})")]
|
|
PayloadTooLarge { size: usize, max: usize },
|
|
}
|
|
|
|
impl RpcError {
|
|
/// Returns `true` if this error is transient and the operation may succeed
|
|
/// on retry (e.g. connection reset, timeout, server 5xx). Returns `false`
|
|
/// for permanent failures (auth, bad request, payload limits).
|
|
pub fn is_retriable(&self) -> bool {
|
|
match self {
|
|
Self::Connection(_) | Self::Timeout | Self::StreamClosed => true,
|
|
Self::Server { status, .. } => matches!(
|
|
status,
|
|
RpcStatus::Unavailable
|
|
| RpcStatus::DeadlineExceeded
|
|
| RpcStatus::Internal
|
|
| RpcStatus::RateLimited
|
|
),
|
|
Self::Encode(_) | Self::Decode(_) | Self::PayloadTooLarge { .. } => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn retriable_errors() {
|
|
assert!(RpcError::Connection("reset".into()).is_retriable());
|
|
assert!(RpcError::Timeout.is_retriable());
|
|
assert!(RpcError::StreamClosed.is_retriable());
|
|
assert!(RpcError::Server {
|
|
status: RpcStatus::Unavailable,
|
|
message: String::new(),
|
|
}
|
|
.is_retriable());
|
|
assert!(RpcError::Server {
|
|
status: RpcStatus::Internal,
|
|
message: String::new(),
|
|
}
|
|
.is_retriable());
|
|
assert!(RpcError::Server {
|
|
status: RpcStatus::DeadlineExceeded,
|
|
message: String::new(),
|
|
}
|
|
.is_retriable());
|
|
assert!(RpcError::Server {
|
|
status: RpcStatus::RateLimited,
|
|
message: String::new(),
|
|
}
|
|
.is_retriable());
|
|
}
|
|
|
|
#[test]
|
|
fn non_retriable_errors() {
|
|
assert!(!RpcError::Encode("bad".into()).is_retriable());
|
|
assert!(!RpcError::Decode("bad".into()).is_retriable());
|
|
assert!(!RpcError::PayloadTooLarge { size: 100, max: 50 }.is_retriable());
|
|
assert!(!RpcError::Server {
|
|
status: RpcStatus::Unauthorized,
|
|
message: String::new(),
|
|
}
|
|
.is_retriable());
|
|
assert!(!RpcError::Server {
|
|
status: RpcStatus::BadRequest,
|
|
message: String::new(),
|
|
}
|
|
.is_retriable());
|
|
assert!(!RpcError::Server {
|
|
status: RpcStatus::Forbidden,
|
|
message: String::new(),
|
|
}
|
|
.is_retriable());
|
|
assert!(!RpcError::Server {
|
|
status: RpcStatus::NotFound,
|
|
message: String::new(),
|
|
}
|
|
.is_retriable());
|
|
}
|
|
|
|
#[test]
|
|
fn connection_state_display() {
|
|
use crate::client::ConnectionState;
|
|
assert_eq!(ConnectionState::Connected.to_string(), "Connected");
|
|
assert_eq!(ConnectionState::Disconnected.to_string(), "Disconnected");
|
|
assert_eq!(
|
|
ConnectionState::Reconnecting { attempt: 2 }.to_string(),
|
|
"Reconnecting (attempt 2)"
|
|
);
|
|
}
|
|
}
|