Primary reference: https://cap.cloud.sap/docs/node.js/events#errors Messages: https://cap.cloud.sap/docs/node.js/events#req-messages
The four messaging methods
// REJECT — stops processing, returns error HTTP response
req.reject(422, 'Quantity {0} exceeds available stock {1}', [qty, stock])
// WARN — non-blocking, adds to @SAP.Messages OData annotation
req.warn(200, 'STOCK_LOW', [product.title])
// INFO — informational message added to response
req.info(200, 'Order will be shipped by {0}', [shipDate])
// ERROR — accumulates error, does NOT stop processing (use reject for that) req.error(400, 'Field {0} is required', ['title']) // ... then call: req.reject() // throws all accumulated errors at once
## HTTP status codes — when to use what
| Code | When |
|------|------|
| 400 | Bad request / validation failure (malformed input) |
| 401 | Not authenticated |
| 403 | Authenticated but not authorized |
| 404 | Entity not found |
| 409 | Conflict (e.g. duplicate, already processed) |
| 422 | Unprocessable entity (semantic/business rule failure) |
| 500 | Unexpected server error (use sparingly, prefer specific codes) |
## i18n error messages
Define messages in `_i18n/messages.properties`:
```properties
ORDER_CLOSED=Order {0} is already closed
STOCK_INSUFFICIENT=Insufficient stock: requested {0}, available {1}
DUPLICATE_ORDER=An order for product {0} already exists today
Reference by key in handlers:
req.reject(409, 'ORDER_CLOSED', [req.data.orderID])
req.reject(422, 'STOCK_INSUFFICIENT', [qty, stock])
CAP resolves the key using the user's locale automatically.
Structured errors with targets
Target a specific field in OData error responses (Fiori shows inline validation):
req.reject({
code: 422,
message: 'Price must be positive',
target: 'price', // field name in the entity
status: 422
})
// Or with req.error for accumulation:
req.error({ code: 400, message: 'Title is required', target: 'title' })
req.error({ code: 400, message: 'Price is required', target: 'price' })
req.reject() // throws all at once
Global error handler
Register in server.js or your AppService:
cds.on('error', (err, req) => {
// Log all errors centrally
console.error(`[${req?.user?.id}] ${err.code}: ${err.message}`)
// You can mutate the error before it's sent to the client
if (err.status === 500) {
err.message = 'An internal error occurred. Please contact support.'
}
})
Error handling for remote service calls
try {
const result = await S4.run(SELECT.from('A_BusinessPartner').where({ BusinessPartner: id }))
return result
} catch (err) {
if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
req.reject(503, 'Backend system is currently unavailable')
} else if (err.status === 404) {
req.reject(404, 'Business partner {0} not found in S/4HANA', [id])
} else {
// Re-throw unexpected errors (they'll become 500)
throw err
}
}
Custom error class
For reusable domain errors:
class BusinessError extends cds.error {
constructor(code, message, args = []) {
super(message, { code, status: 422 })
this.messageArgs = args
}
}
// Usage:
throw new BusinessError('ORDER_CLOSED', 'Order {0} is already closed', [id])
Common mistakes to avoid
- ❌ Using
throw new Error(...)directly — bypasses CAP's error formatting - ❌ Returning error objects instead of calling
req.reject()— agent gets no error - ❌ Sending stack traces to clients in production
- ❌ Using status 500 for business rule violations (use 409/422)
- ❌ Forgetting
targeton validation errors — Fiori Elements needs it for inline display - ❌ Swallowing errors in remote service calls (silent failures)