paperclipai/paperclip · error · ToolGatewayHttpError

oauth_refresh_failed

oauth_refresh_failed

Error message

OAuth authorization could not be refreshed

What it means

When resolving credential headers for an OAuth connection stored in paperclip_vault, the gateway delegates to options.oauthGrantRefresher to refresh the access token. Any error thrown by that refresher that is not already a ToolGatewayHttpError is normalized: status defaults to 502, the reason code defaults to 'oauth_refresh_failed', and the message defaults to 'OAuth authorization could not be refreshed'. It means the stored OAuth grant could not be refreshed with the provider.

Solutions

  1. Have the user re-authorize the connection (the grant typically transitions to needs_reauthorization; complete the OAuth consent flow again).
  2. Check the refresher's original error details — if the provider returned invalid_grant, re-auth is mandatory; if 5xx/network, retry later.
  3. Verify the OAuth client id/secret and redirect configuration are current after any credential rotation.
  4. Catch ToolGatewayHttpError with code 'oauth_refresh_failed' at the tool-call site and surface a 'reconnect account' interaction instead of retrying blindly.

Example fix

// before: refreshing a revoked grant fails with invalid_grant
POST provider/token  grant_type=refresh_token  -> 400 invalid_grant
// after: re-authorize the connection to obtain fresh tokens
await startOAuthConsentFlow({ connectionId, companyId }); // user re-consents, new refresh token stored
Defensive patterns

Strategy: try-catch

Validate before calling

const grantOauth = asRecord(asRecord(grant.providerTenant)?.oauth);
const expiresAt = grantOauth && typeof grantOauth.accessTokenExpiresAt === 'string' ? Date.parse(grantOauth.accessTokenExpiresAt) : NaN;
if (Number.isFinite(expiresAt) && expiresAt <= Date.now() && !canRefresh(grant)) {
  throw new Error('Grant needs re-authorization before use');
}

Type guard

function needsReauth(grant: typeof connectionGrants.$inferSelect): boolean {
  return grant.status === 'needs_reauthorization' || !grant.credentialSecretRefs.some(r => r.configPath === 'oauth.refresh_token');
}

Try / catch

try {
  const headers = await resolveCredentialHeaders(session, connection, grant);
} catch (e) {
  if (e instanceof ToolGatewayHttpError && e.code === 'oauth_refresh_failed') {
    await markGrantNeedsReauthorization(grant.id);
    await createUserAuthorizationInteraction(session, connection, responsibleUserId);
  }
  throw e;
}

Prevention

When it happens

Trigger: The oauthGrantRefresher throws because the refresh token was revoked/expired at the provider, the token endpoint returned an error (invalid_grant, 4xx/5xx), network access to the provider failed, or the refresher threw a non-Error value (record with details.code absent) so no specific code/status is available.

Common situations: A user revoked the app in Google/GitHub settings so the refresh token is invalid; the OAuth grant sat unused past the provider's refresh-token inactivity window; provider token endpoint downtime; a misconfigured OAuth client secret after rotation.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/26fc8a166719002d. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/tool-gateway.ts:4019

            actorId: session.actorId ?? session.agentId,
          },
          issueId: session.issueId,
          heartbeatRunId: session.runId,
        });
      } catch (error) {
        if (error instanceof ToolGatewayHttpError) throw error;
        const record = asRecord(error);
        const details = asRecord(record?.details) ?? {};
        const status = typeof record?.status === "number" ? record.status : 502;
        const reasonCode =
          typeof details.code === "string"
            ? details.code
            : "oauth_refresh_failed";
        const message =
          error instanceof Error
            ? error.message
            : "OAuth authorization could not be refreshed";
        throw new ToolGatewayHttpError(status, message, reasonCode, {
          ...details,
          connectionId: connection.id,
          grantId: grant.id,
        });
      }
    }
    const headers: Record<string, string> = {};
    for (const ref of connection.credentialRefs ?? []) {
      if (ref.placement !== "header") continue;
      const grantRef = grantRefForCredential(grant, ref);
      if (!grantRef) continue;
      try {
        const value = await resolveGrantSecretValue(
          session,
          connection,
          grant,
          grantRef,
          // OAuth grants declare their canonical oauth.* path. Treating

View on GitHub (pinned to 3f1d897a7c)