Error Handling

Azu provides robust error handling middleware to catch exceptions, render error pages, and return appropriate status codes. This ensures your application gracefully handles errors while providing meaningful feedback to users and developers.

Overview

Error handling middleware enables:

  • Exception Catching: Automatic catching and logging of exceptions

  • Error Page Rendering: Custom error pages with appropriate styling

  • Status Code Management: Proper HTTP status codes for different error types

  • Validation Error Aggregation: Collection and display of form validation errors

  • Environment-Aware Error Details: Different error information for development vs production

  • Error Tracking Integration: Integration with external error tracking services

Error Handling Flow

Built-in Error Handler

Rescuer Handler

The Azu::Handler::Rescuer is the primary error handling middleware.

# Basic usage
Azu::Handler::Rescuer.new

# With custom configuration
Azu::Handler::Rescuer.new(
  show_details: false,           # Hide error details in production
  log_errors: true,              # Log errors to console
  custom_templates: {            # Custom error templates
    404 => "errors/not_found.html",
    500 => "errors/server_error.html",
    422 => "errors/validation_error.html"
  },
  error_tracker: ErrorTracker.new # External error tracking
)

Configuration Options:

  • show_details: Whether to show detailed error information (default: true in development)

  • log_errors: Whether to log errors (default: true)

  • custom_templates: Custom error page templates for specific status codes

  • error_tracker: External error tracking service integration

Error Types and Handling

HTTP Status Errors

# 404 Not Found
class NotFoundError < Azu::Response::Error
  def initialize(message : String = "Resource not found")
    super(404, message)
  end
end

# 403 Forbidden
class ForbiddenError < Azu::Response::Error
  def initialize(message : String = "Access denied")
    super(403, message)
  end
end

# 401 Unauthorized
class UnauthorizedError < Azu::Response::Error
  def initialize(message : String = "Authentication required")
    super(401, message)
  end
end

# 422 Validation Error
class ValidationError < Azu::Response::Error
  def initialize(@errors : Hash(String, Array(String)))
    super(422, "Validation failed")
  end
end

Custom Error Classes

# Business logic errors
class BusinessLogicError < Azu::Response::Error
  def initialize(message : String, @code : String)
    super(400, message)
  end
end

# Service unavailable errors
class ServiceUnavailableError < Azu::Response::Error
  def initialize(service : String)
    super(503, "Service #{service} is temporarily unavailable")
  end
end

# Rate limit errors
class RateLimitError < Azu::Response::Error
  def initialize(limit : Int32, window : Time::Span)
    super(429, "Rate limit exceeded: #{limit} requests per #{window}")
  end
end

Error Handling in Endpoints

Basic Error Handling

struct UserEndpoint
  include Endpoint(UserRequest, UserResponse)

  get "/users/:id"

  def call : UserResponse
    user = User.find(params["id"])

    if user
      UserResponse.new(user)
    else
      raise NotFoundError.new("User not found")
    end
  end
end

Validation Error Handling

struct CreateUserEndpoint
  include Endpoint(CreateUserRequest, UserResponse)

  post "/users"

  def call : UserResponse
    # Validate request
    unless request.valid?
      raise ValidationError.new(request.errors)
    end

    # Create user
    user = User.create(request.to_user_params)

    if user.valid?
      UserResponse.new(user)
    else
      raise ValidationError.new(user.errors)
    end
  end
end

Conditional Error Handling

struct AdminEndpoint
  include Endpoint(AdminRequest, AdminResponse)

  get "/admin/users"

  def call : AdminResponse
    user = request.context(:current_user)?.try(&.as(User))

    unless user
      raise UnauthorizedError.new
    end

    unless user.admin?
      raise ForbiddenError.new("Admin access required")
    end

    AdminResponse.new(User.all)
  end
end

Custom Error Pages

HTML Error Templates

<!-- templates/errors/404.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Page Not Found</title>
    <style>
      .error-page {
        text-align: center;
        padding: 50px;
        font-family: Arial, sans-serif;
      }
      .error-code {
        font-size: 72px;
        color: #e74c3c;
        margin-bottom: 20px;
      }
      .error-message {
        font-size: 24px;
        color: #2c3e50;
        margin-bottom: 30px;
      }
      .back-link {
        color: #3498db;
        text-decoration: none;
      }
    </style>
  </head>
  <body>
    <div class="error-page">
      <div class="error-code">404</div>
      <div class="error-message">Page Not Found</div>
      <p>The page you're looking for doesn't exist.</p>
      <a href="/" class="back-link">Go back home</a>
    </div>
  </body>
</html>

JSON Error Responses

