Error Handling
When building functions in Keel, you'll often need to communicate specific error conditions back to API consumers. The @teamkeel/sdk package provides a set of built-in error types that map to standard HTTP status codes and error responses.
Built-in Error Types
Keel provides three error types that you can throw from your functions:
import { errors } from "@teamkeel/sdk";| Error Type | HTTP Status | Error Code | Description |
|---|---|---|---|
errors.NotFound | 404 | ERR_RECORD_NOT_FOUND | The requested resource could not be found |
errors.BadRequest | 400 | ERR_INVALID_INPUT | The request is invalid or malformed |
errors.Unknown | 500 | ERR_UNKNOWN | An unexpected error occurred |
Throwing Errors in Functions
You can throw errors from any function, whether it's a custom function, action hook, job, or subscriber. To throw an error, use the throw keyword with new and the appropriate error class.
Basic Usage
import { GetProduct, errors } from "@teamkeel/sdk";
const hooks: GetProductHooks = {
afterQuery(ctx, inputs, product) {
if (!product) {
throw new errors.NotFound();
}
if (!product.isActive) {
throw new errors.BadRequest("Product is no longer available");
}
return product;
},
};
export default GetProduct(hooks);Custom Error Messages
All error types accept an optional message parameter that will be included in the API response:
// With default message
throw new errors.NotFound();
// With custom message
throw new errors.NotFound("No order found with that reference");
// BadRequest with validation context
throw new errors.BadRequest("Quantity must be greater than zero");
// Unknown for unexpected conditions
throw new errors.Unknown("Failed to process payment");Error Responses to Clients
When you throw an error, Keel transforms it into a structured JSON response. The format depends on which API protocol you're using.
JSON-RPC Response
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": "ERR_RECORD_NOT_FOUND",
"message": "record not found"
}
}GraphQL Response
{
"data": null,
"errors": [
{
"message": "record not found",
"extensions": {
"code": "ERR_RECORD_NOT_FOUND"
}
}
]
}Validation Errors
For input validation, use errors.BadRequest. This signals to the client that their request was malformed or contained invalid data.
import { CreateOrder, errors } from "@teamkeel/sdk";
const hooks: CreateOrderHooks = {
beforeWrite(ctx, inputs, values) {
// Validate quantity
if (values.quantity <= 0) {
throw new errors.BadRequest("Quantity must be a positive number");
}
// Validate date
if (values.deliveryDate < new Date()) {
throw new errors.BadRequest("Delivery date cannot be in the past");
}
// Validate business rules
if (values.quantity > 1000 && !ctx.isAuthenticated) {
throw new errors.BadRequest("Large orders require authentication");
}
return values;
},
};
export default CreateOrder(hooks);Keel automatically validates inputs against your schema before your function runs, but that is not the only
check your function's writes are subject to. For business rules on built-in create, update and delete
actions, the @validate attribute lets you declare the rule in your schema
instead of writing code. A @validate rule declared on a model is enforced by the database, so it also
constrains the writes your function makes itself, including through the Model API and useDatabase, and it
cannot be bypassed by writing SQL by hand. Inside a function, use errors.BadRequest for rules that go beyond
what a rule expression can reach.
Permission Errors
For access control, use the permissions API rather than throwing errors directly. When you call permissions.deny(), Keel throws a permission error internally and returns an ERR_PERMISSION_DENIED response.
import { DeleteOrder, permissions } from "@teamkeel/sdk";
const hooks: DeleteOrderHooks = {
beforeWrite(ctx, inputs, record) {
// Only allow deletion of draft orders
if (record.status !== "Draft") {
permissions.deny();
}
// Only the creator can delete
if (record.createdById !== ctx.identity?.id) {
permissions.deny();
}
},
};
export default DeleteOrder(hooks);Calling permissions.deny() immediately stops execution and returns an error. Any code after this call will not run.
For custom read and write functions, you must explicitly call permissions.allow() for the request to succeed:
import { ProcessPayment, permissions } from "@teamkeel/sdk";
export default ProcessPayment(async (ctx, inputs) => {
// Validate the API key
const apiKey = ctx.headers.get("X-API-Key");
if (apiKey !== ctx.secrets.PAYMENT_API_KEY) {
permissions.deny();
}
// Must explicitly allow
permissions.allow();
// Process the payment...
});Automatic Database Errors
Keel automatically catches and transforms common database errors into appropriate API responses. You don't need to handle these yourself:
| Database Error | Error Code | Example Cause |
|---|---|---|
| Unique constraint violation | ERR_INVALID_INPUT | Inserting a duplicate value for a @unique field |
| Foreign key constraint | ERR_INVALID_INPUT | Referencing a record that doesn't exist |
| Not null constraint | ERR_INVALID_INPUT | Missing a required field value |
| Record not found | ERR_RECORD_NOT_FOUND | Updating or deleting a non-existent record |
| Validation rule violation | ERR_CONFLICT | A write that leaves a record breaking a model-level @validate rule |
ERR_CONFLICT is returned with HTTP 409 and carries the rule's own message, so you do not need to catch it and rewrite it:
{
"error": {
"code": "ERR_CONFLICT",
"message": "a validation rule failed",
"data": { "errors": [{ "error": "quantity must be greater than zero" }] }
}
}For example, if you try to create a product with a duplicate SKU:
{
"error": {
"code": "ERR_INVALID_INPUT",
"message": "the value for the unique field 'sku' must be unique"
}
}Best Practices
Use the Right Error Type
Choose the error type that best describes the problem:
// Resource doesn't exist → NotFound
if (!order) {
throw new errors.NotFound("Order not found");
}
// Invalid input or business rule violation → BadRequest
if (order.status === "Shipped") {
throw new errors.BadRequest("Cannot modify a shipped order");
}
// Unexpected system error → Unknown
if (!paymentResult.success) {
throw new errors.Unknown("Payment processing failed");
}Provide Helpful Messages
Error messages should help API consumers understand what went wrong and how to fix it:
// ❌ Vague
throw new errors.BadRequest("Invalid input");
// ✅ Specific
throw new errors.BadRequest("Email address is already registered");
// ❌ Technical jargon
throw new errors.NotFound("FK constraint violation on order_id");
// ✅ User-friendly
throw new errors.NotFound("The referenced order does not exist");Validate Early
Check for error conditions at the start of your function to fail fast:
import { UpdateInventory, errors, models } from "@teamkeel/sdk";
const hooks: UpdateInventoryHooks = {
async beforeWrite(ctx, inputs, values, record) {
// Validate early
if (values.quantity < 0) {
throw new errors.BadRequest("Quantity cannot be negative");
}
// Check business rules
const location = await models.location.findOne({ id: record.locationId });
if (!location?.isActive) {
throw new errors.BadRequest("Cannot update inventory for inactive location");
}
// Proceed with the update
return values;
},
};
export default UpdateInventory(hooks);Don't Catch and Rethrow
Let Keel handle error transformation. Don't wrap SDK errors in try-catch blocks unless you need to add context:
// ❌ Unnecessary
try {
const product = await models.product.findOne({ id: inputs.productId });
if (!product) {
throw new errors.NotFound();
}
} catch (e) {
throw e; // Just rethrows the same error
}
// ✅ Direct
const product = await models.product.findOne({ id: inputs.productId });
if (!product) {
throw new errors.NotFound("Product not found");
}Use Transactions for Complex Operations
For operations that should succeed or fail together, enable database transactions. If an error is thrown, all changes are automatically rolled back:
import { TransferStock, errors, models } from "@teamkeel/sdk";
const fn = async (ctx, inputs) => {
// Reduce stock at source
const source = await models.inventory.update(
{ id: inputs.sourceId },
{ quantity: { decrement: inputs.quantity } }
);
if (source.quantity < 0) {
// This will roll back the update above
throw new errors.BadRequest("Insufficient stock at source location");
}
// Increase stock at destination
await models.inventory.update(
{ id: inputs.destinationId },
{ quantity: { increment: inputs.quantity } }
);
};
// Enable transaction support
fn.config = { dbTransaction: true };
export default TransferStock(fn);