zeroclaw-labs/zeroclaw · critical · anyhow::Error

token refresh failed ({status}): {body}

Error message

token refresh failed ({status}): {body}

What it means

Raised by RedditChannel::refresh_access_token when POST https://www.reddit.com/api/v1/access_token (grant_type=refresh_token, HTTP Basic client_id:client_secret) returns a non-2xx status. The response body is included verbatim — Reddit returns JSON like {'error': 'invalid_grant'} for revoked/invalid refresh tokens or 401 invalid_client for a bad app credential pair. Every Reddit operation funnels through this: get_access_token, listen polling, fetch_inbox, mark_read and send all fail when it breaks.

Source

Thrown at crates/zeroclaw-channels/src/reddit.rs:118

        let client = self.http_client();
        let resp = client
            .post(REDDIT_TOKEN_URL)
            .basic_auth(&self.client_id, Some(&self.client_secret))
            .header("User-Agent", USER_AGENT)
            .form(&[
                ("grant_type", "refresh_token"),
                ("refresh_token", &self.refresh_token),
            ])
            .send()
            .await?;

        let status = resp.status();
        if !status.is_success() {
            let body = resp
                .text()
                .await
                .unwrap_or_else(|e| format!("<failed to read response: {e}>"));
            bail!("token refresh failed ({status}): {body}");
        }

        let token_resp: RedditTokenResponse = resp.json().await?;
        let mut auth = self.auth.lock();
        auth.access_token = token_resp.access_token;
        auth.expires_at =
            Instant::now() + Duration::from_secs(token_resp.expires_in.saturating_sub(60));
        Ok(())
    }

    /// Get a valid access token, refreshing if expired.
    async fn get_access_token(&self) -> Result<String> {
        {
            let auth = self.auth.lock();
            if !auth.access_token.is_empty() && Instant::now() < auth.expires_at {
                return Ok(auth.access_token.clone());
            }
        }

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. If the body says invalid_grant or status is 400: the refresh token is dead — re-run the OAuth flow for the Reddit app and replace the stored refresh_token
  2. If 401/invalid_client: the client_id/client_secret pair does not match the app that issued the refresh token — fix the config
  3. If 429: back off and lower polling rate (Reddit enforces 60 requests/minute; POLL_INTERVAL is 5s)
  4. Verify the configured User-Agent — Reddit rejects generic/default agents
  5. Check for a second process using the same credentials doubling the request rate
Defensive patterns

Strategy: try-catch

Try / catch

match channel.get_access_token().await {
    Err(err) => {
        let msg = format!("{err:#}");
        if msg.starts_with("token refresh failed (400") || msg.starts_with("token refresh failed (401") {
            return Err(anyhow!("Reddit credentials need manual re-auth: {msg}")).context("stop retrying"); // permanent
        }
        tokio::time::sleep(Duration::from_secs(30)).await; // 429/5xx: transient
        channel.get_access_token().await
    }
    ok => ok,
}

Prevention

When it happens

Trigger: Any Reddit API call with an empty/expired cached access token triggers refresh_access_token; a 400 invalid_grant (refresh token revoked or mistyped), 401 (wrong client_id/client_secret for the app that issued the token), 429 (IP or app rate limited), or 5xx triggers the bail before token_resp parsing.

Common situations: User revoked app access in Reddit settings, invalidating the stored refresh token; refresh_token issued by one Reddit app but client_id/secret from another; placeholder or truncated secrets in config; exceeding Reddit's 60 req/min budget so token endpoint also rate limits; datacenter IP blocked by Reddit.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/36cb16fb051a5a0e. Report an issue: GitHub.