# For API endpoints
class APIErrorHandler
  include Azu::Handler

  def initialize(@next : Azu::Handler)
  end

  def call(request : HttpRequest, response : Response) : Response
    @next.call(request, response)
  rescue ex : Azu::Response::Error
    # Return JSON error response
    Response.new(
      status: ex.status,
      body: {
        error: ex.message,
        status: ex.status,
        timestamp: Time.utc.to_s
      }.to_json,
      headers: {"Content-Type" => "application/json"}
    )
  rescue ex : Exception
    # Handle unexpected errors
    Response.new(
      status: 500,
      body: {
        error: "Internal server error",
        status: 500,
        timestamp: Time.utc.to_s
      }.to_json,
      headers: {"Content-Type" => "application/json"}
    )
  end
end

Error Tracking Integration

Sentry Integration

class SentryErrorHandler
  include Azu::Handler

  def initialize(@next : Azu::Handler, @sentry_dsn : String)
  end

  def call(request : HttpRequest, response : Response) : Response
    @next.call(request, response)
  rescue ex : Exception
    # Send to Sentry
    Sentry.capture_exception(ex, {
      request_id: request.headers["X-Request-ID"]?,
      user_id: request.context(:current_user)?.try(&.as(User).id),
      path: request.path,
      method: request.method,
      params: request.params.to_h
    })

    # Re-raise for Rescuer to handle
    raise ex
  end
end

Custom Error Tracking

class CustomErrorTracker
  def capture_exception(exception : Exception, context : Hash(String, Any))
    # Send to your error tracking service
    error_data = {
      message: exception.message,
      backtrace: exception.backtrace,
      context: context,
      timestamp: Time.utc
    }

    HTTP::Client.post(
      "https://your-error-tracker.com/api/errors",
      headers: HTTP::Headers{"Content-Type" => "application/json"},
      body: error_data.to_json
    )
  end
end

# Usage
Azu::Handler::Rescuer.new(
  error_tracker: CustomErrorTracker.new
)

Validation Error Handling

Form Validation Errors

struct ValidationError
  include Response

  def initialize(@errors : Hash(String, Array(String)))
  end

  def render
    view "errors/validation.html", {
      errors: @errors,
      form_data: request.params
    }
  end
end

# Template: templates/errors/validation.html
<!DOCTYPE html>
<html>
  <head>
    <title>Validation Error</title>
  </head>
  <body>
    <h1>Please correct the following errors:</h1>

    <ul class="errors">
      {% for field, messages in errors %}
      <li>
        <strong>{{ field }}:</strong>
        <ul>
          {% for message in messages %}
          <li>{{ message }}</li>
          {% endfor %}
        </ul>
      </li>
      {% endfor %}
    </ul>

    <form method="post" action="{{ request.path }}">
      <!-- Re-populate form with original data -->
      <input type="text" name="name" value="{{ form_data["name"]? }}"> <input
      type="email" name="email" value="{{ form_data["email"]? }}">
      <button type="submit">Submit</button>
    </form>
  </body>
</html>

API Validation Errors

struct APIValidationError
  include Response

  def initialize(@errors : Hash(String, Array(String)))
  end

  def render
    Response.new(
      status: 422,
      body: {
        error: "Validation failed",
        errors: @errors,
        status: 422
      }.to_json,
      headers: {"Content-Type" => "application/json"}
    )
  end
end

Environment-Specific Error Handling

Development Environment

# config/development.cr
ExampleApp.start [
  Azu::Handler::Rescuer.new(
    show_details: true,           # Show detailed error information
    log_errors: true,             # Log all errors
    custom_templates: {           # Development-specific templates
      500 => "errors/dev_server_error.html"
    }
  ),
  # Other middleware...
]

Production Environment

# config/production.cr
ExampleApp.start [
  Azu::Handler::Rescuer.new(
    show_details: false,          # Hide sensitive error details
    log_errors: true,             # Log errors for monitoring
    custom_templates: {           # Production error templates
      404 => "errors/prod_not_found.html",
      500 => "errors/prod_server_error.html"
    },
    error_tracker: SentryTracker.new(ENV["SENTRY_DSN"])
  ),
  # Other middleware...
]

Error Recovery Strategies

Retry Logic

class RetryHandler
  include Azu::Handler

  def initialize(@next : Azu::Handler, @max_retries : Int32 = 3)
  end

  def call(request : HttpRequest, response : Response) : Response
    retries = 0

    begin
      @next.call(request, response)
    rescue ex : TemporaryError
      retries += 1

      if retries <= @max_retries
        sleep(2 ** retries)  # Exponential backoff
        retry
      else
        raise ex
      end
    end
  end
end

Fallback Responses

class FallbackHandler
  include Azu::Handler

  def initialize(@next : Azu::Handler, @fallback_service : FallbackService)
  end

  def call(request : HttpRequest, response : Response) : Response
    @next.call(request, response)
  rescue ex : ServiceUnavailableError
    # Use fallback service
    fallback_data = @fallback_service.get_fallback_data(request)

    Response.new(
      status: 200,
      body: fallback_data.to_json,
      headers: {"Content-Type" => "application/json", "X-Fallback" => "true"}
    )
  end
