chore: remove unused code and suppress noisy Claude Code logs

- Delete unused files: retry.js, app-init.js, model-manager.js
- Remove duplicate error helpers from helpers.js (exist in errors.js)
- Remove unused exports from signature-cache.js, logger.js
- Remove unused frontend code: ErrorHandler methods, validators, canDelete, destroy
- Make internal functions private in thinking-utils.js
- Remove commented-out code from constants.js
- Remove deprecated .glass-panel CSS class
- Add silent handler for Claude Code event logging (/api/event_logging/batch)
- Suppress logging for /v1/messages/count_tokens (501 responses)
- Fix catch-all to use originalUrl (wildcard strips req.path)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Badri Narayanan S
2026-01-18 01:36:24 +05:30
parent c52d32572a
commit 973234372b
17 changed files with 35 additions and 597 deletions

View File

@@ -111,7 +111,7 @@ src/
│ └── signature-cache.js # Signature cache (tool_use + thinking signatures) │ └── signature-cache.js # Signature cache (tool_use + thinking signatures)
└── utils/ # Utilities └── utils/ # Utilities
├── helpers.js # formatDuration, sleep ├── helpers.js # formatDuration, sleep, isNetworkError
├── logger.js # Structured logging ├── logger.js # Structured logging
└── native-module-helper.js # Auto-rebuild for native modules └── native-module-helper.js # Auto-rebuild for native modules
``` ```
@@ -137,7 +137,6 @@ public/
│ │ ├── account-manager.js # Account list & OAuth handling │ │ ├── account-manager.js # Account list & OAuth handling
│ │ ├── logs-viewer.js # Live log streaming │ │ ├── logs-viewer.js # Live log streaming
│ │ ├── claude-config.js # CLI settings editor │ │ ├── claude-config.js # CLI settings editor
│ │ ├── model-manager.js # Model configuration UI
│ │ ├── server-config.js # Server settings UI │ │ ├── server-config.js # Server settings UI
│ │ └── dashboard/ # Dashboard sub-modules │ │ └── dashboard/ # Dashboard sub-modules
│ │ ├── stats.js # Account statistics calculation │ │ ├── stats.js # Account statistics calculation

View File

