Skip to content

Errors

dAvePi returns a stable error envelope across every surface:

{
"error": {
"code": "VALIDATION",
"message": "Validation failed: amount: Path `amount` is required.",
"details": { /* per-code, see below */ }
}
}
SurfaceHow it surfaces
RESTResponse body, with the appropriate HTTP status.
GraphQLerrors[0].message + errors[0].extensions.code and errors[0].extensions.details.
MCP{ isError: true, content: [{ type: 'text', text: <serialized error JSON> }] }.
Typed clientThrown as DavepiError: { status, code, message, details? }.

code is the part to read in code. message is for humans — agents should branch on code.

CodeHTTPRecoverable?When
VALIDATION400yesMongoose / framework validation failed. details carries the per-field reasons.
INVALID_ID400yesA path param looks like an ObjectId but isn’t valid.
INVALID_TRANSITION400yesA state-machine field was set to a value not declared in transitions[current]. details carries field, current, attempted, allowed.
UNAUTHORIZED401usuallyMissing / invalid / expired Bearer token. The MCP variant carries auth: true so clients can refresh.
FORBIDDEN403noCaller has a valid token but lacks the role for this action (e.g. trying to use an unsafe: true aggregation without acl.list).
NOT_FOUND404noResource doesn’t exist for this caller. Note: cross-tenant reads also return 404, not 403 — we don’t disclose existence to the wrong tenant.
METHOD_NOT_ALLOWED405noVerb not supported on this path.
CONFLICT409noGeneric conflict.
DUPLICATE409sometimesMongo unique-index violation. details carries the duplicate field. Recoverable if the agent can pick a different value.
IDEMPOTENCY_CONFLICT409noIdempotency-Key was reused with a different request body. The agent should pick a new key.
IDEMPOTENCY_IN_PROGRESS409yesConcurrent retry hit while the first call was still running. Wait briefly and retry under the same key.
RATE_LIMITED429yesRate limiter tripped. Retry after the Retry-After header.
INTERNAL500noUnknown error. In production, message is reduced to "Internal server error" deliberately — the real error is in the server log under the request’s reqId.

The recoverable column is what the framework sets on the MCP error payload. Agents should retry recoverable errors after fixing their input; non-recoverable errors are a structural mismatch (wrong tenant, wrong role, etc.).

{
"details": {
"fields": {
"amount": "Path `amount` is required.",
"stage": "`won` is not a valid enum value for path `stage`."
}
}
}
{
"details": {
"field": "status",
"current": "review",
"attempted": "archived",
"allowed": ["approved", "rejected"]
}
}
{
"details": {
"field": "slug",
"value": "acme"
}
}
{
"details": {
"originalBodyHash": "5c6f...",
"submittedBodyHash": "8a3e..."
}
}
{
"details": {
"retryAfterSeconds": 30
}
}

The HTTP response also carries a Retry-After header — both are populated.

In production (NODE_ENV=production), unknown errors (anything that isn’t an AppError subclass) are reduced to:

{ "error": { "code": "INTERNAL", "message": "Internal server error" } }

The actual error is logged at error level with the request’s reqId, so an operator can correlate the response with the log line. Do not write res.status(500).send(err.message) — that leaks stack traces and internal paths to the wire.

const { NotFoundError, ValidationError } = require('./utils/errors');
app.get('/api/v1/foo/:id/custom', auth(true), asyncHandler(async (req, res) => {
const doc = await Foo.findOne({ _id: req.params.id, userId: req.user.user_id });
if (!doc) throw new NotFoundError('foo');
if (!doc.canFrobnicate) throw new ValidationError('foo cannot be frobnicated');
// ...
}));

asyncHandler forwards the rejection to the terminal errorHandler, which formats the envelope. Don’t write the envelope yourself — the formatter ensures consistency with every auto-generated route.