When OAuth tokens rotate or refresh, all reviewer containers are automatically removed. The next reviewer turn recreates them with the latest token, preventing 401 auth failures. Uses callback pattern to avoid circular dependencies: - token-rotation.ts: onTokenRotated(cb) - token-refresh.ts: onTokenRefreshed(cb) - index.ts: registers recreateAllReviewerContainers as callback
266 lines
7.1 KiB
TypeScript
266 lines
7.1 KiB
TypeScript
/**
|
|
* OAuth Token Rotation
|
|
*
|
|
* Rotates between multiple CLAUDE_CODE_OAUTH_TOKEN values when
|
|
* rate-limited. Tokens are stored as comma-separated values in
|
|
* CLAUDE_CODE_OAUTH_TOKENS env var. Falls through to the single
|
|
* CLAUDE_CODE_OAUTH_TOKEN if multi-token is not configured.
|
|
*
|
|
* On rate-limit: rotate to next token
|
|
* All exhausted: surface error to caller
|
|
*/
|
|
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { DATA_DIR } from './config.js';
|
|
import { getEnv } from './env.js';
|
|
import { logger } from './logger.js';
|
|
import {
|
|
computeCooldownUntil,
|
|
findNextAvailable,
|
|
} from './token-rotation-base.js';
|
|
import { readJsonFile, writeJsonFile } from './utils.js';
|
|
|
|
const STATE_FILE = path.join(DATA_DIR, 'token-rotation-state.json');
|
|
|
|
interface TokenState {
|
|
token: string;
|
|
rateLimitedUntil: number | null;
|
|
}
|
|
|
|
const tokens: TokenState[] = [];
|
|
let currentIndex = 0;
|
|
let initialized = false;
|
|
|
|
// ── Post-rotation callback ──────────────────────────────────────
|
|
// Invoked after a successful token rotation so that callers (e.g.
|
|
// index.ts) can tear down reviewer containers whose SDK process
|
|
// still holds the old token.
|
|
type TokenRotationCallback = () => void;
|
|
let onTokenRotatedCallback: TokenRotationCallback | null = null;
|
|
|
|
/** Register a callback to be invoked after a successful token rotation. */
|
|
export function onTokenRotated(cb: TokenRotationCallback): void {
|
|
onTokenRotatedCallback = cb;
|
|
}
|
|
|
|
export function initTokenRotation(): void {
|
|
if (initialized) return;
|
|
initialized = true;
|
|
|
|
const multi = getEnv('CLAUDE_CODE_OAUTH_TOKENS');
|
|
const single = getEnv('CLAUDE_CODE_OAUTH_TOKEN');
|
|
|
|
const raw = multi
|
|
? multi
|
|
.split(',')
|
|
.map((t) => t.trim())
|
|
.filter(Boolean)
|
|
: single
|
|
? [single]
|
|
: [];
|
|
|
|
for (const token of raw) {
|
|
tokens.push({ token, rateLimitedUntil: null });
|
|
}
|
|
|
|
if (tokens.length > 1) {
|
|
loadState();
|
|
logger.info(
|
|
{ count: tokens.length, activeIndex: currentIndex },
|
|
`Token rotation initialized with ${tokens.length} tokens`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function saveState(): void {
|
|
try {
|
|
const state = {
|
|
currentIndex,
|
|
rateLimits: tokens.map((t) => t.rateLimitedUntil),
|
|
};
|
|
writeJsonFile(STATE_FILE, state);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
function loadState(): void {
|
|
const state = readJsonFile<{
|
|
currentIndex?: number;
|
|
rateLimits?: (number | null)[];
|
|
}>(STATE_FILE);
|
|
if (!state) return;
|
|
|
|
const now = Date.now();
|
|
if (
|
|
typeof state.currentIndex === 'number' &&
|
|
state.currentIndex < tokens.length
|
|
) {
|
|
currentIndex = state.currentIndex;
|
|
}
|
|
if (Array.isArray(state.rateLimits)) {
|
|
for (let i = 0; i < Math.min(state.rateLimits.length, tokens.length); i++) {
|
|
const until = state.rateLimits[i];
|
|
if (typeof until === 'number' && until > now) {
|
|
tokens[i].rateLimitedUntil = until;
|
|
}
|
|
}
|
|
}
|
|
logger.info(
|
|
{ currentIndex, tokenCount: tokens.length },
|
|
'Token rotation state restored',
|
|
);
|
|
}
|
|
|
|
/** Get the current active token. */
|
|
export function getCurrentToken(): string | undefined {
|
|
if (tokens.length === 0) return process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
return tokens[currentIndex % tokens.length]?.token;
|
|
}
|
|
|
|
/**
|
|
* Try to rotate to the next available (non-rate-limited) token.
|
|
* Returns true if a fresh token was found, false if all are exhausted.
|
|
*/
|
|
export function rotateToken(
|
|
errorMessage?: string,
|
|
opts?: { ignoreRateLimits?: boolean },
|
|
): boolean {
|
|
if (tokens.length <= 1) return false;
|
|
|
|
const previousIndex = currentIndex;
|
|
const cooldownUntil = computeCooldownUntil(errorMessage);
|
|
tokens[currentIndex].rateLimitedUntil = cooldownUntil;
|
|
|
|
const nextIdx = findNextAvailable(tokens, currentIndex, opts);
|
|
if (nextIdx !== null) {
|
|
tokens[nextIdx].rateLimitedUntil = null;
|
|
currentIndex = nextIdx;
|
|
logger.info(
|
|
{
|
|
transition: 'rotation:execute',
|
|
fromIndex: previousIndex,
|
|
toIndex: currentIndex,
|
|
totalTokens: tokens.length,
|
|
ignoreRL: opts?.ignoreRateLimits ?? false,
|
|
cooldownUntil:
|
|
cooldownUntil != null ? new Date(cooldownUntil).toISOString() : null,
|
|
reason: errorMessage ?? null,
|
|
},
|
|
`Rotated to token #${currentIndex + 1}/${tokens.length}`,
|
|
);
|
|
saveState();
|
|
if (onTokenRotatedCallback) {
|
|
try {
|
|
onTokenRotatedCallback();
|
|
} catch {
|
|
/* best effort — don't break rotation flow */
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
logger.warn(
|
|
{
|
|
transition: 'rotation:skip',
|
|
fromIndex: previousIndex,
|
|
totalTokens: tokens.length,
|
|
ignoreRL: opts?.ignoreRateLimits ?? false,
|
|
cooldownUntil:
|
|
cooldownUntil != null ? new Date(cooldownUntil).toISOString() : null,
|
|
reason: errorMessage ?? null,
|
|
},
|
|
'All tokens are rate-limited, no available tokens',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
/** Clear rate-limit flag for the current token (on successful response). */
|
|
export function markTokenHealthy(): void {
|
|
if (tokens.length === 0) return;
|
|
const state = tokens[currentIndex];
|
|
if (state?.rateLimitedUntil) {
|
|
const previousCooldownUntil = state.rateLimitedUntil;
|
|
state.rateLimitedUntil = null;
|
|
logger.info(
|
|
{
|
|
transition: 'rotation:clear-rate-limit',
|
|
tokenIndex: currentIndex,
|
|
cooldownUntil: new Date(previousCooldownUntil).toISOString(),
|
|
},
|
|
'Cleared Claude token rate-limit state after successful response',
|
|
);
|
|
saveState();
|
|
}
|
|
}
|
|
|
|
/** Number of configured tokens. */
|
|
export function getTokenCount(): number {
|
|
return tokens.length;
|
|
}
|
|
|
|
/** Get all configured tokens (masked for display, raw for API calls). */
|
|
export function getAllTokens(): {
|
|
index: number;
|
|
token: string;
|
|
masked: string;
|
|
isActive: boolean;
|
|
isRateLimited: boolean;
|
|
}[] {
|
|
const now = Date.now();
|
|
return tokens.map((t, i) => ({
|
|
index: i,
|
|
token: t.token,
|
|
masked: `${t.token.slice(0, 20)}...${t.token.slice(-4)}`,
|
|
isActive: i === currentIndex,
|
|
isRateLimited: Boolean(t.rateLimitedUntil && t.rateLimitedUntil > now),
|
|
}));
|
|
}
|
|
|
|
/** Update the access token value for a specific index (after OAuth refresh). */
|
|
export function updateTokenValue(index: number, newAccessToken: string): void {
|
|
if (index < 0 || index >= tokens.length) {
|
|
logger.warn(
|
|
{ index, total: tokens.length },
|
|
'updateTokenValue: index out of range',
|
|
);
|
|
return;
|
|
}
|
|
tokens[index].token = newAccessToken;
|
|
logger.info(
|
|
{
|
|
index,
|
|
masked: `${newAccessToken.slice(0, 20)}...${newAccessToken.slice(-4)}`,
|
|
},
|
|
'Token value updated after OAuth refresh',
|
|
);
|
|
}
|
|
|
|
/** Diagnostic info. */
|
|
export function getTokenRotationInfo(): {
|
|
total: number;
|
|
currentIndex: number;
|
|
rateLimited: number;
|
|
} {
|
|
const now = Date.now();
|
|
return {
|
|
total: tokens.length,
|
|
currentIndex,
|
|
rateLimited: tokens.filter(
|
|
(t) => t.rateLimitedUntil && t.rateLimitedUntil > now,
|
|
).length,
|
|
};
|
|
}
|
|
|
|
export function hasAvailableClaudeToken(): boolean {
|
|
if (tokens.length === 0) {
|
|
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
|
}
|
|
const now = Date.now();
|
|
return tokens.some(
|
|
(token) => !token.rateLimitedUntil || token.rateLimitedUntil <= now,
|
|
);
|
|
}
|