Errors
Error types, codes, and HTTP status mappings.
ForgeError
The core error type in Rust. All function errors convert to this type.
use forge::ForgeError;
// Return an error from any function
Err(ForgeError::NotFound("User not found".to_string()))
Variants
| Variant | HTTP Status | Error Code | Use Case |
|---|---|---|---|
NotFound(String) | 404 | NOT_FOUND | Resource does not exist |
Unauthorized(String) | 401 | UNAUTHORIZED | Authentication required or invalid |
Forbidden(String) | 403 | FORBIDDEN | Authenticated but insufficient permissions |
Validation(String) | 400 | VALIDATION_ERROR | Input validation failed |
InvalidArgument(String) | 400 | INVALID_ARGUMENT | Malformed or invalid arguments |
Deserialization(String) | 400 | INTERNAL_ERROR | Failed to deserialize request input |
Timeout(String) | 504 | TIMEOUT | Operation exceeded time limit |
RateLimitExceeded { retry_after, limit, remaining } | 429 | RATE_LIMITED | Rate limit exceeded |
JobCancelled(String) | 409 | JOB_CANCELLED | Job was explicitly cancelled |
Conflict(String) | 409 | CONFLICT | Concurrent modification conflict |
UnprocessableEntity(String) | 422 | UNPROCESSABLE_ENTITY | Input is valid but semantically wrong |
ServiceUnavailable(String) | 503 | SERVICE_UNAVAILABLE | Temporary service outage |
Config(String) | 500 | INTERNAL_ERROR | Configuration error |
Database(sqlx::Error) | 500 | INTERNAL_ERROR | Database operation failed |
Function(String) | 500 | INTERNAL_ERROR | Function execution failed |
Job(String) | 500 | INTERNAL_ERROR | Job execution failed |
Cluster(String) | 500 | INTERNAL_ERROR | Cluster communication failed |
Serialization(String) | 500 | INTERNAL_ERROR | Serialization failed |
Io(std::io::Error) | 500 | INTERNAL_ERROR | IO operation failed |
Internal(String) | 500 | INTERNAL_ERROR | Generic internal error |
InvalidState(String) | 500 | INTERNAL_ERROR | Invalid state transition |
WorkflowSuspended | — | — | Internal signal for workflow suspension; never returned to clients |
RateLimitExceeded Fields
| Field | Type | Description |
|---|---|---|
retry_after | Duration | Time until next request allowed |
limit | u32 | Configured request limit |
remaining | u32 | Tokens remaining (0 when exceeded) |
The HTTP response serializes only top-level retry_after_secs. The Svelte client exposes it as retryAfterSecs; Dioxus keeps the Rust field name retry_after_secs. The limit and remaining fields are available in Rust but not included in the JSON response.
HTTP Response Format
All errors serialize to the same JSON shape:
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "User not found",
"details": null
},
"request_id": "abc123"
}
The data field is omitted from error responses (not set to null). Similarly, the error field is omitted from success responses. The request_id field is also omitted when not set.
Response Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Always false for errors |
data | - | Omitted for errors |
error.code | string | Machine-readable error code |
error.message | string | Human-readable description |
error.details | object | null | Additional context (optional) |
request_id | string | Request identifier for tracing |
TypeScript Error Types
Generated client includes error types:
// types.ts
export interface ForgeError {
code: string;
message: string;
retryAfterSecs?: number;
details?: unknown;
isRateLimited(): boolean;
isUnauthorized(): boolean;
isValidation(): boolean;
}
ForgeClientError
Thrown by API calls when the server returns an error:
import { ForgeClientError } from '$lib/forge';
try {
await getUser({ id });
} catch (e) {
if (e instanceof ForgeClientError) {
console.log(e.code); // "NOT_FOUND"
console.log(e.message); // "User not found"
console.log(e.details); // { userId: "..." }
}
}
Error Codes
| Code | HTTP Status | Description |
|---|---|---|
NOT_FOUND | 404 | Requested resource does not exist |
UNAUTHORIZED | 401 | Authentication required or token invalid |
FORBIDDEN | 403 | User lacks required permissions |
VALIDATION_ERROR | 400 | Input failed validation |
INVALID_ARGUMENT | 400 | Argument format or value invalid |
TIMEOUT | 504 | Request exceeded configured timeout |
RATE_LIMITED | 429 | Too many requests |
JOB_CANCELLED | 409 | Job was explicitly cancelled |
CONFLICT | 409 | Concurrent modification conflict |
UNPROCESSABLE_ENTITY | 422 | Input is syntactically valid but semantically wrong |
SERVICE_UNAVAILABLE | 503 | Temporary service outage |
INTERNAL_ERROR | 500 | Server-side error |
Error Catalog
What you see in the error.code field, what caused it, and what to do about it.
| Error Code | HTTP Status | Common Cause | Fix |
|---|---|---|---|
INVALID_ARGUMENT | 400 | A required argument is the wrong type, out of range, or structurally malformed (e.g., negative ID, empty string where a value is required). Returned by the handler before any business logic runs. | Validate inputs on the client before submitting. Check the error message for the specific field and constraint. |
VALIDATION_ERROR | 400 | Business-rule validation failed inside the handler — the shape was valid but the value violates domain constraints (e.g., quantity is 0, order has no items, email already taken). | Read error.message for the violated rule. Fix the submitted value; no retry makes sense without changing the input. |
UNAUTHORIZED | 401 | No JWT was sent, the token is expired, or the signature is invalid. The gateway rejects the request before the handler runs. | Refresh the token and retry. If missing entirely, redirect to login. Implement onAuthError in the client to handle this globally. |
FORBIDDEN | 403 | The user is authenticated but lacks the required role or permission (e.g., calling ctx.auth.require_role("admin")). Also raised when an outbound HTTP request targets a private/internal host (SSRF guard). | Check whether the user actually has the required role. If so, verify the role name in both the handler and the user's JWT claims. For SSRF: add the target host to the allowlist in forge.toml. |
NOT_FOUND | 404 | The handler looked up a resource by ID and it does not exist in the database. | Verify the ID is correct and the resource has not been deleted. On the frontend, display a "not found" state rather than retrying. |
CONFLICT | 409 | Two concurrent requests tried to modify the same resource (optimistic locking failure), or a job was explicitly cancelled mid-run (JOB_CANCELLED also maps to 409). | For conflicts: retry with fresh data from the server. For cancelled jobs: re-enqueue if the operation should still run. |
RATE_LIMITED | 429 | The caller exceeded the configured request limit for the function or globally. The response includes retry_after_secs. | Wait retryAfterSecs before retrying. Use exponential backoff for repeated failures. Consider increasing the limit in forge.toml if the rate is legitimate traffic. |
TIMEOUT | 504 | The handler or an outbound HTTP call exceeded its configured timeout. Also raised when the circuit breaker is open after repeated downstream failures. | For slow queries: add indexes, paginate results, or raise statement_timeout in forge.toml. For circuit-breaker timeouts: check the downstream service health and wait for the breaker to recover. |
INTERNAL_ERROR | 500 | Catch-all for server-side failures: unhandled ForgeError::Internal, ForgeError::Database (non-query errors), serialization failures, IO errors, or cluster coordination errors. | Check server logs for the request_id from the response. The log includes the full error chain. Fix the root cause — these should never reach production in steady state. |
UNPROCESSABLE_ENTITY | 422 | The request is structurally valid JSON but semantically wrong — the server understands what you sent but cannot act on it (e.g., a date range where end < start). | Read error.message for the specific semantic constraint that failed. Fix the value before retrying. |
SERVICE_UNAVAILABLE | 503 | The server explicitly declared itself unavailable (e.g., during startup, shutdown, or a dependency outage). | Retry with backoff. If persistent, check server health via /_api/health and /_api/ready. The ready endpoint reports unhealthy when blocked workflow runs exist. |
Creating Custom Errors
Return Specific Variants
#[forge::query]
pub async fn get_user(ctx: &QueryContext, id: Uuid) -> Result<User> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(ctx.db())
.await?;
user.ok_or_else(|| ForgeError::NotFound(format!("User {} not found", id)))
}
With Details
#[forge::mutation]
pub async fn create_order(ctx: &MutationContext, items: Vec<Item>) -> Result<Order> {
if items.is_empty() {
return Err(ForgeError::Validation("Order must contain at least one item".into()));
}
for item in &items {
if item.quantity == 0 {
return Err(ForgeError::InvalidArgument(
format!("Item {} has invalid quantity", item.id)
));
}
}
// Create order...
}
Role Enforcement
#[forge::query]
pub async fn get_audit_logs(ctx: &QueryContext) -> Result<Vec<AuditLog>> {
ctx.auth.require_role("admin")?; // Returns ForgeError::Forbidden if missing
// Fetch logs...
}
From Database Errors
#[forge::mutation]
pub async fn create_user(ctx: &MutationContext, email: String) -> Result<User> {
let mut conn = ctx.conn().await?;
sqlx::query_as!(User, "INSERT INTO users (email) VALUES ($1) RETURNING *", &email)
.fetch_one(&mut *conn)
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("unique constraint") {
ForgeError::Validation(format!("Email {} already exists", email))
} else {
e.into()
}
})
}
Error Handling Patterns
Frontend: Per-Query Errors
Subscription stores expose error state:
<script lang="ts">
import { getUser$ } from '$lib/forge';
const user = getUser$({ id });
</script>
{#if user.loading}
<p>Loading...</p>
{:else if user.error}
{#if user.error.code === 'NOT_FOUND'}
<p>User not found</p>
{:else if user.error.code === 'FORBIDDEN'}
<p>You don't have access to this user</p>
{:else}
<p>Error: {user.error.message}</p>
{/if}
{:else}
<p>Welcome, {user.data.name}</p>
{/if}
Frontend: Mutation Errors
Catch errors from mutations:
async function submit() {
try {
await createUser({ email });
} catch (e) {
if (e instanceof ForgeClientError) {
if (e.code === 'VALIDATION_ERROR') {
showValidationError(e.message);
} else if (e.code === 'UNAUTHORIZED') {
redirectToLogin();
} else {
showGenericError(e.message);
}
}
}
}
Frontend: Global Auth Error Handler
Configure a handler for auth errors across all requests:
import { createForgeClient } from '$lib/forge';
createForgeClient({
url: 'http://localhost:3000',
getToken: () => localStorage.getItem('token'),
onAuthError: (error) => {
// Called when any request returns UNAUTHORIZED
localStorage.removeItem('token');
window.location.href = '/login';
}
});
Backend: Error Conversion
Implement From for domain errors:
pub enum OrderError {
InsufficientStock(String),
PaymentFailed(String),
ShippingUnavailable(String),
}
impl From<OrderError> for ForgeError {
fn from(e: OrderError) -> Self {
match e {
OrderError::InsufficientStock(msg) =>
ForgeError::Validation(format!("Insufficient stock: {}", msg)),
OrderError::PaymentFailed(msg) =>
ForgeError::Internal(format!("Payment failed: {}", msg)),
OrderError::ShippingUnavailable(msg) =>
ForgeError::Validation(format!("Shipping unavailable: {}", msg)),
}
}
}
Then use ? to propagate:
#[forge::mutation]
pub async fn create_order(ctx: &MutationContext, items: Vec<Item>) -> Result<Order> {
check_stock(&items).map_err(OrderError::InsufficientStock)?;
// Automatically converts to ForgeError::Validation
}
Rate Limit Errors
When rate limiting triggers, the error includes retry information:
ForgeError::RateLimitExceeded {
retry_after: Duration::from_secs(30),
limit: 100,
remaining: 0,
}
Handle on the frontend:
try {
await expensiveQuery();
} catch (e) {
if (e instanceof ForgeClientError && e.isRateLimited()) {
const retryAfter = e.retryAfterSecs ?? 60;
showMessage(`Rate limited. Try again in ${retryAfter} seconds`);
}
}
sqlx::Error Conversion
SQLx errors automatically convert to ForgeError::Database:
#[forge::query]
pub async fn get_user(ctx: &QueryContext, id: Uuid) -> Result<User> {
sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(ctx.db())
.await
.map_err(Into::into) // sqlx::Error -> ForgeError::Database
}
To preserve the original error type for matching:
#[forge::query]
pub async fn get_user(ctx: &QueryContext, id: Uuid) -> Result<User> {
match sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(ctx.db())
.await
{
Ok(user) => Ok(user),
Err(sqlx::Error::RowNotFound) =>
Err(ForgeError::NotFound(format!("User {} not found", id))),
Err(e) => Err(e.into()),
}
}
Error Logging
All errors are logged server-side with context:
ERROR forge::function: Function error
function=get_user
request_id=abc-123
user_id=Some(user-456)
error="User not found"
error_code=NOT_FOUND
Client errors (4xx) log at WARN level. Server errors (5xx) log at ERROR level.
Troubleshooting
Common Issues
"Database error: pool timed out"
Cause: All database connections are in use and checkout timed out.
Solutions:
- Increase
pool_sizein forge.toml - Check for long-running queries holding connections
- Lower
statement_timeoutso runaway queries release connections sooner - Move heavy workloads to dedicated worker nodes with their own
pool_size
[database]
pool_size = 60
statement_timeout = "30s"
"Unauthorized: Token expired"
Cause: JWT token has passed its expiration time.
Solutions:
- Refresh the token before expiration
- Increase token expiry in auth configuration
- Implement token refresh on 401 response
onAuthError: (error) => {
if (error.code === 'UNAUTHORIZED') {
refreshToken().then(retry);
}
}
"Rate limit exceeded: retry after 30s"
Cause: Too many requests from this user/IP in the configured window.
Solutions:
- Implement client-side rate limiting
- Use exponential backoff on retry
- Adjust rate limit configuration if too restrictive
if (error.code === 'RATE_LIMITED') {
await sleep((error.retryAfterSecs ?? 1) * 1000);
return retry();
}
"Timeout: Query exceeded 30s"
Cause: Query took longer than configured timeout.
Solutions:
- Add appropriate indexes to the database
- Optimize the query (check EXPLAIN ANALYZE)
- Increase statement_timeout for specific pools
- Consider pagination for large result sets
"Job error: max attempts exceeded"
Cause: Job failed all retry attempts.
Solutions:
- Check job logs for the actual failure reason
- Investigate the failed job's arguments
- Fix the underlying issue and re-enqueue
- Increase max_attempts if failures are transient
-- Find failed jobs
SELECT id, job_type, error, attempts
FROM forge_jobs
WHERE status = 'failed'
ORDER BY created_at DESC
LIMIT 10;
"Cluster error: Leader election failed"
Cause: Unable to acquire advisory lock for leader role.
Solutions:
- Check database connectivity
- Verify another node isn't holding the lock
- Check for zombie connections holding locks
-- View current advisory locks
SELECT * FROM pg_locks WHERE locktype = 'advisory';
-- Terminate stuck sessions if needed
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE state = 'idle' AND query_start < NOW() - INTERVAL '1 hour';
Debugging Tips
-
Enable debug logging:
RUST_LOG=debug cargo run -
Trace a specific request:
RUST_LOG=forge=debug,tower_http=debug cargo run -
Check job queue status:
SELECT status, COUNT(*) FROM forge_jobs GROUP BY status; -
Monitor workflow runs:
SELECT workflow_name, status, error
FROM forge_workflow_runs
WHERE status IN ('failed')
ORDER BY started_at DESC; -
Verify node health:
SELECT hostname, status, last_heartbeat
FROM forge_nodes
ORDER BY last_heartbeat DESC;