Use req.reject() vs req.error(), return structured errors with target fields, and wire i18n error message keys.
req.reject() vs req.error() — abort vs accumulateA consistent error API is a quality signal. Fiori apps expect @odata.error formatted responses; REST clients expect structured JSON. CAP gives you primitives to produce both from one place, with target-field-level errors that map directly to Fiori input field highlights.
req.reject() — abort immediatelythis.before('CREATE', 'Books', (req) => {
if (!req.data.title)
return req.reject(400, 'Book must have a title')
// chain stops here; nothing else runs
})
req.error() — accumulate, then abortthis.before('CREATE', 'Books', (req) => {
if (!req.data.title)
req.error(400, 'Title is required', 'title') // target = field name
if (req.data.price < 0)
req.error(400, 'Price must be positive', 'price')
// Both errors collected; CAP auto-rejects with all messages after BEFORE phase
})
req.error({
code: 'INVALID_PRICE',
message: 'Price must be between {0} and {1}',
args: [0, 9999],
target: 'price', // maps to Fiori field highlighting
status: 400
})
{
"error": {
"code": "INVALID_PRICE",
"message": "Price must be between 0 and 9999",
"target": "price",
"@Common.numericSeverity": 4,
"details": []
}
}
| Status | Use case |
|---|---|
| 400 | Bad request / validation failure |
| 401 | Not authenticated |
| 403 | Authenticated but not authorized |
| 404 | Entity not found |
| 409 | Conflict (e.g., already processed) |
| 422 | Semantic error (valid syntax, invalid business rule) |
| 500 | Unexpected server error |
this.on('submitOrder', async (req) => {
const order = await SELECT.one.from('Orders').where({ ID: req.data.orderId })
if (!order) return req.reject(404, `Order not found: ${req.data.orderId}`)
try {
await externalService.notify(order)
} catch (e) {
// Log internally, return friendly message to client
console.error('Notification failed:', e)
return req.reject(502, 'Failed to notify external service. Please retry.')
}
})
# _i18n/messages.properties
ORDER_NOT_FOUND=Order {0} not found
PRICE_INVALID=Price must be between {0} and {1}
req.reject(404, 'ORDER_NOT_FOUND', [req.data.orderId])
// ↑ i18n key ↑ substitution args
CAP resolves the key from _i18n/messages_<locale>.properties based on Accept-Language.
// Catch all unhandled errors across the service
this.on('error', (err, req) => {
console.error(`[${req.event}] ${req.target?.name}:`, err.message)
// Optionally transform error before it's sent
if (err.code === 'SQLITE_CONSTRAINT') {
err.status = 409
err.message = 'A duplicate entry already exists'
}
})
CAP automatically formats errors based on the Accept header:
Accept: application/json → OData error format
Accept: application/json;odata → same
Content-Type: application/json → same
// OData format (Fiori-compatible)
{
"error": {
"code": "400",
"message": "Title is required",
"target": "title"
}
}
const { POST } = cds.test('.').in(__dirname + '/..')
it('rejects book without title', async () => {
const { status, data } = await POST('/catalog/Books', { price: 9.99 })
expect(status).toBe(400)
expect(data.error.message).toMatch(/title/i)
expect(data.error.target).toBe('title')
})
it('returns 404 for missing order', async () => {
const { status } = await POST('/orders/submitOrder', { orderId: 'non-existent' })
expect(status).toBe(404)
})
'price'req.error(): validate both title and price in one passAccept-Language: dethis.on('error', ...) handler that logs all errors with timestampreq.reject() stops the chain; req.error() accumulatesreq.reject() are resolved from _i18n/messages.properties