ppoppo-sdk-core 0.4.1

Internal shared primitives for the Ppoppo SDK family (pas-external, pas-plims, pcs-external) — verifier port, audit trait, session liveness port, OIDC discovery, perimeter Bearer-auth Layer kit, identity types. Not a stable public API; do not depend on this crate directly. Consume the SDK crates that re-export from it (e.g. `pas-external`).
Documentation
//! Shared error type + `tonic::Status` classifier for the Ppoppo gRPC SDK
//! family (`pas-plims`, `pcs-external`).
//!
//! Both client SDKs re-export [`Error`] as their public error type and route
//! every RPC failure through [`classify_status`], so the family's retry
//! taxonomy — which `tonic::Code` is retryable ([`Error::ServerError`]) vs
//! terminal ([`Error::Rejected`]) vs rate-limited ([`Error::RateLimited`]) —
//! lives in exactly one place, with one test suite. Service-specific detail
//! (which server rejected, why) travels in the `message` field, never in the
//! variant set.

use std::time::Duration;

use tonic::Code;

use crate::retry::retry_after;
use crate::token_cache::TokenCacheError;

/// All errors returned by the Ppoppo gRPC client SDKs.
///
/// - [`Error::Rejected`] — the call reached the server and was turned down at
///   the application layer (don't retry as-is).
/// - [`Error::ServerError`] — a 5xx-class state (retry-eligible).
/// - [`Error::Transport`] — the call never reached the server.
/// - [`Error::RateLimited`] — the substrate-authoritative rate-limit denial
///   (`ResourceExhausted`), carrying a `retry_after` hint for precise retry
///   scheduling.
#[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 connect time.
    #[error("invalid path prefix '{prefix}': {reason}")]
    InvalidPathPrefix { prefix: String, reason: String },

    /// The server returned a non-OK gRPC status at the application layer
    /// (`InvalidArgument`, `NotFound`, `PermissionDenied`, `Unauthenticated`,
    /// `FailedPrecondition`, `AlreadyExists`, `OutOfRange`, `Aborted`,
    /// `Cancelled`).
    ///
    /// Caller's input, auth, or state is bad — do not retry as-is.
    /// `ResourceExhausted` is **not** routed here; it surfaces as
    /// [`Error::RateLimited`] which carries the `retry_after` hint.
    #[error("rejected: {code:?} {message}")]
    Rejected { code: Code, message: String },

    /// The rate-limit substrate denied the call (`ResourceExhausted`).
    ///
    /// `retry_after` carries the substrate-authoritative reset duration when
    /// the server attached `retry-after` metadata; `None` means the substrate
    /// failed to surface a hint and callers should fall back to a default
    /// backoff. Consumers SHOULD wait at least `retry_after` before retrying —
    /// retrying earlier just trips the same bucket again (the reset instant has
    /// not passed), burning attempts without making progress.
    #[error("rate limited: {message}")]
    RateLimited {
        message: String,
        retry_after: Option<Duration>,
    },

    /// The server returned a 5xx-class status (`Internal`, `Unknown`,
    /// `DataLoss`, `Unimplemented`, `DeadlineExceeded`). Retry-eligible.
    #[error("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),
}

impl Error {
    /// Whether retrying the same call *as-is* may succeed.
    ///
    /// Mirrors the house `is_retryable` idiom (cf.
    /// `ppoppo_error::PortErrorKind::is_retryable`): transient transport loss,
    /// 5xx-class server errors, and rate-limit denials are retryable — the
    /// last only after honoring the `retry_after` hint (retrying earlier trips
    /// the same bucket). Application-layer rejections, a dead credential, a
    /// malformed prefix, and proto-shape mismatches are terminal: retrying
    /// reproduces the same failure.
    ///
    /// Note `ServerError` carries `tonic::Code::Unimplemented` (see
    /// [`classify_status`]) into the retryable set: a rolling deploy can
    /// transiently return it before the method is wired on every replica.
    /// A *permanently* unimplemented method still fails after the retry
    /// budget; a mid-deploy one recovers — this is an intentional bias for
    /// the GKE rolling-update window, not an oversight.
    ///
    /// The match is exhaustive (no wildcard): a future `#[non_exhaustive]`
    /// variant forces a compile error here rather than silently defaulting to
    /// "not retryable", keeping the family retry taxonomy drift-proof.
    #[must_use]
    pub fn is_retryable(&self) -> bool {
        match self {
            Self::Transport(_) | Self::ServerError { .. } | Self::RateLimited { .. } => true,
            Self::TokenRefresh(_)
            | Self::InvalidPathPrefix { .. }
            | Self::Rejected { .. }
            | Self::ProtoMismatch(_) => false,
        }
    }
}

