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 |
Timeout(String) | 504 | TIMEOUT | Operation exceeded time limit |
RateLimitExceeded { retry_after, limit, remaining } | 429 | RATE_LIMITED | Rate limit exceeded |
Database(String) | 500 | INTERNAL_ERROR | Database operation failed |
Config(String) | 500 | INTERNAL_ERROR | Configuration error |
Function(String) | 500 | INTERNAL_ERROR | Function execution failed |
Job(String) | 500 | INTERNAL_ERROR | Job execution failed |
JobCancelled(String) | 409 | JOB_CANCELLED | Job was explicitly cancelled |
Cluster(String) | 500 | INTERNAL_ERROR | Cluster communication failed |
Serialization(String) | 500 | INTERNAL_ERROR | Serialization failed |
Deserialization(String) | 500 | INTERNAL_ERROR | Deserialization failed |
Io(std::io::Error) | 500 | INTERNAL_ERROR | IO operation failed |
Sql(sqlx::Error) | 500 | INTERNAL_ERROR | SQL execution failed |
Internal(String) | 500 | INTERNAL_ERROR | Generic internal error |
InvalidState(String) | 500 | INTERNAL_ERROR | Invalid state transition |
WorkflowSuspended | - | - | Internal signal for workflow suspension |
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 retry_after_secs in the details object. 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;
details?: unknown;
}
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 |
INTERNAL_ERROR | 500 | Server-side error |
JOB_CANCELLED | 409 | Job was explicitly cancelled |
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.code === 'RATE_LIMITED') {
const retryAfter = e.details?.retry_after_secs ?? 60;
showMessage(`Rate limited. Try again in ${retryAfter} seconds`);
}
}
sqlx::Error Conversion
SQLx errors automatically convert to ForgeError::Sql:
#[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::Sql
}
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
- Use separate pools for different workloads (bulkhead pattern)
[database.pools.default]
size = 30
[database.pools.analytics]
size = 10
statement_timeout_secs = 600
"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.details.retry_after_secs * 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', 'compensated')
ORDER BY started_at DESC; -
Verify node health:
SELECT hostname, status, last_heartbeat
FROM forge_nodes
ORDER BY last_heartbeat DESC;