Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions src/utils/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
* Error Classification and User-Friendly Messages
*
* Provides utilities for classifying errors and generating
* helpful error messages for users.
*/

/**
* Error type classifications
*/
export const ErrorType = {
// Network-related errors
NETWORK: 'network',
TIMEOUT: 'timeout',

// API errors
API_ERROR: 'api_error',
API_RATE_LIMIT: 'api_rate_limit',
API_UNAUTHORIZED: 'api_unauthorized',
API_NOT_FOUND: 'api_not_found',
API_SERVER_ERROR: 'api_server_error',

// Discord-specific errors
DISCORD_PERMISSION: 'discord_permission',
DISCORD_CHANNEL_NOT_FOUND: 'discord_channel_not_found',
DISCORD_MISSING_ACCESS: 'discord_missing_access',

// Configuration errors
CONFIG_MISSING: 'config_missing',
CONFIG_INVALID: 'config_invalid',

// Unknown/generic errors
UNKNOWN: 'unknown',
};

/**
* Classify an error into a specific error type
*
* @param {Error} error - The error to classify
* @param {Object} context - Optional context (response, statusCode, etc.)
* @returns {string} Error type from ErrorType enum
*/
export function classifyError(error, context = {}) {
if (!error) return ErrorType.UNKNOWN;

const message = error.message?.toLowerCase() || '';
const code = error.code || context.code;
const status = error.status || context.status || context.statusCode;

// Network errors
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT') {
return ErrorType.NETWORK;
}
if (code === 'ETIMEDOUT' || message.includes('timeout')) {
return ErrorType.TIMEOUT;
}
Comment on lines +51 to +56
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ETIMEDOUT classification is unreachable for TIMEOUT.

ETIMEDOUT is checked at line 51 and returns NETWORK, so the ETIMEDOUT check at line 54 for TIMEOUT will never execute. Since ETIMEDOUT specifically indicates a connection timeout, it should likely return TIMEOUT, not NETWORK.

🐛 Proposed fix
   // Network errors
-  if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ETIMEDOUT') {
+  if (code === 'ECONNREFUSED' || code === 'ENOTFOUND') {
     return ErrorType.NETWORK;
   }
   if (code === 'ETIMEDOUT' || message.includes('timeout')) {
     return ErrorType.TIMEOUT;
   }
🤖 Prompt for AI Agents
In `@src/utils/errors.js` around lines 51 - 56, The ETIMEDOUT branch is currently
classified as NETWORK and thus never reaches the TIMEOUT check; update the logic
in the function that maps error codes (uses the variables code, message and the
ErrorType enum) so ETIMEDOUT maps to ErrorType.TIMEOUT: remove ETIMEDOUT from
the first conditional (the one returning ErrorType.NETWORK) or explicitly check
ETIMEDOUT before the NETWORK branch, and ensure the second conditional (checking
ETIMEDOUT or message.includes('timeout')) returns ErrorType.TIMEOUT.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ETIMEDOUT classified as NETWORK instead of TIMEOUT

Medium Severity

The error code ETIMEDOUT is checked in the network errors condition (line 51) before the timeout condition (line 54). When an error has code === 'ETIMEDOUT', it returns ErrorType.NETWORK instead of ErrorType.TIMEOUT. The second check for ETIMEDOUT on line 54 becomes unreachable for that code path. This causes timeout errors to show network-related user messages instead of timeout-specific guidance.

Fix in Cursor Fix in Web

if (message.includes('fetch failed') || message.includes('network')) {
return ErrorType.NETWORK;
}

// HTTP status code errors
if (status) {
if (status === 401 || status === 403) {
return ErrorType.API_UNAUTHORIZED;
}
if (status === 404) {
return ErrorType.API_NOT_FOUND;
}
if (status === 429) {
return ErrorType.API_RATE_LIMIT;
}
if (status >= 500) {
return ErrorType.API_SERVER_ERROR;
}
if (status >= 400) {
return ErrorType.API_ERROR;
}
}

