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
- Have the user re-authorize the connection (the grant typically transitions to needs_reauthorization; complete the OAuth consent flow again).
- Check the refresher's original error details — if the provider returned invalid_grant, re-auth is mandatory; if 5xx/network, retry later.
- Verify the OAuth client id/secret and redirect configuration are current after any credential rotation.
- 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
- Proactively refresh access tokens before expiry (e.g. refresh when < 1h left) instead of on-demand at call time.
- Detect invalid_grant responses and immediately flip the grant to needs_reauthorization with a user-facing prompt.
- Alert on refresh failures so revoked/inactive grants are re-authorized before agents hit them.
- Keep OAuth client secrets rotated with a documented process so refresh calls don't fail on auth errors.
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
- github_identity_unavailable
- grant_owner_missing
- user_authorization_required
- access.reasonCode
- agent_authorization_required
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. TreatingView on GitHub (pinned to 3f1d897a7c)