Skip to content

fix: improve error handling for API failures#22

Merged
unhappychoice merged 2 commits intomainfrom
fix/improve-error-handling
Feb 13, 2026
Merged

fix: improve error handling for API failures#22
unhappychoice merged 2 commits intomainfrom
fix/improve-error-handling

Conversation

@unhappychoice
Copy link
Owner

@unhappychoice unhappychoice commented Feb 13, 2026

Summary

Improve error handling to provide better user experience when API calls fail. Closes #5.

Changes

  • Add SteamApiError custom error type for structured error handling (PrivateProfile, RateLimited, Timeout, NetworkError, InvalidApiKey, PlayerNotFound, ApiError)
  • Add retry logic with exponential backoff (3 retries: 500ms → 1s → 2s) for rate limiting, timeouts, network errors, and server errors
  • Detect and explain private profile errors with step-by-step guidance to set profile public
  • Handle HTTP 429 rate limiting with clear message and automatic retry
  • Show partial data when optional API calls (steam level, recently played) fail instead of aborting
  • Add --timeout <SECONDS> CLI flag for configurable request timeout (default: 30s)
  • Add unit tests for error detection and top games extraction

Checklist from #5

  • Add retry logic with exponential backoff
  • Detect and explain private profile errors
  • Handle rate limiting with clear message
  • Show partial data when some API calls fail
  • Add --verbose flag for detailed error info
  • Timeout configuration for slow connections

Summary by CodeRabbit

  • New Features

    • Configurable request timeout (default 30s) and ability to apply it at runtime.
    • Automatic retry with exponential backoff for HTTP requests.
    • Richer error classification with clearer, user-facing messages for private profiles, rate limits, timeouts, and network issues.
  • Bug Fixes

    • More robust handling of optional account details with non-fatal failures in verbose mode.
  • Tests

    • Added unit tests covering error detection and top-games extraction logic.

- Add retry logic with exponential backoff (3 retries, 500ms/1s/2s)
- Detect and explain private profile errors with guidance
- Handle rate limiting (HTTP 429) with clear message and retry
- Show partial data when steam level or recently played fails
- Add SteamApiError custom error type for structured error handling
- Add --timeout flag for configurable request timeout (default 30s)
- Add unit tests for error detection and top games extraction

Closes #5
@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

Refactors CLI to carry a new timeout field and pass a &Cli context through fetch paths. Adds retry with exponential backoff and timeout support to the Steam HTTP client. Introduces a SteamApiError enum and exposes the steam::error module.

Changes

Cohort / File(s) Summary
CLI Context & Invocation
src/main.rs
Added timeout: u64 to Cli. Unified fetch call signatures to accept &Cli and updated call sites in main.
Steam HTTP client & retry
src/steam/client.rs
Added retry constants, request_with_retry with exponential backoff, build_http_client supporting timeouts, with_timeout(secs) on SteamClient, centralized request/response parsing, helpers (detect_api_error, detect_private_profile, extract_top_games, fetch_optional_details), and refactored endpoints to use retry logic. Tests added/updated.
Error types & module export
src/steam/error.rs, src/steam/mod.rs
New public SteamApiError enum with Display and Error impls and is_retryable() method. Exported module via pub mod error;.

Sequence Diagram

sequenceDiagram
    participant App as "App (main / CLI)"
    participant Client as "SteamClient (HTTP)"
    participant Retry as "Retry Handler"
    participant API as "Steam API"
    participant Detector as "Error Detector"
    participant Result as "Result/Parser"

    App->>Client: build client (timeout from CLI)
    App->>Retry: request_with_retry(endpoint, retries)
    Retry->>Client: send HTTP request (with timeout)
    Client->>API: HTTP request
    alt 200 OK
        API-->>Client: 200 + body
        Client->>Result: parse body
        Result-->>App: SteamStats
    else 429 / Rate limit
        API-->>Client: 429
        Client->>Retry: classify as RateLimited
        Retry->>Retry: backoff & retry
    else Timeout / Network
        Client--xAPI: no response / error
        Client->>Retry: classify as Timeout/NetworkError
        Retry->>Retry: backoff & retry
    else 4xx/5xx
        API-->>Client: error
        Client->>Detector: detect_api_error / detect_private_profile
        Detector-->>App: SteamApiError
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 With carrots packed and timeout set,
I hop through retries without a fret.
Private profiles now I gently find,
Rate limits coaxed, errors redefined.
Hooray — steady stats for humankind! 🥕✨

