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
//! `retry-after` metadata extraction for tonic gRPC responses.
//!
//! Counterpart producer: `chat-api::rate_limit::resource_exhausted_with_retry_after`
//! at `services/chat/crates/chat-api/src/rate_limit/status.rs`. Both adhere
//! to RFC 2026-05-12 D5: lowercase ASCII key `retry-after`, integer seconds.
//!
//! ## Usage (SDK consumer)
//!
//! ```ignore
//! use ppoppo_sdk_core::retry::retry_after;
//!
//! match client.send_alert(&tmpl, &recipients, None).await {
//!     Err(pcs_external::Error::RateLimited { retry_after, .. }) => {
//!         tokio::time::sleep(retry_after).await;
//!         // retry exactly once at the substrate-authoritative reset instant
//!     }
//!     other => other,
//! }
//! ```
//!
//! The helper is `tonic-interceptor`-gated because it takes
//! `&tonic::Status`. The gate is shared with `interceptor.rs` — both are
//! tonic-side SDK primitives; splitting the feature flag would be
//! over-decomposed for a 10-line helper.

use std::time::Duration;

/// Extract a `retry-after` hint (integer seconds) from a `tonic::Status`'s
/// metadata.
///
/// Returns `None` when the metadata is absent, the value is non-ASCII, or
/// parsing as `u64` fails. Negative strings and non-digit content both
/// return `None` — `u64::from_str` rejects them.
///
/// `Some(Duration::ZERO)` is a legitimate output: the substrate may
/// surface `retry-after: 0` when the reset instant has already passed by
/// the time the response builds; the consumer MAY retry immediately.
///
/// # Wire contract (RFC 2026-05-12 D5)
///
/// - Key: lowercase `retry-after` (gRPC ASCII metadata convention).
/// - Value: integer seconds, ASCII digits only (`"0"`..=`"86400"` etc.).
/// - HTTP-date form (`"Wed, 21 Oct 2026 07:28:00 GMT"`) is NOT supported on
///   the gRPC face. RFC 2026-05-12 D4 locks the wire shape to integer
///   seconds for both HTTP and gRPC; clock-skew-free + UI-countdown-friendly.
#[must_use]
pub fn retry_after(status: &tonic::Status) -> Option<Duration> {
    // Header absent is the common, legitimate case — the substrate simply
    // attached no hint. Silent `None`, no log noise.
    let raw = status.metadata().get("retry-after")?;

    // Present but unparseable (non-ASCII, non-digit, negative, overflow) is a
    // producer-contract violation (RFC 2026-05-12 D5): the consumer loses the
    // precise reset instant and falls back to a default backoff blind. Surface
    // it — a fail-silent here would mask a real wire drift.
    match raw.to_str().ok().and_then(|s| s.parse::<u64>().ok()) {
        Some(secs) => Some(Duration::from_secs(secs)),
        None => {
            tracing::warn!(
                retry_after = ?raw,
                "gRPC `retry-after` metadata present but unparseable; \
                 falling back to default backoff"
            );
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tonic::metadata::MetadataValue;

    fn status_with_retry_after(value: &str) -> tonic::Status {
        let mut status = tonic::Status::resource_exhausted("rate limited");
        if let Ok(v) = MetadataValue::try_from(value) {
            status.metadata_mut().insert("retry-after", v);
        }
        status
    }

    #[test]
    fn present_value_60_returns_duration() {
        let status = status_with_retry_after("60");
        assert_eq!(retry_after(&status), Some(Duration::from_secs(60)));
    }

    #[test]
    fn missing_metadata_returns_none() {
        let status = tonic::Status::resource_exhausted("no metadata");
        assert_eq!(retry_after(&status), None);
    }

    #[test]
    fn malformed_ascii_returns_none() {
        let status = status_with_retry_after("abc");
        assert_eq!(retry_after(&status), None);
    }

    #[test]
    fn negative_string_returns_none() {
        // u64::from_str rejects leading '-'.
        let status = status_with_retry_after("-5");
        assert_eq!(retry_after(&status), None);
    }

    #[test]
    fn zero_returns_duration_zero() {
        // Substrate may legitimately surface 0 if the reset instant has
        // already passed; consumer may retry immediately.
        let status = status_with_retry_after("0");
        assert_eq!(retry_after(&status), Some(Duration::ZERO));
    }

    #[test]
    fn large_value_86400_returns_duration() {
        // Daily-quota substrate path (24h reset).
        let status = status_with_retry_after("86400");
        assert_eq!(retry_after(&status), Some(Duration::from_secs(86_400)));
    }
}