Errors
Typed error codes and the ModuleError factories that produce them.
When a tool fails, the caller is usually a language model deciding what to do next. A thrown
TypeError tells it nothing; ModuleError.missingSecret('RESEND_API_KEY') tells it the user needs
to configure something, which is a different situation with a different fix.
ModuleError is the SDK's structured error. Throw it from a handler and it serializes into a shape
callers can branch on.
import { ErrorCode, ModuleError } from '@odel/module-sdk';Codes
Codes are grouped by cause, and the range is the group:
| Range | Cause | Codes |
|---|---|---|
| 1xxx | Validation | INVALID_INPUT 1001, MISSING_REQUIRED_FIELD 1002, INVALID_FORMAT 1003 |
| 2xxx | Credentials | MISSING_SECRET 2001, INVALID_SECRET 2002, UNAUTHORIZED 2003 |
| 3xxx | Something upstream | API_ERROR 3001, NETWORK_ERROR 3002, TIMEOUT 3003, NOT_FOUND 3004 |
| 4xxx | Limits | RATE_LIMIT_EXCEEDED 4001, QUOTA_EXCEEDED 4002 |
| 5xxx | Your module | INTERNAL_ERROR 5001, NOT_IMPLEMENTED 5002, CONFIGURATION_ERROR 5003 |
The grouping matters more than the individual numbers. A caller that only checks
Math.floor(code / 1000) can still tell "the user must fix their input" from "wait and retry"
without knowing every code you might throw.
Factories
Each factory fills in the code and writes the message, so the same failure reads the same way across every module:
| Factory | Code | Message |
|---|---|---|
validationError(message, metadata?) | INVALID_INPUT | yours |
missingField(fieldName) | MISSING_REQUIRED_FIELD | Required field "x" is missing |
missingSecret(secretName) | MISSING_SECRET | Required secret "X" is not configured |
invalidSecret(secretName, reason?) | INVALID_SECRET | Secret "X" is invalid: reason |
apiError(message, metadata?) | API_ERROR | yours |
networkError(message, metadata?) | NETWORK_ERROR | yours |
timeout(operation, timeoutMs?) | TIMEOUT | Operation "x" timed out |
notFound(resource, identifier?) | NOT_FOUND | Resource "id" not found |
rateLimitError(retryAfter?) | RATE_LIMIT_EXCEEDED | Rate limit exceeded |
quotaExceeded(quotaType?) | QUOTA_EXCEEDED | x quota exceeded |
internalError(message, metadata?) | INTERNAL_ERROR | yours |
notImplemented(feature) | NOT_IMPLEMENTED | Feature "x" is not implemented |
configurationError(message, metadata?) | CONFIGURATION_ERROR | yours |
throw ModuleError.missingSecret('RESEND_API_KEY');
throw ModuleError.invalidSecret('API_KEY', 'must start with sk-');
throw ModuleError.apiError('Service unavailable', { statusCode: 503 });
throw ModuleError.timeout('weather lookup', 30_000);
throw ModuleError.rateLimitError(60); // seconds until a retry is worth tryingINVALID_FORMAT and UNAUTHORIZED have no factory. Use the constructor:
throw new ModuleError(ErrorCode.UNAUTHORIZED, 'Admin access required', { role: 'member' });Metadata
Every factory that takes metadata puts it in the serialized error untouched. It is for machine
detail that doesn't belong in a sentence — a status code, the field that failed, how long to wait:
ModuleError.apiError('Service unavailable', { statusCode: 503 }).toJSON();
// {
// success: false,
// error: 'Service unavailable',
// code: 3001,
// metadata: { statusCode: 503 }
// }metadata is omitted entirely when you don't pass any, rather than serialized as an empty object.
Metadata travels back to the caller. Don't put a secret, a raw upstream response, or a full stack trace in it — attach the status code, not the response body that contained the API key you sent.
In a handler
server.registerTool(
'send-email',
{
description: 'Send an email',
inputSchema: { to: validators.email(), subject: validators.nonEmptyString() }
},
async (args, extra) => {
// Throws ModuleError.missingSecret if the user hasn't configured it
const apiKey = getRequiredSecret(extra, 'RESEND_API_KEY');
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { authorization: `Bearer ${apiKey}` },
body: JSON.stringify(args)
});
if (!response.ok) {
throw ModuleError.apiError('Failed to send email', { statusCode: response.status });
}
const result = { success: true as const, messageId: (await response.json()).id };
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
}
);Throwing is one of two options. A failure the caller should treat as a result — "no matches found",
"this address bounced" — is often better returned as a { success: false, error } value, which is
what SuccessResponseSchema describes. Throw
when the call could not be completed; return when it completed and the answer was no.