/// Classify a `tonic::Status` into an [`Error`] variant.
///
/// `ResourceExhausted` is split out into [`Error::RateLimited`], which extracts
/// the `retry-after` metadata via [`crate::retry::retry_after`]. All other
/// application-layer rejection codes collapse to [`Error::Rejected`]; 5xx-class
/// codes to [`Error::ServerError`]; `Unavailable` to [`Error::Transport`].
#[must_use]
pub fn classify_status(status: &tonic::Status) -> Error {
    let code = status.code();
    let message = status.message().to_string();
    match code {
        Code::ResourceExhausted => Error::RateLimited {
            message,
            retry_after: retry_after(status),
        },

        Code::InvalidArgument
        | Code::NotFound
        | Code::AlreadyExists
        | Code::PermissionDenied
        | Code::Unauthenticated
        | 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}")),
    }
}

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

    #[test]
    fn resource_exhausted_with_metadata_routes_to_rate_limited() {
        let mut status = Status::resource_exhausted("Rate limit exceeded");
        status
            .metadata_mut()
            .insert("retry-after", MetadataValue::from_static("60"));
        match classify_status(&status) {
            Error::RateLimited { message, retry_after } => {
                assert_eq!(message, "Rate limit exceeded");
                assert_eq!(retry_after, Some(Duration::from_secs(60)));
            }
            other => panic!("expected RateLimited, got {other:?}"),
        }
    }

    #[test]
    fn resource_exhausted_without_metadata_routes_to_rate_limited_none() {
        // Substrate failure-open: header absent. The SDK still routes to
        // RateLimited so callers pattern-match by semantic, not by code.
        let status = Status::resource_exhausted("Rate limit exceeded");
        match classify_status(&status) {
            Error::RateLimited { retry_after, .. } => assert_eq!(retry_after, None),
            other => panic!("expected RateLimited, got {other:?}"),
        }
    }

    #[test]
    fn invalid_argument_routes_to_rejected() {
        let status = Status::invalid_argument("bad template");
        match classify_status(&status) {
            Error::Rejected { code, message } => {
                assert_eq!(code, Code::InvalidArgument);
                assert_eq!(message, "bad template");
            }
            other => panic!("expected Rejected, got {other:?}"),
        }
    }

    #[test]
    fn permission_denied_routes_to_rejected() {
        // A perimeter that rejects non-app-credential callers with
        // PermissionDenied must surface as a non-retryable Rejected.
        let status = Status::permission_denied("ops face requires an application credential");
        match classify_status(&status) {
            Error::Rejected { code, .. } => assert_eq!(code, Code::PermissionDenied),
            other => panic!("expected Rejected, got {other:?}"),
        }
    }

    #[test]
    fn internal_routes_to_server_error() {
        let status = Status::internal("boom");
        match classify_status(&status) {
            Error::ServerError { code, .. } => assert_eq!(code, Code::Internal),
            other => panic!("expected ServerError, got {other:?}"),
        }
    }

    #[test]
    fn unimplemented_routes_to_server_error() {
        // A not-yet-wired RPC returns Unimplemented; classify as a
        // retry-eligible ServerError.
        let status = Status::unimplemented("not yet wired");
        match classify_status(&status) {
            Error::ServerError { code, .. } => assert_eq!(code, Code::Unimplemented),
            other => panic!("expected ServerError, got {other:?}"),
        }
    }

    #[test]
    fn deadline_exceeded_routes_to_server_error() {
        let status = Status::deadline_exceeded("slow upstream");
        match classify_status(&status) {
            Error::ServerError { code, .. } => assert_eq!(code, Code::DeadlineExceeded),
            other => panic!("expected ServerError, got {other:?}"),
        }
    }

    #[test]
    fn unavailable_routes_to_transport() {
        let status = Status::unavailable("conn refused");
        match classify_status(&status) {
            Error::Transport(msg) => assert_eq!(msg, "conn refused"),
            other => panic!("expected Transport, got {other:?}"),
        }
    }

    #[test]
    fn ok_degrades_to_transport() {
        // Ok should never reach the classifier; if it does, degrade rather
        // than misclassify as a success-shaped error.
        let status = Status::ok("");
        match classify_status(&status) {
            Error::Transport(_) => {}
            other => panic!("expected Transport, got {other:?}"),
        }
    }

    #[test]
    fn is_retryable_partitions_the_variant_set() {
        // Retryable: transient transport, 5xx server, rate-limit (after backoff).
        assert!(Error::Transport("conn reset".to_owned()).is_retryable());
        assert!(
            Error::ServerError { code: Code::Internal, message: String::new() }.is_retryable()
        );
        assert!(
            Error::RateLimited {
                message: String::new(),
                retry_after: Some(Duration::from_secs(1)),
            }
            .is_retryable()
        );
        // Terminal: dead credential, bad prefix, app rejection, proto drift.
        assert!(!Error::TokenRefresh(TokenCacheError::Fetch("no token".to_owned())).is_retryable());
        assert!(
            !Error::InvalidPathPrefix { prefix: "/x".to_owned(), reason: String::new() }
                .is_retryable()
        );
        assert!(
            !Error::Rejected { code: Code::PermissionDenied, message: String::new() }
                .is_retryable()
        );
        assert!(!Error::ProtoMismatch("missing field".to_owned()).is_retryable());
    }
}