@@ -115,8 +115,6 @@
animation: fadeIn 0.4s ease-out forwards; animation: fadeIn 0.4s ease-out forwards;
} }
/* Note: .glass-panel has been deprecated. Use .view-card instead for consistency. */
.nav-item.active { .nav-item.active {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,

2
public/css/style.css generated

File diff suppressed because one or more lines are too long

View File

@@ -374,7 +374,6 @@
<script src="js/components/claude-config.js"></script> <script src="js/components/claude-config.js"></script>
<script src="js/components/logs-viewer.js"></script> <script src="js/components/logs-viewer.js"></script>
<script src="js/components/server-config.js"></script> <script src="js/components/server-config.js"></script>
<script src="js/components/model-manager.js"></script>
<!-- 4. App (registers Alpine components from window.Components) --> <!-- 4. App (registers Alpine components from window.Components) -->
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>

View File

@@ -1,140 +0,0 @@
/**
* App Initialization (Non-module version)
* This must load BEFORE Alpine initializes
*/
document.addEventListener('alpine:init', () => {
// App component registration
// Main App Controller
Alpine.data('app', () => ({
// Re-expose store properties for easier access in navbar
get connectionStatus() {
return Alpine.store('data').connectionStatus;
},
get loading() {
return Alpine.store('data').loading;
},
init() {
// App component initialization
// Theme setup
document.documentElement.setAttribute('data-theme', 'black');
document.documentElement.classList.add('dark');
// Chart Defaults
if (typeof Chart !== 'undefined') {
Chart.defaults.color = window.utils.getThemeColor('--color-text-dim');
Chart.defaults.borderColor = window.utils.getThemeColor('--color-space-border');
Chart.defaults.font.family = '"JetBrains Mono", monospace';
}
// Start Data Polling
this.startAutoRefresh();
document.addEventListener('refresh-interval-changed', () => this.startAutoRefresh());
// Initial Data Fetch (separate from health check)
Alpine.store('data').fetchData();
},
refreshTimer: null,
isTabVisible: true,
fetchData() {
Alpine.store('data').fetchData();
},
startAutoRefresh() {
if (this.refreshTimer) clearInterval(this.refreshTimer);
const baseInterval = parseInt(Alpine.store('settings').refreshInterval);
if (baseInterval > 0) {
// Setup visibility change listener (only once)
if (!this._visibilitySetup) {
this._visibilitySetup = true;
document.addEventListener('visibilitychange', () => {
this.isTabVisible = !document.hidden;
if (this.isTabVisible) {
// Tab became visible - fetch immediately and restart timer
Alpine.store('data').fetchData();
this.startAutoRefresh();
}
});
}
// Schedule next refresh with jitter
const scheduleNext = () => {
// Add ±20% random jitter to prevent synchronized requests
const jitter = (Math.random() - 0.5) * 0.4; // -0.2 to +0.2
const interval = baseInterval * (1 + jitter);
// Slow down when tab is hidden (reduce frequency by 3x)
const actualInterval = this.isTabVisible
? interval
: interval * 3;
this.refreshTimer = setTimeout(() => {
Alpine.store('data').fetchData();
scheduleNext(); // Reschedule with new jitter
}, actualInterval * 1000);
};
scheduleNext();
}
},
// Translation helper for modal (not in a component scope)
t(key) {
return Alpine.store('global').t(key);
},
// Add account handler for modal
async addAccountWeb(reAuthEmail = null) {
const password = Alpine.store('global').webuiPassword;
try {
const urlPath = reAuthEmail
? `/api/auth/url?email=${encodeURIComponent(reAuthEmail)}`
: '/api/auth/url';
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
const width = 600;
const height = 700;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
data.url,
'google_oauth',
`width=${width},height=${height},top=${top},left=${left},scrollbars=yes`
);
const messageHandler = (event) => {
if (event.data?.type === 'oauth-success') {
const store = Alpine.store('global');
const successMsg = reAuthEmail
? store.t('accountReauthSuccess')
: store.t('accountAddedSuccess');
store.showToast(successMsg, 'success');
Alpine.store('data').fetchData();
const modal = document.getElementById('add_account_modal');
if (modal) modal.close();
}
};
window.addEventListener('message', messageHandler);
setTimeout(() => window.removeEventListener('message', messageHandler), 300000);
} else {
Alpine.store('global').showToast(data.error || Alpine.store('global').t('failedToGetAuthUrl'), 'error');
}
} catch (e) {
Alpine.store('global').showToast(Alpine.store('global').t('failedToStartOAuth') + ': ' + e.message, 'error');
}
}
}));
});

View File

@@ -1,47 +0,0 @@
/**
* Model Manager Component
* Handles model configuration (pinning, hiding, aliasing, mapping)
* Registers itself to window.Components for Alpine.js to consume
*/
window.Components = window.Components || {};
window.Components.modelManager = () => ({
// Track which model is currently being edited (null = none)
editingModelId: null,
init() {
// Component is ready
},
/**
* Start editing a model's mapping
* @param {string} modelId - The model to edit
*/
startEditing(modelId) {
this.editingModelId = modelId;
},
/**
* Stop editing
*/
stopEditing() {
this.editingModelId = null;
},
/**
* Check if a model is being edited
* @param {string} modelId - The model to check
*/
isEditing(modelId) {
return this.editingModelId === modelId;
},
/**
* Update model configuration (delegates to shared utility)
* @param {string} modelId - The model ID to update
* @param {object} configUpdates - Configuration updates (pinned, hidden, alias, mapping)
*/
async updateModelConfig(modelId, configUpdates) {
return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates);
}
});

View File

