Error Boundaries
Error boundaries protect nodes in your workflow. When a protected node fails, Graph Compose calls the boundary's HTTP endpoint with details about the failure, giving you a place to run cleanup logic, send alerts, or release held resources.
How error boundaries work
An error boundary is a node with type: "error_boundary". It defines a list of protectedNodes (the node IDs it guards) and an http configuration (the endpoint Graph Compose calls when a failure occurs).
When a protected node fails:
- Graph Compose finds the most specific error boundary protecting that node
- The boundary's HTTP endpoint is called with an
errorContextobject injected into the request body - The failed node is marked as protected
- All downstream dependents of the failed node are skipped
- If the boundary itself has downstream dependents, those continue to run after the boundary completes
In this workflow, if charge_payment fails, send_confirmation is skipped and payment_error_handler fires. After the handler completes, release_inventory runs.
When a node fails
A node is considered failed when its HTTP request returns a non-success response or cannot be completed. Specifically, a node fails when:
- HTTP status code is 300 or higher. Any response with a status of 300, 400, 500, or above is treated as a failure. A
200,201, or other 2xx response is a success. - A network error occurs. Connection timeouts, DNS resolution failures, and connection refused errors are all treated as failures with a default status code of 500.
- A polling condition is never satisfied. If a node uses
pollUntiland the condition is not met before retries are exhausted, the node fails.
Retries come first
Error boundaries do not fire on the first failure. If the failed node has a retry policy configured, Graph Compose retries the node according to that policy. The error boundary only triggers after all retry attempts have been exhausted.
For example, if a node is configured with maximumAttempts: 3 and the first two attempts return a 500 status, Graph Compose retries. If the third attempt also fails, the node is marked as failed and the error boundary fires.
Non-retryable failures
Two types of failures skip the retry policy entirely and fail the node immediately:
- Secret resolution errors. If a template expression references a secret that does not exist (e.g.,
{{ $secret('missing_key') }}), the node fails without retrying. - Validation errors. If the node has a validation schema and the upstream data does not match the expected shape, the node fails without retrying.
These failures still trigger error boundaries if the node is protected.
Define an error boundary
An error boundary needs an ID, a list of protected node IDs, and an HTTP endpoint to call on failure. The endpoint is your own service. Graph Compose does not provide built-in error handling logic.
import { GraphCompose } from '@graph-compose/client'
const graph = new GraphCompose({
token: process.env.GRAPH_COMPOSE_TOKEN
})
graph
.node('charge_payment')
.post('https://api.example.com/payments')
.withBody({
orderId: '{{ context.orderId }}',
amount: '{{ context.amount }}'
})
.end()
.node('send_confirmation')
.post('https://api.example.com/notifications')
.withBody({
email: '{{ context.email }}',
paymentId: '{{ results.charge_payment.data.id }}'
})
.withDependencies(['charge_payment'])
.end()
.errorBoundary('payment_error_handler', ['charge_payment'])
.post('https://api.example.com/payments/rollback')
.withBody({
orderId: '{{ context.orderId }}'
})
.end()
If charge_payment fails, send_confirmation is skipped and payment_error_handler calls your rollback endpoint.
The errorContext object
When a boundary fires, Graph Compose injects an errorContext object into the request body sent to your endpoint. If you defined a body on the boundary, errorContext is merged into it.
Request body sent to your endpoint
{
// Your custom body fields (if any)
orderId: 'order_456',
// Automatically injected by Graph Compose
errorContext: {
message: 'Payment processing failed: insufficient funds',
failedNodeId: 'charge_payment',
timestamp: 1710000000000
}
}
| Field | Type | Description |
|---|---|---|
message | string | The error message from the failed node |
failedNodeId | string | The ID of the node that failed |
timestamp | number | Unix timestamp (milliseconds) when the boundary was triggered |
Your endpoint receives this context automatically. Use it to log the failure, determine what to clean up, or route to different recovery logic based on the failed node.
Reference failed node data
Error boundaries support template expressions in URLs, headers, and body, just like HTTP nodes. You can reference results from any node that completed before the failure, including the failed node's own response data.
Even when a node fails with an HTTP error (e.g., a 402 or 500 response), its response data is still stored. This means you can reference results.failedNodeId.data in your error boundary configuration.
Reference failed node response
graph
.errorBoundary('payment_error_handler', ['charge_payment'])
.post('https://api.example.com/payments/rollback')
.withHeaders({
'X-Original-Payment-Id': '{{ results.charge_payment.data.id }}'
})
.withBody({
orderId: '{{ context.orderId }}',
amount: '{{ results.charge_payment.data.amount }}',
currency: '{{ results.charge_payment.data.currency }}',
reason: '{{ results.charge_payment.data.error }}'
})
.end()
This boundary sends the failed payment's ID, amount, currency, and error reason to the rollback endpoint.
What happens after a boundary fires
When an error boundary triggers, Graph Compose updates the workflow state as follows:
| Node | State | Runs? |
|---|---|---|
| The failed node | Protected (failure recorded) | Already attempted and failed |
| Downstream dependents of the failed node | Skipped | No |
| The error boundary itself | Executes as an HTTP call | Yes |
| Downstream dependents of the boundary | Execute normally after boundary completes | Yes |
This means you can chain nodes after an error boundary. For example, after a payment rollback, you might want to send an alert:
Chain after boundary
graph
.node('charge_payment')
.post('https://api.example.com/payments')
.withBody({ orderId: '{{ context.orderId }}' })
.end()
.errorBoundary('payment_error_handler', ['charge_payment'])
.post('https://api.example.com/payments/rollback')
.end()
.node('notify_support')
.post('https://api.example.com/alerts')
.withBody({
type: 'payment_rollback',
orderId: '{{ context.orderId }}'
})
.withDependencies(['payment_error_handler'])
.end()
notify_support depends on payment_error_handler. It only runs if the boundary fires and completes successfully. If the payment succeeds, the boundary never fires and notify_support is not triggered.
If the boundary's own HTTP call fails, its downstream dependents are also skipped. The workflow completes, but with failure states recorded on those nodes.
Multiple boundaries and specificity
You can define multiple error boundaries that protect overlapping sets of nodes. When a node fails, Graph Compose selects the most specific boundary using two criteria:
- Depth: Boundaries nested inside other boundaries have higher depth. The deepest boundary wins.
- Tie-breaker: If two boundaries have the same depth, the one protecting fewer nodes wins.
Only one boundary fires per failure.
graph
.node('fetch_user')
.get('https://api.example.com/users/{{ context.userId }}')
.end()
.node('fetch_orders')
.get('https://api.example.com/orders')
.withDependencies(['fetch_user'])
.end()
.node('process_orders')
.post('https://api.example.com/orders/process')
.withDependencies(['fetch_orders'])
.end()
// Specific: only protects fetch_orders
.errorBoundary('order_error', ['fetch_orders'])
.post('https://api.example.com/errors/orders')
.end()
// General: protects all three nodes
.errorBoundary('general_error', [
'fetch_user', 'fetch_orders', 'process_orders'
])
.post('https://api.example.com/errors/general')
.end()
With this setup:
| Failed node | Boundary that fires | Why |
|---|---|---|
fetch_user | general_error | Only general_error protects it |
fetch_orders | order_error | More specific (protects 1 node vs 3, higher depth) |
process_orders | general_error | Only general_error protects it |
The order_error boundary is nested inside general_error because all of order_error's protected nodes are also protected by general_error. This gives order_error higher depth and therefore higher priority for fetch_orders failures.
Retry policies on boundaries
Error boundaries support the same activityConfig as HTTP nodes. You can configure retry policies and timeouts to make the boundary's own HTTP call more resilient.
Boundary with retries
graph
.errorBoundary('payment_error_handler', ['charge_payment'])
.post('https://api.example.com/payments/rollback')
.withRetries({
maximumAttempts: 3,
initialInterval: '2s',
backoffCoefficient: 2,
maximumInterval: '10s'
})
.withStartToCloseTimeout('30s')
.end()
If the rollback endpoint is temporarily unavailable, Graph Compose retries the boundary call up to 3 times with exponential backoff.
Validation rules
Graph Compose enforces these rules for error boundaries at both build time (SDK) and submission time (API):
- An error boundary must protect at least one node.
- All protected node IDs must reference nodes that exist in the workflow.
- An error boundary cannot protect another error boundary.
- Error boundaries do not support
dependencies,conditions, orvalidation. These fields are only available on HTTP nodes.
Best practices
- Protect nodes whose failure requires cleanup: payment charges, inventory holds, resource provisioning, token generation.
- Keep boundary endpoints idempotent. If the boundary itself is retried, your cleanup logic should handle being called more than once.
- Use the
errorContext.failedNodeIdfield to route cleanup logic when a single boundary protects multiple nodes. - Use template expressions to pass the failed node's response data to your cleanup endpoint. This lets you undo specific operations (e.g., reverse a charge by referencing the payment ID).
- Chain nodes after a boundary when you need post-failure actions like alerts or audit logging.
- Use nested boundaries for layered protection: a specific boundary for payment failures, a general boundary for everything else.