🚥 Pre-merge checks | ✅ 5 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: improve error handling for API failures' directly reflects the main change: a comprehensive refactor adding custom error types, retry logic, and improved error messaging throughout the codebase.
Linked Issues check ✅ Passed All coding requirements from issue #5 are fully implemented: retry logic with exponential backoff, private profile detection and guidance, rate limiting handling, partial data display, timeout configuration, and error classification with proper retry semantics.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #5 requirements. The CLI timeout field, error module, retry mechanism, and helper functions serve the stated objectives of improving error handling and user experience.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/improve-error-handling

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/main.rs`:
- Around line 33-36: The CLI currently allows --timeout 0 for the timeout field
(timeout: u64) which yields a zero-duration and makes requests fail immediately;
update the argument parsing for timeout to enforce a minimum of 1 second (either
by adding clap's value_parser with a range like
clap::value_parser!(u64).range(1..) on the timeout field or by adding a small
runtime check after parsing that rejects values < 1 and exits with a clear
error), and ensure the error message references the timeout option so users know
to supply a positive integer.

In `@src/steam/client.rs`:
- Around line 561-568: The current build_http_client function silently falls
back to Client::new() (losing the configured timeout) when
Client::builder().build() fails; change this to propagate the failure instead of
silently ignoring it by removing the unwrap_or_else fallback and using expect
(or propagate the Result) on Client::builder().build() so the error is surfaced
(e.g., call Client::builder().timeout(timeout)...build().expect("failed to build
reqwest Client with timeout")). This keeps the configured timeout and avoids
silent degradation in build_http_client.
- Around line 525-531: The current verbose logging slices the response body by
bytes using &body[..body.len().min(500)] which can panic on UTF-8 multi-byte
boundaries; change the truncation in the eprintln block (the code guarded by
self.verbose that prints context and body) to compute a character-boundary-safe
end index (e.g., use str::floor_char_boundary(body, 500) on Rust >=1.91 or
compute the last char boundary with body.char_indices().take_while(|(i, _)| *i <
500).last().map_or(0, |(i, c)| i + c.len_utf8())) and then slice with that index
so eprintln prints a valid UTF-8 substring without risking a panic.
🧹 Nitpick comments (5)
src/steam/error.rs (2)

49-55: is_retryable() doesn't cover server errors, but request_with_retry retries them anyway.

In client.rs (lines 511–518), server errors (5xx) set last_error and continue, effectively retrying. However, is_retryable() returns false for ApiError. This inconsistency means is_retryable() doesn't accurately describe retry behavior, which can mislead future callers.

Consider either adding ApiError with a server-error guard to is_retryable, or using is_retryable() as the single source of truth in request_with_retry instead of hard-coding the retry decision for each status category.


1-47: Consider using thiserror to reduce boilerplate.

The project already depends on thiserror v2.0.0 (per Cargo.toml). Using #[derive(thiserror::Error)] with #[error("...")] attributes would eliminate the manual Display and Error impls while keeping the same behavior.

src/steam/client.rs (3)

476-558: Retry loop: the is_retryable() check on network errors (line 542) is always true — the else branch at line 550 is dead code.

Within the Err(e) arm (lines 535–551), api_error is always either Timeout or NetworkError, both of which return true from is_retryable(). The return Err(api_error.into()) at line 550 is unreachable. This isn't a bug, but it's misleading.

If the intent is to guard against future non-retryable network error variants, a comment would suffice. Otherwise, the branch can be removed to simplify the code.


438-474: Achievement endpoints bypass request_with_retry — no retry or error classification.

fetch_player_achievements and fetch_global_percentages still call self.client.get().send().await directly, so they don't benefit from the new retry logic or structured error handling. This is likely acceptable since achievement fetching is already best-effort (wrapped in Option), but it means transient failures on achievements won't be retried.

Worth noting for future consideration if achievement fetching becomes more critical.


584-620: detect_api_error and detect_private_profile rely on fragile string matching.

Both functions use body.contains(...) to classify errors from raw JSON. This works today but is brittle — whitespace variations, key ordering changes, or localized responses could break detection. Since the body is already being parsed as JSON elsewhere, consider parsing once and inspecting the structured data instead of substring matching.

For detect_api_error specifically, the "Forbidden" / "Access is denied" check (line 589) overlaps with the HTTP 403 check already handled in request_with_retry (line 507), which makes this path mainly a fallback for non-403 body content.

- Validate --timeout with value_parser range to reject 0
- Fix UTF-8 panic in verbose body truncation (floor_char_boundary)
- Use expect() in build_http_client to surface build errors
- Use thiserror derive for SteamApiError to reduce boilerplate
- Add server errors (5xx) to is_retryable() for consistency
- Unify retry logic via is_retryable() as single source of truth
- Remove dead code branch in network error handling
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/steam/client.rs`:
- Around line 508-511: The call to str::floor_char_boundary (used in the
verbose-response truncation inside the block checking self.verbose and body)
requires Rust 1.91; either declare rust-version = "1.91" in Cargo.toml to set
the MSRV, or replace floor_char_boundary with a compatibility-safe truncation:
compute the byte index of the 500th character using body.char_indices() (or
fallback to body.len()) and slice with that byte index to avoid splitting UTF-8
characters. Ensure you update the truncation logic where truncated =
&body[..body.floor_char_boundary(500)] is used.
🧹 Nitpick comments (1)
src/steam/client.rs (1)