// Discord-specific errors
if (code === 50001 || message.includes('missing access')) {
return ErrorType.DISCORD_MISSING_ACCESS;
}
if (code === 50013 || message.includes('missing permissions')) {
return ErrorType.DISCORD_PERMISSION;
}
if (code === 10003 || message.includes('unknown channel')) {
return ErrorType.DISCORD_CHANNEL_NOT_FOUND;
}

// Config errors
if (message.includes('config.json not found') || message.includes('enoent')) {
return ErrorType.CONFIG_MISSING;
}
Comment on lines +91 to +94
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

ENOENT detection may miss file-not-found errors.

Node.js filesystem errors set error.code = 'ENOENT', but this code only checks the message. Consider also checking the code variable.

🛠️ Proposed fix
   // Config errors
-  if (message.includes('config.json not found') || message.includes('enoent')) {
+  if (message.includes('config.json not found') || message.includes('enoent') || code === 'ENOENT') {
     return ErrorType.CONFIG_MISSING;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Config errors
if (message.includes('config.json not found') || message.includes('enoent')) {
return ErrorType.CONFIG_MISSING;
}
// Config errors
if (message.includes('config.json not found') || message.includes('enoent') || code === 'ENOENT') {
return ErrorType.CONFIG_MISSING;
}
🤖 Prompt for AI Agents
In `@src/utils/errors.js` around lines 91 - 94, The current detection only
inspects the error message string (`message`) to map to
ErrorType.CONFIG_MISSING; update the logic in the function in
src/utils/errors.js to also check the error object's `code` property (e.g.,
`code === 'ENOENT'`) and handle cases where the input may be an Error object
rather than a string, so that when either message includes 'config.json not
found' or `code === 'ENOENT'` you return ErrorType.CONFIG_MISSING; reference the
existing `message` variable and the `ErrorType.CONFIG_MISSING` symbol when
making this change.

if (message.includes('invalid') && message.includes('config')) {
return ErrorType.CONFIG_INVALID;
}

// API errors (generic)
if (message.includes('api error') || context.isApiError) {
return ErrorType.API_ERROR;
}

return ErrorType.UNKNOWN;
}

/**
* Get a user-friendly error message based on error type
*
* @param {Error} error - The error object
* @param {Object} context - Optional context for more specific messages
* @returns {string} User-friendly error message
*/
export function getUserFriendlyMessage(error, context = {}) {
const errorType = classifyError(error, context);

const messages = {
[ErrorType.NETWORK]: "I'm having trouble connecting to my brain right now. Check if the AI service is running and try again!",

[ErrorType.TIMEOUT]: "That took too long to process. Try again with a shorter message, or wait a moment and retry!",

[ErrorType.API_RATE_LIMIT]: "Whoa, too many requests! Let's take a quick breather. Try again in a minute.",

[ErrorType.API_UNAUTHORIZED]: "I'm having authentication issues with the AI service. An admin needs to check the API credentials.",

[ErrorType.API_NOT_FOUND]: "The AI service endpoint isn't responding. Please check if it's configured correctly.",

[ErrorType.API_SERVER_ERROR]: "The AI service is having technical difficulties. It should recover automatically - try again in a moment!",

[ErrorType.API_ERROR]: "Something went wrong with the AI service. Give it another shot in a moment!",

[ErrorType.DISCORD_PERMISSION]: "I don't have permission to do that! An admin needs to check my role permissions.",

[ErrorType.DISCORD_CHANNEL_NOT_FOUND]: "I can't find that channel. It might have been deleted, or I don't have access to it.",

[ErrorType.DISCORD_MISSING_ACCESS]: "I don't have access to that resource. Please check my permissions!",

[ErrorType.CONFIG_MISSING]: "Configuration file not found! Please create a config.json file (you can copy from config.example.json).",

[ErrorType.CONFIG_INVALID]: "The configuration file has errors. Please check config.json for syntax errors or missing required fields.",

[ErrorType.UNKNOWN]: "Something unexpected happened. Try again, and if it keeps happening, check the logs for details.",
};

return messages[errorType] || messages[ErrorType.UNKNOWN];
}
Comment on lines +114 to +146
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider moving the messages object outside the function.

