pcs-external 0.2.0

Ppoppo Chat System (PCS) External API client -- gRPC client for the External Developer Platform
Documentation
//! `PcsFailure` — classified PCS-side failure vocabulary.
//!
//! Mirrors `pas-external::pas_port::PasFailure` in shape: three variants
//! covering the entire space of post-classification outcomes the SDK
//! exposes. Adapters translate `tonic::Status` (or transport errors)
//! into one of these variants once at the boundary; downstream code is
//! gRPC-free.

use tonic::Code;

/// Classified PCS-side failure. Single source of truth for the
/// gRPC-status → cause mapping; only adapters produce these.
///
/// The split mirrors `PasFailure` for cross-crate familiarity:
///
/// - [`Self::Rejected`] — the call reached PCS and was rejected by
///   business logic. Caller's input or auth is bad; do not retry as-is.
/// - [`Self::ServerError`] — PCS reached a 5xx-equivalent state.
///   Retry-eligible upstream; the call may succeed on a later attempt.
/// - [`Self::Transport`] — could not reach PCS, or a malformed response
///   prevented decoding. Network-class fault.
#[non_exhaustive]
#[derive(Debug, Clone, thiserror::Error)]
pub enum PcsFailure {
    /// PCS returned a non-OK gRPC status the caller is expected to handle
    /// at the application level (`InvalidArgument`, `NotFound`,
    /// `PermissionDenied`, `Unauthenticated`, `ResourceExhausted`,
    /// `FailedPrecondition`, `AlreadyExists`, `OutOfRange`, `Aborted`).
    #[error("rejected: {code:?} {message}")]
    Rejected { code: Code, message: String },

    /// PCS returned a 5xx-class status (`Internal`, `Unknown`, `DataLoss`,
    /// `Unimplemented`, `DeadlineExceeded`). Retry-eligible — the SDK
    /// caller may re-issue the same request after backoff.
    #[error("server error: {code:?} {message}")]
    ServerError { code: Code, message: String },

    /// Could not reach PCS (DNS failure, TLS handshake error, connect
    /// refused, broken stream, body decode failure). Indistinguishable
    /// from a true network blip at the SDK layer.
    #[error("transport: {detail}")]
    Transport { detail: String },
}

/// Classify a `tonic::Status` into a [`PcsFailure`].
///
/// This is the inverse of `pas-external::pas_port` HTTP-status classifier
/// — sister implementations should stay visually similar so a reader of
/// either crate carries the same mental model.
///
/// The mapping is deliberately exhaustive over `tonic::Code`. Any future
/// `Code` variant added by tonic falls through the catch-all to
/// [`PcsFailure::Transport`] (fail-safe) and a comment block flags the
/// regression to be classified deliberately on the next pcs-external
/// release.
#[must_use]
pub fn classify_status(status: &tonic::Status) -> PcsFailure {
    let code = status.code();
    let message = status.message().to_string();
    match code {
        // Application-level rejections — caller's input/auth/state is bad.
        Code::InvalidArgument
        | Code::NotFound
        | Code::AlreadyExists
        | Code::PermissionDenied
        | Code::Unauthenticated
        | Code::ResourceExhausted
        | Code::FailedPrecondition
        | Code::OutOfRange
        | Code::Aborted
        | Code::Cancelled => PcsFailure::Rejected { code, message },

        // 5xx-equivalent — retry-eligible.
        Code::Internal | Code::Unknown | Code::DataLoss | Code::Unimplemented => {
            PcsFailure::ServerError { code, message }
        }

        // DeadlineExceeded sits between server-side overload and client-side
        // network slowness; classify as ServerError (retry) because the
        // most common cause is the upstream taking too long, not the pipe
        // being broken.
        Code::DeadlineExceeded => PcsFailure::ServerError { code, message },

        // Unavailable is the canonical "transient transport-class" code in
        // tonic — connect failure, server shutdown mid-stream, etc.
        Code::Unavailable => PcsFailure::Transport { detail: message },

        // Ok shouldn't reach the classifier, but if it does, treat as
        // transport because the caller already lost the response shape.
        Code::Ok => PcsFailure::Transport {
            detail: format!("classify_status called on Code::Ok: {message}"),
        },
    }
}

/// Wrap a transport-class error (tonic::transport::Error, body decode
/// failure, etc) into a [`PcsFailure::Transport`].
#[must_use]
pub fn classify_transport(detail: impl Into<String>) -> PcsFailure {
    PcsFailure::Transport { detail: detail.into() }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    fn st(code: Code) -> tonic::Status {
        tonic::Status::new(code, "test")
    }

    #[test]
    fn rejected_arms() {
        for code in [
            Code::InvalidArgument,
            Code::NotFound,
            Code::AlreadyExists,
            Code::PermissionDenied,
            Code::Unauthenticated,
            Code::ResourceExhausted,
            Code::FailedPrecondition,
            Code::OutOfRange,
            Code::Aborted,
            Code::Cancelled,
        ] {
            assert!(
                matches!(classify_status(&st(code)), PcsFailure::Rejected { .. }),
                "expected Rejected for {code:?}"
            );
        }
    }

    #[test]
    fn server_error_arms() {
        for code in [
            Code::Internal,
            Code::Unknown,
            Code::DataLoss,
            Code::Unimplemented,
            Code::DeadlineExceeded,
        ] {
            assert!(
                matches!(classify_status(&st(code)), PcsFailure::ServerError { .. }),
                "expected ServerError for {code:?}"
            );
        }
    }

    #[test]
    fn transport_arms() {
        assert!(matches!(
            classify_status(&st(Code::Unavailable)),
            PcsFailure::Transport { .. }
        ));
        // Code::Ok is degenerate — classifier shouldn't be called with it,
        // but if it is the SDK degrades gracefully to Transport.
        assert!(matches!(
            classify_status(&st(Code::Ok)),
            PcsFailure::Transport { .. }
        ));
    }

    #[test]
    fn classify_transport_helper() {
        let f = classify_transport("connect refused");
        assert!(matches!(f, PcsFailure::Transport { detail } if detail == "connect refused"));
    }
}