{"record":{"id":"26fc8a166719002d","repo":"paperclipai/paperclip","slug":"oauth-refresh-failed","errorCode":"oauth_refresh_failed","errorMessage":"OAuth authorization could not be refreshed","messagePattern":"OAuth authorization could not be refreshed","errorType":"http","errorClass":"ToolGatewayHttpError","httpStatus":502,"severity":"error","filePath":"server/src/services/tool-gateway.ts","lineNumber":4019,"sourceCode":"            actorId: session.actorId ?? session.agentId,\n          },\n          issueId: session.issueId,\n          heartbeatRunId: session.runId,\n        });\n      } catch (error) {\n        if (error instanceof ToolGatewayHttpError) throw error;\n        const record = asRecord(error);\n        const details = asRecord(record?.details) ?? {};\n        const status = typeof record?.status === \"number\" ? record.status : 502;\n        const reasonCode =\n          typeof details.code === \"string\"\n            ? details.code\n            : \"oauth_refresh_failed\";\n        const message =\n          error instanceof Error\n            ? error.message\n            : \"OAuth authorization could not be refreshed\";\n        throw new ToolGatewayHttpError(status, message, reasonCode, {\n          ...details,\n          connectionId: connection.id,\n          grantId: grant.id,\n        });\n      }\n    }\n    const headers: Record<string, string> = {};\n    for (const ref of connection.credentialRefs ?? []) {\n      if (ref.placement !== \"header\") continue;\n      const grantRef = grantRefForCredential(grant, ref);\n      if (!grantRef) continue;\n      try {\n        const value = await resolveGrantSecretValue(\n          session,\n          connection,\n          grant,\n          grantRef,\n          // OAuth grants declare their canonical oauth.* path. Treating","sourceCodeStart":4001,"sourceCodeEnd":4037,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/server/src/services/tool-gateway.ts#L4001-L4037","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: refreshing a revoked grant fails with invalid_grant\nPOST provider/token  grant_type=refresh_token  -> 400 invalid_grant\n// after: re-authorize the connection to obtain fresh tokens\nawait startOAuthConsentFlow({ connectionId, companyId }); // user re-consents, new refresh token stored","handlingStrategy":"try-catch","validationCode":"const grantOauth = asRecord(asRecord(grant.providerTenant)?.oauth);\nconst expiresAt = grantOauth && typeof grantOauth.accessTokenExpiresAt === 'string' ? Date.parse(grantOauth.accessTokenExpiresAt) : NaN;\nif (Number.isFinite(expiresAt) && expiresAt <= Date.now() && !canRefresh(grant)) {\n  throw new Error('Grant needs re-authorization before use');\n}","typeGuard":"function needsReauth(grant: typeof connectionGrants.$inferSelect): boolean {\n  return grant.status === 'needs_reauthorization' || !grant.credentialSecretRefs.some(r => r.configPath === 'oauth.refresh_token');\n}","tryCatchPattern":"try {\n  const headers = await resolveCredentialHeaders(session, connection, grant);\n} catch (e) {\n  if (e instanceof ToolGatewayHttpError && e.code === 'oauth_refresh_failed') {\n    await markGrantNeedsReauthorization(grant.id);\n    await createUserAuthorizationInteraction(session, connection, responsibleUserId);\n  }\n  throw e;\n}","preventionTips":["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."],"tags":["oauth","token-refresh","authorization","http-502"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}