Skip to content

Latest commit

 

History

History
308 lines (240 loc) · 9.38 KB

File metadata and controls

308 lines (240 loc) · 9.38 KB

Error Handling

The InferaDB SDK provides typed errors that enable precise handling of failure scenarios.

Error Types

use inferadb::{Error, ErrorKind};

match vault.check("user:alice", "view", "doc:1").await {
    Ok(allowed) => { /* handle result */ }
    Err(e) => {
        match e.kind() {
            ErrorKind::Unauthorized => { /* credentials invalid */ }
            ErrorKind::Forbidden => { /* insufficient permissions */ }
            ErrorKind::NotFound => { /* resource/vault not found */ }
            ErrorKind::RateLimited => { /* back off and retry */ }
            ErrorKind::SchemaViolation => { /* invalid relation/permission */ }
            ErrorKind::Unavailable => { /* service temporarily down */ }
            ErrorKind::Timeout => { /* request timed out */ }
            _ => { /* other error */ }
        }
    }
}

check() vs require()

The SDK provides two patterns for authorization checks:

Method Returns Use Case
check() Result<bool, Error> When you need the boolean value
require() Result<(), AccessDenied> Guard clauses, early-return on denial
// check() - returns bool, denial is Ok(false)
let allowed = vault.check("user:alice", "view", "doc:1").await?;
if !allowed {
    return Err(AppError::Forbidden);
}

// require() - denial is Err(AccessDenied), integrates with ?
vault.check("user:alice", "view", "doc:1")
    .require()
    .await?;  // Returns early on denial

Key invariant: check() returns Ok(false) for denied access. Only require() converts denial to an error.

AccessDenied Error

The AccessDenied error integrates with web frameworks:

use inferadb::AccessDenied;

// Axum
impl IntoResponse for AccessDenied {
    fn into_response(self) -> Response {
        StatusCode::FORBIDDEN.into_response()
    }
}

// Actix-web
impl ResponseError for AccessDenied {
    fn status_code(&self) -> StatusCode {
        StatusCode::FORBIDDEN
    }
}

Retriable Errors

Check if an error is safe to retry:

match vault.check(subject, permission, resource).await {
    Ok(allowed) => Ok(allowed),
    Err(e) if e.is_retriable() => {
        // Safe to retry: Unavailable, Timeout, RateLimited
        let delay = e.retry_after().unwrap_or(Duration::from_millis(100));
        tokio::time::sleep(delay).await;
        // Retry...
    }
    Err(e) => Err(e),  // Not retriable
}

Retriable errors:

ErrorKind Retry? Notes
Unavailable Yes Service temporarily down
Timeout Yes Request timed out
RateLimited Yes Use retry_after() for delay
Unauthorized No Fix credentials
Forbidden No Fix permissions
NotFound No Resource doesn't exist
SchemaViolation No Fix schema/query
InvalidArgument No Fix input

Request IDs

All errors include request IDs for debugging:

match vault.check(subject, permission, resource).await {
    Err(e) => {
        if let Some(request_id) = e.request_id() {
            tracing::error!(
                request_id = %request_id,
                error = %e,
                "Authorization check failed"
            );
        }
    }
    Ok(_) => {}
}

Error Context

Errors include context for debugging:

let e: Error = /* ... */;

// Error kind for matching
e.kind();  // ErrorKind::RateLimited

// Request ID for support
e.request_id();  // Some("req_abc123...")

// Retry guidance for rate limits
e.retry_after();  // Some(Duration::from_secs(5))

// Full error chain
eprintln!("{:#}", e);

Retry Configuration

Configure retry behavior per operation category:

use inferadb::{RetryConfig, OperationRetry, RetryBudget};

let client = Client::builder()
    .url("https://api.inferadb.com")
    .credentials(creds)
    .retry(RetryConfig::default()
        .max_retries(3)
        .initial_backoff(Duration::from_millis(100))
        .max_backoff(Duration::from_secs(10))
        // Retry budget prevents retry storms
        .retry_budget(RetryBudget::default()
            .retry_ratio(0.1)  // Max 10% retries
            .min_retries_per_second(10))
        // Per-category settings
        .reads(OperationRetry::enabled())
        .idempotent_writes(OperationRetry::enabled())
        .non_idempotent_writes(OperationRetry::connection_only()))
    .build()
    .await?;