@@ -381,13 +381,6 @@ document.addEventListener('alpine:init', () => {
}); });
return rows; return rows;
},
destroy() {
this.stopHealthCheck();
if (this._visibilityHandler) {
document.removeEventListener('visibilitychange', this._visibilityHandler);
}
} }
}); });
}); });

View File

@@ -187,13 +187,3 @@ window.AccountActions.reloadAccounts = async function() {
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}; };
/**
* 检查账号是否可以删除
* 来自 Antigravity 数据库的账号source='database')不可删除
* @param {object} account - 账号对象
* @returns {boolean} true 表示可删除
*/
window.AccountActions.canDelete = function(account) {
return account && account.source !== 'database';
};

View File

@@ -46,39 +46,6 @@ window.ErrorHandler.safeAsync = async function(fn, errorMessage = null, options
} }
}; };
/**
* Wrap a component method with error handling
* @param {Function} method - Method to wrap
* @param {string} errorMessage - Error message prefix
* @returns {Function} Wrapped method
*/
window.ErrorHandler.wrapMethod = function(method, errorMessage = null) {
return async function(...args) {
return window.ErrorHandler.safeAsync(
() => method.apply(this, args),
errorMessage || Alpine.store('global').t('operationFailed')
);
};
};
/**
* Show a success toast notification
* @param {string} message - Success message
*/
window.ErrorHandler.showSuccess = function(message) {
const store = Alpine.store('global');
store.showToast(message, 'success');
};
/**
* Show an info toast notification
* @param {string} message - Info message
*/
window.ErrorHandler.showInfo = function(message) {
const store = Alpine.store('global');
store.showToast(message, 'info');
};
/** /**
* Show an error toast notification * Show an error toast notification
* @param {string} message - Error message * @param {string} message - Error message
@@ -90,23 +57,6 @@ window.ErrorHandler.showError = function(message, error = null) {
store.showToast(fullMessage, 'error'); store.showToast(fullMessage, 'error');
}; };
/**
* Validate and execute an API call with error handling
* @param {Function} apiCall - Async function that makes the API call
* @param {string} successMessage - Message to show on success (optional)
* @param {string} errorMessage - Message to show on error
* @returns {Promise<any>} API response or undefined on error
*/
window.ErrorHandler.apiCall = async function(apiCall, successMessage = null, errorMessage = 'API call failed') {
const result = await window.ErrorHandler.safeAsync(apiCall, errorMessage);
if (result !== undefined && successMessage) {
window.ErrorHandler.showSuccess(successMessage);
}
return result;
};
/** /**
* Execute an async function with automatic loading state management * Execute an async function with automatic loading state management
* @param {Function} asyncFn - Async function to execute * @param {Function} asyncFn - Async function to execute

View File

@@ -47,71 +47,6 @@ window.Validators.validateRange = function(value, min, max, fieldName = 'Value')
}; };
}; };
/**
* Validate a port number
* @param {number} port - Port number to validate
* @returns {object} { isValid: boolean, value: number, error: string|null }
*/
window.Validators.validatePort = function(port) {
const { PORT_MIN, PORT_MAX } = window.AppConstants.VALIDATION;
return window.Validators.validateRange(port, PORT_MIN, PORT_MAX, 'Port');
};
/**
* Validate a string is not empty
* @param {string} value - String to validate
* @param {string} fieldName - Name of the field for error messages
* @returns {object} { isValid: boolean, value: string, error: string|null }
*/
window.Validators.validateNotEmpty = function(value, fieldName = 'Field') {
const trimmedValue = String(value || '').trim();
const t = Alpine.store('global').t;
if (trimmedValue.length === 0) {
return {
isValid: false,
value: trimmedValue,
error: t('cannotBeEmpty', { fieldName })
};
}
return {
isValid: true,
value: trimmedValue,
error: null
};
};
/**
* Validate a boolean value
* @param {any} value - Value to validate as boolean
* @returns {object} { isValid: boolean, value: boolean, error: string|null }
*/
window.Validators.validateBoolean = function(value) {
if (typeof value === 'boolean') {
return {
isValid: true,
value: value,
error: null
};
}
// Try to coerce common values
if (value === 'true' || value === 1 || value === '1') {
return { isValid: true, value: true, error: null };
}
if (value === 'false' || value === 0 || value === '0') {
return { isValid: true, value: false, error: null };
}
return {
isValid: false,
value: false,
error: Alpine.store('global').t('mustBeTrueOrFalse')
};
};
/** /**
* Validate a timeout/duration value (in milliseconds) * Validate a timeout/duration value (in milliseconds)
* @param {number} value - Timeout value in ms * @param {number} value - Timeout value in ms
@@ -124,16 +59,6 @@ window.Validators.validateTimeout = function(value, minMs = null, maxMs = null)
return window.Validators.validateRange(value, minMs ?? TIMEOUT_MIN, maxMs ?? TIMEOUT_MAX, 'Timeout'); return window.Validators.validateRange(value, minMs ?? TIMEOUT_MIN, maxMs ?? TIMEOUT_MAX, 'Timeout');
}; };
/**
* Validate log limit
* @param {number} value - Log limit value
* @returns {object} { isValid: boolean, value: number, error: string|null }
*/
window.Validators.validateLogLimit = function(value) {
const { LOG_LIMIT_MIN, LOG_LIMIT_MAX } = window.AppConstants.VALIDATION;
return window.Validators.validateRange(value, LOG_LIMIT_MIN, LOG_LIMIT_MAX, 'Log limit');
};
/** /**
* Validate and sanitize input with custom validator * Validate and sanitize input with custom validator
* @param {any} value - Value to validate * @param {any} value - Value to validate
@@ -150,21 +75,3 @@ window.Validators.validate = function(value, validator, showError = true) {
return result; return result;
}; };
/**
* Create a validated input handler for Alpine.js
* @param {Function} validator - Validator function
* @param {Function} onValid - Callback when validation passes
* @returns {Function} Handler function
*/
window.Validators.createHandler = function(validator, onValid) {
return function(value) {
const result = window.Validators.validate(value, validator, true);
if (result.isValid && onValid) {
onValid.call(this, result.value);
}
return result.value;
};
};

