Files
quicproquo/crates/quicprochat-rpc/src/error.rs
Christian Nennemann e4c5868b31 feat: add client auto-reconnect, heartbeat, and connection status UI
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
2026-03-21 19:14:06 +01:00

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