The messages object is recreated on every call. Moving it to module scope would avoid repeated object creation, though the impact is minor.

♻️ Proposed refactor
+const USER_FRIENDLY_MESSAGES = {
+  [ErrorType.NETWORK]: "I'm having trouble connecting to my brain right now. Check if the AI service is running and try again!",
+  [ErrorType.TIMEOUT]: "That took too long to process. Try again with a shorter message, or wait a moment and retry!",
+  // ... remaining messages
+  [ErrorType.UNKNOWN]: "Something unexpected happened. Try again, and if it keeps happening, check the logs for details.",
+};
+
 export function getUserFriendlyMessage(error, context = {}) {
   const errorType = classifyError(error, context);
-  const messages = {
-    // ... all messages
-  };
-  return messages[errorType] || messages[ErrorType.UNKNOWN];
+  return USER_FRIENDLY_MESSAGES[errorType] || USER_FRIENDLY_MESSAGES[ErrorType.UNKNOWN];
 }
🤖 Prompt for AI Agents
In `@src/utils/errors.js` around lines 114 - 146, The messages map inside
getUserFriendlyMessage is recreated on every call; move the messages object to
module scope (e.g., const MESSAGES = { ... }) and have getUserFriendlyMessage
reference MESSAGES[errorType]. Update references to ErrorType keys accordingly
and keep the default fallback to MESSAGES[ErrorType.UNKNOWN] so behavior is
unchanged while avoiding repeated allocations.


/**
* Get suggested next steps for an error
*
* @param {Error} error - The error object
* @param {Object} context - Optional context
* @returns {string|null} Suggested next steps or null if none
*/
export function getSuggestedNextSteps(error, context = {}) {
const errorType = classifyError(error, context);

const suggestions = {
[ErrorType.NETWORK]: "Make sure the AI service (OpenClaw) is running and accessible.",

[ErrorType.TIMEOUT]: "Try a shorter message or wait a moment before retrying.",

[ErrorType.API_RATE_LIMIT]: "Wait 60 seconds before trying again.",

[ErrorType.API_UNAUTHORIZED]: "Check the OPENCLAW_TOKEN environment variable and API credentials.",

[ErrorType.API_NOT_FOUND]: "Verify the OPENCLAW_URL environment variable points to the correct endpoint.",

[ErrorType.API_SERVER_ERROR]: "The service should recover automatically. If it persists, restart the AI service.",

[ErrorType.DISCORD_PERMISSION]: "Grant the bot appropriate permissions in Server Settings > Roles.",

[ErrorType.DISCORD_CHANNEL_NOT_FOUND]: "Update the channel ID in config.json or verify the channel exists.",

[ErrorType.DISCORD_MISSING_ACCESS]: "Ensure the bot has access to the required channels and roles.",

[ErrorType.CONFIG_MISSING]: "Create config.json from config.example.json and fill in your settings.",

[ErrorType.CONFIG_INVALID]: "Validate your config.json syntax using a JSON validator.",
};

return suggestions[errorType] || null;
}

/**
* Check if an error is retryable (transient failure)
*
* @param {Error} error - The error to check
* @param {Object} context - Optional context
* @returns {boolean} True if the error should be retried
*/
export function isRetryable(error, context = {}) {
const errorType = classifyError(error, context);

// Only retry transient failures, not user/config errors
const retryableTypes = [
ErrorType.NETWORK,
ErrorType.TIMEOUT,
ErrorType.API_SERVER_ERROR,
ErrorType.API_RATE_LIMIT,
];

return retryableTypes.includes(errorType);
}
130 changes: 130 additions & 0 deletions src/utils/retry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Retry Utility with Exponential Backoff
*
* Provides utilities for retrying operations with configurable
* exponential backoff and integration with error classification.
*/

import { isRetryable, classifyError } from './errors.js';
import { warn, error, debug } from '../logger.js';

