Skip to main content

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

VariantHTTP StatusError CodeUse Case
NotFound(String)404NOT_FOUNDResource does not exist
Unauthorized(String)401UNAUTHORIZEDAuthentication required or invalid
Forbidden(String)403FORBIDDENAuthenticated but insufficient permissions
Validation(String)400VALIDATION_ERRORInput validation failed
InvalidArgument(String)400INVALID_ARGUMENTMalformed or invalid arguments
Deserialization(String)400INTERNAL_ERRORFailed to deserialize request input
Timeout(String)504TIMEOUTOperation exceeded time limit
RateLimitExceeded { retry_after, limit, remaining }429RATE_LIMITEDRate limit exceeded
JobCancelled(String)409JOB_CANCELLEDJob was explicitly cancelled
Conflict(String)409CONFLICTConcurrent modification conflict
UnprocessableEntity(String)422UNPROCESSABLE_ENTITYInput is valid but semantically wrong
ServiceUnavailable(String)503SERVICE_UNAVAILABLETemporary service outage
Config(String)500INTERNAL_ERRORConfiguration error
Database(sqlx::Error)500INTERNAL_ERRORDatabase operation failed
Function(String)500INTERNAL_ERRORFunction execution failed
Job(String)500INTERNAL_ERRORJob execution failed
Cluster(String)500INTERNAL_ERRORCluster communication failed
Serialization(String)500INTERNAL_ERRORSerialization failed
Io(std::io::Error)500INTERNAL_ERRORIO operation failed
Internal(String)500INTERNAL_ERRORGeneric internal error
InvalidState(String)500INTERNAL_ERRORInvalid state transition
WorkflowSuspendedInternal signal for workflow suspension; never returned to clients

RateLimitExceeded Fields

FieldTypeDescription
retry_afterDurationTime until next request allowed
limitu32Configured request limit
remainingu32Tokens 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

FieldTypeDescription
successbooleanAlways false for errors
data-Omitted for errors
error.codestringMachine-readable error code
error.messagestringHuman-readable description
error.detailsobject | nullAdditional context (optional)
request_idstringRequest 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

CodeHTTP StatusDescription
NOT_FOUND404Requested resource does not exist
UNAUTHORIZED401Authentication required or token invalid
FORBIDDEN403User lacks required permissions
VALIDATION_ERROR400Input failed validation
INVALID_ARGUMENT400Argument format or value invalid
TIMEOUT504Request exceeded configured timeout
RATE_LIMITED429Too many requests
JOB_CANCELLED409Job was explicitly cancelled
CONFLICT409Concurrent modification conflict
UNPROCESSABLE_ENTITY422Input is syntactically valid but semantically wrong
SERVICE_UNAVAILABLE503Temporary service outage
INTERNAL_ERROR500Server-side error

Error Catalog

What you see in the error.code field, what caused it, and what to do about it.

Error CodeHTTP StatusCommon CauseFix
INVALID_ARGUMENT400A 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_ERROR400Business-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.
UNAUTHORIZED401No 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.
FORBIDDEN403The 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_FOUND404The 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.
CONFLICT409Two 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_LIMITED429The 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.
TIMEOUT504The 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_ERROR500Catch-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_ENTITY422The 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_UNAVAILABLE503The 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:

  1. Increase pool_size in forge.toml
  2. Check for long-running queries holding connections
  3. Lower statement_timeout so runaway queries release connections sooner
  4. 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:

  1. Refresh the token before expiration
  2. Increase token expiry in auth configuration
  3. 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:

  1. Implement client-side rate limiting
  2. Use exponential backoff on retry
  3. 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:

  1. Add appropriate indexes to the database
  2. Optimize the query (check EXPLAIN ANALYZE)
  3. Increase statement_timeout for specific pools
  4. Consider pagination for large result sets

"Job error: max attempts exceeded"

Cause: Job failed all retry attempts.

Solutions:

  1. Check job logs for the actual failure reason
  2. Investigate the failed job's arguments
  3. Fix the underlying issue and re-enqueue
  4. 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:

  1. Check database connectivity
  2. Verify another node isn't holding the lock
  3. 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

  1. Enable debug logging:

    RUST_LOG=debug cargo run
  2. Trace a specific request:

    RUST_LOG=forge=debug,tower_http=debug cargo run
  3. Check job queue status:

    SELECT status, COUNT(*) FROM forge_jobs GROUP BY status;
  4. Monitor workflow runs:

    SELECT workflow_name, status, error
    FROM forge_workflow_runs
    WHERE status IN ('failed')
    ORDER BY started_at DESC;
  5. Verify node health:

    SELECT hostname, status, last_heartbeat
    FROM forge_nodes
    ORDER BY last_heartbeat DESC;