593-614: Private-profile detection relies on fragile heuristics and can false-positive.

A user who genuinely owns zero games will trigger PrivateProfile if the response body lacks a "games" key (which Steam omits when the list is empty). The parse-error branch (line 607) is similarly ambiguous — "game_count":0 without "games" can be a normal empty response, not necessarily a private profile.

Consider logging a verbose hint when the heuristic fires so users who actually own zero games aren't stuck:

Suggested improvement
         Ok(resp) if resp.response.games.is_empty() && resp.response.game_count == 0 => {
-            // Could be a private profile or truly no games.
-            // Check if the response body looks like a minimal/empty response
             if !body.contains("\"games\"") {
+                // Note: this also triggers for accounts that genuinely own zero games
                 return Err(SteamApiError::PrivateProfile.into());
             }
             Ok(())

This is acceptable for now since zero-game accounts are rare, but worth a comment to flag the known limitation.

Comment on lines +508 to +511
if self.verbose {
let truncated = &body[..body.floor_char_boundary(500)];
eprintln!("[verbose] {} response body: {}", context, truncated);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if MSRV or rust-version is specified in Cargo.toml or rust-toolchain
echo "=== Cargo.toml ==="
cat Cargo.toml | grep -i -E 'rust-version|edition|msrv' || echo "No MSRV found in Cargo.toml"
echo ""
echo "=== rust-toolchain* ==="
fd 'rust-toolchain' --max-depth 1 --exec cat {} \; || echo "No rust-toolchain file found"
echo ""
echo "=== Full Cargo.toml [package] section ==="
sed -n '/^\[package\]/,/^\[/p' Cargo.toml | head -30

Repository: unhappychoice/steamfetch

Length of output: 521


🌐 Web query:

str::floor_char_boundary Rust stabilized version 1.91

💡 Result:

str::floor_char_boundary was stabilized in Rust 1.91.0 (and it is also const-stable starting in 1.91.0). It shipped with the Rust 1.91.0 release on October 30, 2025. [1] [2]

Sources: Rust 1.91.0 release announcement (lists str::floor_char_boundary as newly stabilized) [1], Rust standard library docs for str::floor_char_boundary (shows 1.91.0 (const: 1.91.0)) [2].


Add rust-version = "1.91" to Cargo.toml or avoid using floor_char_boundary.

The project does not specify an MSRV in Cargo.toml. Since the code uses str::floor_char_boundary (stabilized in Rust 1.91.0), either declare the minimum supported version or replace with a compatibility-friendly alternative.

🤖 Prompt for AI Agents
In `@src/steam/client.rs` around lines 508 - 511, The call to
str::floor_char_boundary (used in the verbose-response truncation inside the
block checking self.verbose and body) requires Rust 1.91; either declare
rust-version = "1.91" in Cargo.toml to set the MSRV, or replace
floor_char_boundary with a compatibility-safe truncation: compute the byte index
of the 500th character using body.char_indices() (or fallback to body.len()) and
slice with that byte index to avoid splitting UTF-8 characters. Ensure you
update the truncation logic where truncated =
&body[..body.floor_char_boundary(500)] is used.

@unhappychoice unhappychoice merged commit f076a6b into main Feb 13, 2026
8 checks passed
@unhappychoice unhappychoice deleted the fix/improve-error-handling branch February 13, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: improve error handling for API failures

1 participant