View File

@@ -67,17 +67,7 @@ export const LOAD_CODE_ASSIST_ENDPOINTS = [
// Endpoint order for onboardUser (same as generateContent fallbacks) // Endpoint order for onboardUser (same as generateContent fallbacks)
export const ONBOARD_USER_ENDPOINTS = ANTIGRAVITY_ENDPOINT_FALLBACKS; export const ONBOARD_USER_ENDPOINTS = ANTIGRAVITY_ENDPOINT_FALLBACKS;
// Hybrid headers specifically for loadCodeAssist // Headers for loadCodeAssist API
// Uses google-api-nodejs-client User-Agent (required for project discovery on some accounts)
// export const LOAD_CODE_ASSIST_HEADERS = {
// 'User-Agent': 'google-api-nodejs-client/9.15.1',
// 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
// 'Client-Metadata': JSON.stringify({
// ideType: 'IDE_UNSPECIFIED',
// platform: 'PLATFORM_UNSPECIFIED',
// pluginType: 'GEMINI'
// })
// };
export const LOAD_CODE_ASSIST_HEADERS = ANTIGRAVITY_HEADERS; export const LOAD_CODE_ASSIST_HEADERS = ANTIGRAVITY_HEADERS;
// Default project ID if none can be discovered // Default project ID if none can be discovered

View File

@@ -47,32 +47,6 @@ export function getCachedSignature(toolUseId) {
return entry.signature; return entry.signature;
} }
/**
* Clear expired entries from the cache
* Can be called periodically to prevent memory buildup
*/
export function cleanupCache() {
const now = Date.now();
for (const [key, entry] of signatureCache) {
if (now - entry.timestamp > GEMINI_SIGNATURE_CACHE_TTL_MS) {
signatureCache.delete(key);
}
}
for (const [key, entry] of thinkingSignatureCache) {
if (now - entry.timestamp > GEMINI_SIGNATURE_CACHE_TTL_MS) {
thinkingSignatureCache.delete(key);
}
}
}
/**
* Get the current cache size (for debugging)
* @returns {number} Number of entries in the cache
*/
export function getCacheSize() {
return signatureCache.size;
}
/** /**
* Cache a thinking block signature with its model family * Cache a thinking block signature with its model family
* @param {string} signature - The thinking signature to cache * @param {string} signature - The thinking signature to cache
@@ -105,14 +79,6 @@ export function getCachedSignatureFamily(signature) {
return entry.modelFamily; return entry.modelFamily;
} }
/**
* Get the current thinking signature cache size (for debugging)
* @returns {number} Number of entries in the thinking signature cache
*/
export function getThinkingCacheSize() {
return thinkingSignatureCache.size;
}
/** /**
* Clear all entries from the thinking signature cache. * Clear all entries from the thinking signature cache.
* Used for testing cold cache scenarios. * Used for testing cold cache scenarios.

View File

@@ -12,7 +12,7 @@ import { logger } from '../utils/logger.js';
* @param {Object} part - Content part to check * @param {Object} part - Content part to check
* @returns {boolean} True if the part is a thinking block * @returns {boolean} True if the part is a thinking block
*/ */
export function isThinkingPart(part) { function isThinkingPart(part) {
return part.type === 'thinking' || return part.type === 'thinking' ||
part.type === 'redacted_thinking' || part.type === 'redacted_thinking' ||
part.thinking !== undefined || part.thinking !== undefined ||
@@ -22,7 +22,7 @@ export function isThinkingPart(part) {
/** /**
* Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars) * Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars)
*/ */
export function hasValidSignature(part) { function hasValidSignature(part) {
const signature = part.thought === true ? part.thoughtSignature : part.signature; const signature = part.thought === true ? part.thoughtSignature : part.signature;
return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH; return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH;
} }
@@ -61,7 +61,7 @@ export function hasUnsignedThinkingBlocks(messages) {
/** /**
* Sanitize a thinking part by keeping only allowed fields * Sanitize a thinking part by keeping only allowed fields
*/ */
export function sanitizeThinkingPart(part) { function sanitizeThinkingPart(part) {
// Gemini-style thought blocks: { thought: true, text, thoughtSignature } // Gemini-style thought blocks: { thought: true, text, thoughtSignature }
if (part.thought === true) { if (part.thought === true) {
const sanitized = { thought: true }; const sanitized = { thought: true };
@@ -85,7 +85,7 @@ export function sanitizeThinkingPart(part) {
* Sanitize a thinking block by removing extra fields like cache_control. * Sanitize a thinking block by removing extra fields like cache_control.
* Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking) * Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking)
*/ */
export function sanitizeAnthropicThinkingBlock(block) { function sanitizeAnthropicThinkingBlock(block) {
if (!block) return block; if (!block) return block;
if (block.type === 'thinking') { if (block.type === 'thinking') {
@@ -366,7 +366,7 @@ function isPlainUserMessage(message) {
* @param {Array<Object>} messages - Array of messages * @param {Array<Object>} messages - Array of messages
* @returns {Object} State object with inToolLoop, interruptedTool, turnHasThinking, etc. * @returns {Object} State object with inToolLoop, interruptedTool, turnHasThinking, etc.
*/ */
export function analyzeConversationState(messages) { function analyzeConversationState(messages) {
if (!Array.isArray(messages) || messages.length === 0) { if (!Array.isArray(messages) || messages.length === 0) {
return { inToolLoop: false, interruptedTool: false, turnHasThinking: false, toolResultCount: 0 }; return { inToolLoop: false, interruptedTool: false, turnHasThinking: false, toolResultCount: 0 };
} }

View File

@@ -103,6 +103,23 @@ app.use('/v1', (req, res, next) => {
// Setup usage statistics middleware // Setup usage statistics middleware
usageStats.setupMiddleware(app); usageStats.setupMiddleware(app);
/**
* Silent handler for Claude Code CLI root POST requests
* Claude Code sends heartbeat/event requests to POST / which we don't need
* Using app.use instead of app.post for earlier middleware interception
*/
app.use((req, res, next) => {
// Handle Claude Code event logging requests silently
if (req.method === 'POST' && req.path === '/api/event_logging/batch') {
return res.status(200).json({ status: 'ok' });
}
// Handle Claude Code root POST requests silently
if (req.method === 'POST' && req.path === '/') {
return res.status(200).json({ status: 'ok' });
}
next();
});
// Mount WebUI (optional web interface for account management) // Mount WebUI (optional web interface for account management)
mountWebUI(app, __dirname, accountManager); mountWebUI(app, __dirname, accountManager);
@@ -162,7 +179,7 @@ app.use((req, res, next) => {
const logMsg = `[${req.method}] ${req.path} ${status} (${duration}ms)`; const logMsg = `[${req.method}] ${req.path} ${status} (${duration}ms)`;
// Skip standard logging for event logging batch unless in debug mode // Skip standard logging for event logging batch unless in debug mode
if (req.path === '/api/event_logging/batch') { if (req.path === '/api/event_logging/batch' || req.path === '/v1/messages/count_tokens') {
if (logger.isDebugEnabled) { if (logger.isDebugEnabled) {
logger.debug(logMsg); logger.debug(logMsg);
} }
@@ -181,6 +198,14 @@ app.use((req, res, next) => {
next(); next();
}); });
/**
* Silent handler for Claude Code CLI root POST requests
* Claude Code sends heartbeat/event requests to POST / which we don't need
*/
app.post('/', (req, res) => {
res.status(200).json({ status: 'ok' });
});
/** /**
* Test endpoint - Clear thinking signature cache * Test endpoint - Clear thinking signature cache
* Used for testing cold cache scenarios in cross-model tests * Used for testing cold cache scenarios in cross-model tests
@@ -810,6 +835,7 @@ app.post('/v1/messages', async (req, res) => {
usageStats.setupRoutes(app); usageStats.setupRoutes(app);
app.use('*', (req, res) => { app.use('*', (req, res) => {
// Log 404s (use originalUrl since wildcard strips req.path)
if (logger.isDebugEnabled) { if (logger.isDebugEnabled) {
logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`); logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);
} }

View File

@@ -49,32 +49,3 @@ export function isNetworkError(error) {
msg.includes('timeout') msg.includes('timeout')
); );
} }
/**
* Check if an error is an authentication error (permanent until fixed)
* @param {Error} error - The error to check
* @returns {boolean} True if it is an auth error
*/
export function isAuthError(error) {
const msg = error.message.toLowerCase();
return (
msg.includes('401') ||
msg.includes('unauthenticated') ||
msg.includes('invalid_grant') ||
msg.includes('invalid_client')
);
}
/**
* Check if an error is a rate limit error
* @param {Error} error - The error to check
* @returns {boolean} True if it is a rate limit error
*/
export function isRateLimitError(error) {
const msg = error.message.toLowerCase();
return (
msg.includes('429') ||
msg.includes('resource_exhausted') ||
msg.includes('quota_exhausted')
);
}

View File

@@ -140,6 +140,3 @@ class Logger extends EventEmitter {
// Export a singleton instance // Export a singleton instance
export const logger = new Logger(); export const logger = new Logger();
// Export class if needed for multiple instances
export { Logger };

View File

@@ -1,161 +0,0 @@
/**
* Retry Utilities with Exponential Backoff
*
* Provides retry logic with exponential backoff and jitter
* to prevent thundering herd and optimize API quota usage.
*/
import { sleep } from './helpers.js';
import { logger } from './logger.js';
/**
* Calculate exponential backoff delay with jitter
*
* @param {number} attempt - Current attempt number (0-based)
* @param {number} baseMs - Base delay in milliseconds
* @param {number} maxMs - Maximum delay in milliseconds
* @returns {number} Delay in milliseconds
*/
export function calculateBackoff(attempt, baseMs = 1000, maxMs = 30000) {
// Exponential: baseMs * 2^attempt
const exponential = baseMs * Math.pow(2, attempt);
// Cap at max
const capped = Math.min(exponential, maxMs);
// Add random jitter (±25%) to prevent thundering herd
const jitter = capped * 0.25 * (Math.random() * 2 - 1);
return Math.floor(capped + jitter);
}
/**
* Retry a function with exponential backoff
*
* @param {Function} fn - Async function to retry (receives attempt number)
* @param {Object} options - Retry options
* @param {number} options.maxAttempts - Maximum number of attempts (default: 5)
* @param {number} options.baseMs - Base delay in milliseconds (default: 1000)
* @param {number} options.maxMs - Maximum delay in milliseconds (default: 30000)
* @param {Function} options.shouldRetry - Function to determine if error is retryable
* @param {Function} options.onRetry - Callback before each retry (error, attempt, backoffMs)
* @returns {Promise<any>} Result from fn
* @throws {Error} Last error if all attempts fail
*/
export async function retryWithBackoff(fn, options = {}) {
const {
maxAttempts = 5,
baseMs = 1000,
maxMs = 30000,
shouldRetry = () => true,
onRetry = null
} = options;
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn(attempt);
} catch (error) {
lastError = error;
// Check if this is the last attempt
if (attempt === maxAttempts - 1) {
logger.debug(`[Retry] All ${maxAttempts} attempts exhausted`);
throw error;
}
// Check if error is retryable
if (!shouldRetry(error, attempt)) {
logger.debug(`[Retry] Error not retryable, aborting: ${error.message}`);
throw error;
}
// Calculate backoff
const backoffMs = calculateBackoff(attempt, baseMs, maxMs);
logger.debug(`[Retry] Attempt ${attempt + 1}/${maxAttempts} failed, retrying in ${backoffMs}ms`);
// Call onRetry callback
if (onRetry) {
await onRetry(error, attempt, backoffMs);
}
// Wait before retrying
await sleep(backoffMs);
}
}
// Should never reach here, but just in case
throw lastError;
}
/**
* Check if an error is retryable (5xx errors or network issues)
*
* @param {Error} error - Error to check
* @returns {boolean} True if error is retryable
*/
export function isRetryableError(error) {
const message = error.message?.toLowerCase() || '';
// Network errors
if (message.includes('econnrefused') ||
message.includes('econnreset') ||
message.includes('etimedout') ||
message.includes('network') ||
message.includes('fetch failed')) {
return true;
}
// 5xx server errors
if (message.includes('500') ||
message.includes('502') ||
message.includes('503') ||
message.includes('504')) {
return true;
}
// Rate limits (429) are retryable
if (message.includes('429') || message.includes('rate limit')) {
return true;
}
return false;
}
/**
* Check if an error is NOT retryable (4xx client errors except 429)
*
* @param {Error} error - Error to check
* @returns {boolean} True if error should not be retried
*/
export function isNonRetryableError(error) {
const message = error.message?.toLowerCase() || '';
// Authentication errors (401, 403)
if (message.includes('401') ||
message.includes('403') ||
message.includes('unauthorized') ||
message.includes('forbidden')) {
return true;
}
// Bad request (400)
if (message.includes('400') || message.includes('bad request')) {
return true;
}
// Not found (404)
if (message.includes('404') || message.includes('not found')) {
return true;
}
return false;
}
export default {
calculateBackoff,
retryWithBackoff,
isRetryableError,
isNonRetryableError
};