/**
* Sleep for a specified duration
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

/**
* Calculate delay with exponential backoff
* @param {number} attempt - Current attempt number (0-indexed)
* @param {number} baseDelay - Base delay in milliseconds
* @param {number} maxDelay - Maximum delay in milliseconds
* @returns {number} Delay in milliseconds
*/
function calculateBackoff(attempt, baseDelay, maxDelay) {
// Exponential backoff: baseDelay * 2^attempt
const delay = baseDelay * Math.pow(2, attempt);

// Cap at maxDelay
return Math.min(delay, maxDelay);
}
Comment on lines +27 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding jitter to prevent thundering herd.

Pure exponential backoff without jitter can cause synchronized retry storms when multiple operations fail simultaneously. Adding randomized jitter spreads out retries.

♻️ Proposed fix with jitter
 function calculateBackoff(attempt, baseDelay, maxDelay) {
   // Exponential backoff: baseDelay * 2^attempt
   const delay = baseDelay * Math.pow(2, attempt);
 
-  // Cap at maxDelay
-  return Math.min(delay, maxDelay);
+  // Cap at maxDelay and add jitter (±25%)
+  const cappedDelay = Math.min(delay, maxDelay);
+  const jitter = cappedDelay * 0.25 * (Math.random() * 2 - 1);
+  return Math.max(0, Math.floor(cappedDelay + jitter));
 }
🤖 Prompt for AI Agents
In `@src/utils/retry.js` around lines 27 - 33, The calculateBackoff function uses
pure exponential backoff which risks synchronized retries; modify
calculateBackoff(attempt, baseDelay, maxDelay) to add randomized jitter to the
computed delay (for example by applying a random factor or selecting a random
value within a range around the exponential value) and still cap the result at
maxDelay: compute the exponential delay as now, apply jitter via Math.random to
spread retries, then return Math.min(jitteredDelay, maxDelay). Ensure the jitter
keeps delays non-negative and documented in the function comment.


/**
* Retry an async operation with exponential backoff
*
* @param {Function} fn - Async function to retry
* @param {Object} options - Retry configuration options
* @param {number} options.maxRetries - Maximum number of retry attempts (default: 3)
* @param {number} options.baseDelay - Initial delay in milliseconds (default: 1000)
* @param {number} options.maxDelay - Maximum delay in milliseconds (default: 30000)
* @param {Function} options.shouldRetry - Custom function to determine if error is retryable
* @param {Object} options.context - Optional context for logging
* @returns {Promise<any>} Result of the function
* @throws {Error} Throws the last error if all retries fail
*/
export async function withRetry(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 30000,
shouldRetry = isRetryable,
context = {},
} = options;

let lastError;

for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// Execute the function
return await fn();
} catch (err) {
lastError = err;

// Check if we should retry
const errorType = classifyError(err, context);
const canRetry = shouldRetry(err, context);

// Log the error
if (attempt === 0) {
warn(`Operation failed: ${err.message}`, {
...context,
errorType,
attempt: attempt + 1,
maxRetries: maxRetries + 1,
});
}

// If this was the last attempt or error is not retryable, throw
if (attempt >= maxRetries || !canRetry) {
if (!canRetry) {
error('Operation failed with non-retryable error', {
...context,
errorType,
attempt: attempt + 1,
error: err.message,
});
} else {
error('Operation failed after all retries', {
...context,
errorType,
totalAttempts: attempt + 1,
error: err.message,
});
}
throw err;
}

// Calculate backoff delay
const delay = calculateBackoff(attempt, baseDelay, maxDelay);

debug(`Retrying in ${delay}ms`, {
...context,
attempt: attempt + 1,
maxRetries: maxRetries + 1,
delay,
errorType,
});

// Wait before retrying
await sleep(delay);
}
}

// Should never reach here, but just in case
throw lastError;
}

/**
* Create a retry wrapper with pre-configured options
*
* @param {Object} defaultOptions - Default retry options
* @returns {Function} Configured retry function
*/
export function createRetryWrapper(defaultOptions = {}) {
return (fn, options = {}) => {
return withRetry(fn, { ...defaultOptions, ...options });
};
}