pcs-external 0.3.0

Ppoppo Chat System (PCS) External API client -- gRPC client for the External Developer Platform
Documentation
use ppoppo_sdk_core::token_cache::TokenCacheError;
use tonic::Code;

/// All errors returned by [`crate::PcsExternalClient`] methods.
///
/// The three `Grpc*` variants mirror `PasFailure` semantics — `Rejected`
/// means the call reached PCS and was turned down at the application layer
/// (don't retry as-is), `ServerError` is a 5xx-class state (retry-eligible),
/// `Transport` means the call never reached PCS.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// Connection, TLS, or network-level failure.
    #[error("transport error: {0}")]
    Transport(String),

    /// Bearer token acquisition failed before the gRPC call could be made.
    #[error("token refresh failed: {0}")]
    TokenRefresh(#[from] TokenCacheError),

    /// The configured path prefix is malformed — caught at build time.
    #[error("invalid path prefix '{prefix}': {reason}")]
    InvalidPathPrefix { prefix: String, reason: String },

    /// PCS returned a non-OK gRPC status at the application layer
    /// (`InvalidArgument`, `NotFound`, `PermissionDenied`, `Unauthenticated`,
    /// `ResourceExhausted`, `FailedPrecondition`, `AlreadyExists`,
    /// `OutOfRange`, `Aborted`, `Cancelled`).
    ///
    /// Caller's input, auth, or state is bad — do not retry as-is.
    #[error("rejected by PCS: {code:?} {message}")]
    Rejected { code: Code, message: String },

    /// PCS returned a 5xx-class status (`Internal`, `Unknown`, `DataLoss`,
    /// `Unimplemented`, `DeadlineExceeded`). Retry-eligible.
    #[error("PCS server error: {code:?} {message}")]
    ServerError { code: Code, message: String },

    /// A required proto field was absent or could not be mapped to a domain type.
    #[error("unexpected proto response: {0}")]
    ProtoMismatch(String),
}

/// Classify a `tonic::Status` into an [`Error`] variant.
#[must_use]
pub(crate) fn classify_status(status: &tonic::Status) -> Error {
    let code = status.code();
    let message = status.message().to_string();
    match code {
        Code::InvalidArgument
        | Code::NotFound
        | Code::AlreadyExists
        | Code::PermissionDenied
        | Code::Unauthenticated
        | Code::ResourceExhausted
        | Code::FailedPrecondition
        | Code::OutOfRange
        | Code::Aborted
        | Code::Cancelled => Error::Rejected { code, message },

        Code::Internal | Code::Unknown | Code::DataLoss | Code::Unimplemented => {
            Error::ServerError { code, message }
        }

        // DeadlineExceeded: most often upstream overload → retry-eligible.
        Code::DeadlineExceeded => Error::ServerError { code, message },

        // Unavailable is the canonical transient-transport code.
        Code::Unavailable => Error::Transport(message),

        // Ok shouldn't reach the classifier; degrade gracefully.
        Code::Ok => Error::Transport(format!("classify_status called on Code::Ok: {message}")),
    }
}