steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! Kind-aware retry strategy for `reqwest-middleware`.
//!
//! Wraps `reqwest_retry::DefaultRetryableStrategy` and short-circuits to
//! "do not retry" when the active endpoint's [`EndpointKind`] is one that
//! Steam treats as security-sensitive:
//!
//! - [`EndpointKind::Recovery`]: account recovery wizard on
//!   `help.steampowered.com`. Repeated wrong codes lock the account.
//! - [`EndpointKind::Auth`]: login / 2FA / mobile confirmation. Steam
//!   IP-bans aggressive retries here.
//!
//! All other kinds keep the default behaviour: retry 5XX, 408, 429, and
//! transient network errors.

use reqwest::Response;
use reqwest_middleware::Error;
use reqwest_retry::{DefaultRetryableStrategy, Retryable, RetryableStrategy};

use crate::endpoint::{current_endpoint, EndpointKind};

/// Retry strategy that consults the active endpoint via task-local
/// [`crate::endpoint::CURRENT_ENDPOINT`].
pub struct KindAwareRetryStrategy;

impl RetryableStrategy for KindAwareRetryStrategy {
    fn handle(&self, res: &Result<Response, Error>) -> Option<Retryable> {
        if let Some(ep) = current_endpoint() {
            match ep.kind {
                EndpointKind::Recovery | EndpointKind::Auth => {
                    if let Err(e) = res {
                        tracing::warn!(
                            steam.endpoint.kind = %ep.kind,
                            steam.endpoint.path = %ep.path,
                            error = %e,
                            "skipping auto-retry for security-sensitive endpoint",
                        );
                    } else if let Ok(r) = res {
                        let s = r.status();
                        if s.is_server_error() || s == reqwest::StatusCode::TOO_MANY_REQUESTS {
                            tracing::warn!(
                                steam.endpoint.kind = %ep.kind,
                                steam.endpoint.path = %ep.path,
                                status = %s,
                                "skipping auto-retry for security-sensitive endpoint",
                            );
                        }
                    }
                    return None;
                }
                EndpointKind::Read | EndpointKind::Write | EndpointKind::Upload => {
                    // Fall through to default behaviour.
                }
            }
        }
        DefaultRetryableStrategy.handle(res)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::endpoint::{EndpointInfo, Host, HttpMethod, CURRENT_ENDPOINT};

    fn make_500_response() -> Response {
        let raw = http::Response::builder().status(500).body("").expect("valid http response");
        Response::from(raw)
    }

    fn make_200_response() -> Response {
        let raw = http::Response::builder().status(200).body("").expect("valid http response");
        Response::from(raw)
    }

    fn ep(kind: EndpointKind) -> EndpointInfo {
        EndpointInfo {
            name: "test", module: "test", method: HttpMethod::Get,
            host: Host::Help, path: "/test", kind,
        }
    }

    #[tokio::test]
    async fn recovery_endpoint_skips_retry_on_5xx() {
        static EP: std::sync::OnceLock<EndpointInfo> = std::sync::OnceLock::new();
        let info = EP.get_or_init(|| ep(EndpointKind::Recovery));

        let decision = CURRENT_ENDPOINT
            .scope(info, async {
                KindAwareRetryStrategy.handle(&Ok(make_500_response()))
            })
            .await;

        assert!(decision.is_none(), "Recovery must not retry on 5xx");
    }

    #[tokio::test]
    async fn auth_endpoint_skips_retry_on_5xx() {
        static EP: std::sync::OnceLock<EndpointInfo> = std::sync::OnceLock::new();
        let info = EP.get_or_init(|| ep(EndpointKind::Auth));

        let decision = CURRENT_ENDPOINT
            .scope(info, async {
                KindAwareRetryStrategy.handle(&Ok(make_500_response()))
            })
            .await;

        assert!(decision.is_none(), "Auth must not retry on 5xx");
    }

    #[tokio::test]
    async fn read_endpoint_uses_default_retry_on_5xx() {
        static EP: std::sync::OnceLock<EndpointInfo> = std::sync::OnceLock::new();
        let info = EP.get_or_init(|| ep(EndpointKind::Read));

        let decision = CURRENT_ENDPOINT
            .scope(info, async {
                KindAwareRetryStrategy.handle(&Ok(make_500_response()))
            })
            .await;

        assert!(matches!(decision, Some(Retryable::Transient)), "Read on 5xx should retry");
    }

    #[tokio::test]
    async fn read_endpoint_no_retry_on_200() {
        static EP: std::sync::OnceLock<EndpointInfo> = std::sync::OnceLock::new();
        let info = EP.get_or_init(|| ep(EndpointKind::Read));

        let decision = CURRENT_ENDPOINT
            .scope(info, async {
                KindAwareRetryStrategy.handle(&Ok(make_200_response()))
            })
            .await;

        assert!(decision.is_none(), "200 should not retry");
    }

    #[test]
    fn no_endpoint_falls_back_to_default() {
        // No CURRENT_ENDPOINT scope — strategy should fall through to default.
        let decision = KindAwareRetryStrategy.handle(&Ok(make_500_response()));
        assert!(matches!(decision, Some(Retryable::Transient)));
    }
}