Operation Categories

Category Default Behavior Notes
reads Retry all errors Checks, lookups - always safe
idempotent_writes Retry all errors Writes with request ID
non_idempotent_writes Connection errors only Safe: request didn't reach server

Retry Budget

Prevents cascading failures under load:

RetryBudget::default()
    .ttl(Duration::from_secs(10))     // Tracking window
    .min_retries_per_second(10)       // Always allow 10/sec
    .retry_ratio(0.1)                 // Max 10% of requests

Request ID for Idempotent Writes

Ensure safe retries for mutations:

use uuid::Uuid;

// Generate ID once, reuse for retries
let request_id = Uuid::new_v4();

vault.relationships()
    .write(Relationship::new("doc:1", "viewer", "user:alice"))
    .request_id(request_id)
    .await?;

// Safe to retry with same ID - server deduplicates
vault.relationships()
    .write(Relationship::new("doc:1", "viewer", "user:alice"))
    .request_id(request_id)  // Same ID
    .await?;

Auto-Generated Request IDs

let client = Client::builder()
    .auto_request_id(true)  // Generate UUID for each mutation
    .build()
    .await?;

Graceful Degradation

Configure how the SDK handles failures when the service is unavailable.

Failure Modes

use inferadb::FailureMode;

// Per-request override
let allowed = vault.check("user:alice", "view", "doc:1")
    .on_error(FailureMode::FailClosed)  // Deny on error (default)
    .await?;

// Fail-open for non-critical paths (logs WARN)
let allowed = vault.check("user:alice", "view", "doc:public")
    .on_error(FailureMode::FailOpen)
    .await
    .unwrap_or(true);
Mode Behavior Use Case
FailClosed Deny on error Security-critical paths (default)
FailOpen Allow on error Non-critical paths, availability priority
Propagate Return error Custom fallback logic in application

Degradation Configuration

Configure global fallback strategies for production resilience:

use inferadb::{DegradationConfig, FailureMode, CheckFallbackStrategy, WriteFallbackStrategy};

let client = Client::builder()
    .url("https://api.inferadb.com")
    .credentials(creds)
    .degradation(DegradationConfig::new()
        // Default: deny on check failure
        .on_check_failure(FailureMode::FailClosed)

        // Use cached decisions when service unavailable
        .on_check_unavailable(CheckFallbackStrategy::UseCache {
            max_age: Duration::from_secs(300),
        })

        // Queue writes for retry when service unavailable
        .on_write_failure(WriteFallbackStrategy::Queue {
            max_queue_size: 1000,
            flush_interval: Duration::from_secs(5),
        })

        // Alert on degradation
        .on_degradation_start(|reason| {
            tracing::warn!("Entering degraded mode: {}", reason);
        })
        .on_degradation_end(|| {
            tracing::info!("Service recovered");
        }))
    .build()
    .await?;

Fallback Strategies

Check fallbacks (when authorization service unavailable):

Strategy Behavior
Error Return error immediately (default)
UseCache { max_age } Use cached decision if fresh enough
Default(bool) Return fixed value (use with caution)
Custom(fn) Call custom fallback function

Write fallbacks (when write operations fail):

Strategy Behavior
Error Return error immediately (default)
Queue { max_queue_size, flush_interval } Queue for background retry

Best Practices

  1. Use require() for guards - Cleaner code, integrates with ?
  2. Log request IDs - Essential for debugging production issues
  3. Handle rate limits - Use retry_after() for backoff
  4. Fail closed by default - Only use fail-open for non-critical paths
  5. Categorize errors - Distinguish user errors from system errors
  6. Use retry budgets - Prevent retry storms in production
  7. Use request IDs - Enable safe retries for writes
  8. Configure degradation - Plan for service unavailability in production