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
Timeout(String)504TIMEOUTOperation exceeded time limit
RateLimitExceeded { retry_after, limit, remaining }429RATE_LIMITEDRate limit exceeded
Database(String)500INTERNAL_ERRORDatabase operation failed
Config(String)500INTERNAL_ERRORConfiguration error
Function(String)500INTERNAL_ERRORFunction execution failed
Job(String)500INTERNAL_ERRORJob execution failed
JobCancelled(String)409JOB_CANCELLEDJob was explicitly cancelled
Cluster(String)500INTERNAL_ERRORCluster communication failed
Serialization(String)500INTERNAL_ERRORSerialization failed
Deserialization(String)500INTERNAL_ERRORDeserialization failed
Io(std::io::Error)500INTERNAL_ERRORIO operation failed
Sql(sqlx::Error)500INTERNAL_ERRORSQL execution failed
Internal(String)500INTERNAL_ERRORGeneric internal error
InvalidState(String)500INTERNAL_ERRORInvalid state transition
WorkflowSuspended--Internal signal for workflow suspension

RateLimitExceeded Fields

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

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;
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

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
INTERNAL_ERROR500Server-side error
JOB_CANCELLED409Job 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:

  1. Increase pool_size in forge.toml
  2. Check for long-running queries holding connections
  3. 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:

  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.details.retry_after_secs * 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', 'compensated')
    ORDER BY started_at DESC;
  5. Verify node health:

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