end

Error Monitoring and Alerting

Error Metrics

class ErrorMetricsHandler
  include Azu::Handler

  def initialize(@next : Azu::Handler, @metrics : MetricsCollector)
  end

  def call(request : HttpRequest, response : Response) : Response
    start_time = Time.monotonic

    begin
      result = @next.call(request, response)

      # Record success metrics
      @metrics.increment("requests.success", {
        path: request.path,
        method: request.method
      })

      result
    rescue ex : Exception
      # Record error metrics
      @metrics.increment("requests.error", {
        path: request.path,
        method: request.method,
        error_type: ex.class.name
      })

      # Alert on high error rates
      if @metrics.get_error_rate > 0.05  # 5% error rate
        send_alert("High error rate detected: #{@metrics.get_error_rate}")
      end

      raise ex
    end
  end
end

Health Checks

class HealthCheckEndpoint
  include Endpoint(HealthCheckRequest, HealthCheckResponse)

  get "/health"

  def call : HealthCheckResponse
    # Check various system components
    checks = {
      database: check_database,
      redis: check_redis,
      external_api: check_external_api
    }

    overall_status = checks.values.all? ? "healthy" : "unhealthy"

    HealthCheckResponse.new(
      status: overall_status,
      checks: checks,
      timestamp: Time.utc
    )
  end

  private def check_database : Bool
    # Database health check
    true
  end

  private def check_redis : Bool
    # Redis health check
    true
  end

  private def check_external_api : Bool
    # External API health check
    true
  end
end

Testing Error Handling

Unit Testing

require "spec"

describe "Error Handling" do
  it "handles 404 errors" do
    endpoint = NotFoundEndpoint.new

    response = endpoint.call(HttpRequest.new("GET", "/nonexistent"))

    response.status.should eq(404)
    response.body.should contain("Page Not Found")
  end

  it "handles validation errors" do
    endpoint = CreateUserEndpoint.new

    request = CreateUserRequest.new(email: "invalid-email")

    expect_raises(ValidationError) do
      endpoint.call(request)
    end
  end

  it "handles authentication errors" do
    endpoint = ProtectedEndpoint.new

    request = HttpRequest.new("GET", "/protected")

    response = endpoint.call(request)

    response.status.should eq(401)
  end
end

Integration Testing

describe "Error Handling Integration" do
  it "renders error pages correctly" do
    app = ExampleApp.new([
      Azu::Handler::Rescuer.new,
      ErrorProneEndpoint.new
    ])

    response = app.get("/error")

    response.status.should eq(500)
    response.body.should contain("Server Error")
  end

  it "tracks errors in production" do
    error_tracker = MockErrorTracker.new
    error_tracker.should_receive(:capture_exception)

    app = ExampleApp.new([
      Azu::Handler::Rescuer.new(error_tracker: error_tracker),
      ErrorProneEndpoint.new
    ])

    app.get("/error")
  end
end

Best Practices

1. Use Appropriate Error Types

# Good: Specific error types
raise NotFoundError.new("User not found") if user.nil?
raise ValidationError.new(errors) unless request.valid?
raise UnauthorizedError.new unless user.authenticated?

# Bad: Generic errors
raise Exception.new("Something went wrong")

2. Provide Meaningful Error Messages

# Good: Descriptive error messages
raise ValidationError.new({
  "email" => ["must be a valid email address"],
  "password" => ["must be at least 8 characters long"]
})

# Bad: Generic error messages
raise ValidationError.new({"form" => ["invalid"]})

3. Log Errors Appropriately

# Good: Structured error logging
Log.error { {
  error: ex.message,
  backtrace: ex.backtrace,
  request_id: request.headers["X-Request-ID"]?,
  user_id: request.context(:current_user)?.try(&.as(User).id),
  path: request.path
} }

# Bad: Simple error logging
Log.error { ex.message }

4. Handle Errors Gracefully

# Good: Graceful error handling
def call : Response
  user = User.find(params["id"])

  if user
    UserResponse.new(user)
  else
    raise NotFoundError.new("User not found")
  end
rescue ex : DatabaseError
  # Handle database errors specifically
  Log.error { "Database error: #{ex.message}" }
  raise ServiceUnavailableError.new("Database")
end

5. Use Error Context

# Good: Rich error context
class ContextualError < Azu::Response::Error
  def initialize(message : String, @context : Hash(String, Any))
    super(500, message)
  end
end

# Usage
raise ContextualError.new("Processing failed", {
  user_id: user.id,
  operation: "user_update",
  data: request.params
})

Next Steps


Effective error handling ensures your application remains robust and provides a good user experience even when things go wrong.

Last updated

Was this helpful?