Integrate AI with Rails: Day 8 – Production Hardening of the AI Integration, add AI Observablility

We have enough practical experience with SSE right now. We don’t need to perfect the transport layer, lets move on to improve our production error handling architecture.

Step 12 – Production Hardening of the AI Integration

We’ll cover this as one compact step:

LLM request
 ├── timeout
 ├── rate limit
 ├── provider error
 ├── invalid response
 ├── logging
 └── token/cost tracking

12.1 Add a custom AI error

Create:

app/services/ai/error.rb
class Ai::Error < StandardError
end

class Ai::ProviderError < Ai::Error
end

class Ai::RateLimitError < Ai::Error
end

class Ai::TimeoutError < Ai::Error
end

This gives our application its own error vocabulary instead of exposing SDK/provider exceptions everywhere.

12.2 Wrap the provider call

In Ai::Client, wrap the API call.

Conceptually:

def chat(messages:)
  response = @client.chat.completions.create(
    model: MODEL,
    messages: messages
  )

  {
    content: response.choices.first.message.content,
    model: response.model,
    input_tokens: response.usage&.prompt_tokens,
    output_tokens: response.usage&.completion_tokens
  }
rescue Faraday::TooManyRequestsError => e
  raise Ai::RateLimitError, e.message
rescue Faraday::TimeoutError => e
  raise Ai::TimeoutError, e.message
rescue Faraday::Error => e
  raise Ai::ProviderError, e.message
end

The exact exception classes can depend on the SDK/version, so inspect the exception raised by your installed openai gem rather than blindly copying provider-specific classes.

The important architecture is:

OpenRouter/SDK error
        ↓
Ai::Client
        ↓
Ai::RateLimitError
Ai::TimeoutError
Ai::ProviderError
        ↓
Rails application

Your controllers don’t need to know OpenRouter’s exception hierarchy.

12.3 Add timeout thinking

Never allow an AI request to hang indefinitely.

A production system should have:

connection timeout
read/request timeout

and then either:

retry

or:

fail gracefully

depending on the failure.

A key int. answer:

Retry transient failures such as timeouts and 429s with bounded exponential backoff, but don’t blindly retry all errors.

12.4 Token tracking

We’re already storing:

input_tokens
output_tokens

in messages.

That gives us an important operational capability:

conversation.messages.sum(:input_tokens)

and:

conversation.messages.sum(:output_tokens)

Now we can answer:

How many tokens did this conversation consume?

Later we can add pricing:

input tokens  × input price
+
output tokens × output price
=
estimated cost

Don’t hard-code provider pricing into the model. Pricing changes.

12.5 Add request timing

For a production AI application, latency is valuable.

In Ai::Client:

started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)

response = ...

latency_ms =
  ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round

Then eventually store:

latency_ms

on the message or in a separate AI usage/event table.

This allows:

model
tokens
latency
errors

to be correlated.

12.6 Don’t log prompts blindly

Avoid:

Rails.logger.info(params)

for AI endpoints.

User prompts may contain:

  • PII
  • secrets
  • customer information
  • proprietary company data

Log metadata instead:

conversation_id
model
latency
token counts
error type

rather than dumping the entire conversation into logs.

12.7 Add application-level rate limiting

An expensive AI endpoint should never be unrestricted.

Conceptually:

User
 ↓
Rate limit
 ↓
AI endpoint
 ↓
LLM

For example:

10 requests/minute/user

The exact limit depends on your application.

This protects:

  • cost
  • provider quotas
  • abuse
  • system capacity

12.8 What about retries?

Use something like:

Timeout      → retry
429          → retry with backoff
5xx          → retry with backoff
400          → don't retry
401          → don't retry
invalid input → don't retry

The exact mapping depends on the provider.

A useful int. phrase:

“I distinguish transient failures from permanent failures. For transient failures, I use a bounded number of retries with exponential (delay: 1,2,4,8,16 seconds) backoff.”


Step 13: Add AI Observability with admin Dashboard

Instead of merely saying we support observability, let’s build an actual AI Admin / Observability dashboard into the app. This will make the project much stronger because you can demonstrate that we thought beyond “call the LLM.”

We will track:

AI Request
├── provider
├── model
├── operation
├── status
├── conversation
├── message
├── input tokens
├── output tokens
├── estimated cost
├── latency
├── started/completed timestamps
├── retry count
├── HTTP status
├── error class
├── error message
├── request ID
├── streamed?
└── metadata

And the admin UI will have:

/admin/ai_requests

AI Observability
-------------------------------------------------
Total Requests       127
Successful           119
Failed                 8
Total Input Tokens  45,230
Total Output Tokens 18,921
Avg Latency          2.34 sec
Estimated Cost       $0.00 / N/A
-------------------------------------------------

Recent AI Requests
-------------------------------------------------
Time | Model | Status | Tokens | Latency | Error
-------------------------------------------------
...

Then clicking a request gives the complete details.

Step 12A – Create AiRequest

We’ll call the model AiRequest.

This is not the AI message itself.

Remember:

Message
    ↓
What the user/assistant said

AiRequest
    ↓
What happened while talking to the LLM

That distinction is important.

1. Generate the model

Run:

bin/rails g model AiRequest \
  conversation:references \
  message:references \
  provider:string \
  model:string \
  operation:string \
  status:string \
  input_tokens:integer \
  output_tokens:integer \
  estimated_cost:decimal \
  latency_ms:integer \
  retry_count:integer \
  http_status:integer \
  request_id:string \
  error_class:string \
  error_message:text \
  started_at:datetime \
  completed_at:datetime \
  streamed:boolean \
  metadata:jsonb

You can also use one line:

bin/rails g model AiRequest conversation:references message:references provider:string model:string operation:string status:string input_tokens:integer output_tokens:integer estimated_cost:decimal latency_ms:integer retry_count:integer http_status:integer request_id:string error_class:string error_message:text started_at:datetime completed_at:datetime streamed:boolean metadata:jsonb

Step 12B – Migration

Open the generated migration.

Change it to:

class CreateAiRequests < ActiveRecord::Migration[8.1]
  def change
    create_table :ai_requests do |t|
      t.references :conversation, null: true, foreign_key: true
      t.references :message, null: true, foreign_key: true

      t.string :provider, null: false
      t.string :model, null: false
      t.string :operation, null: false
      t.string :status, null: false

      t.integer :input_tokens
      t.integer :output_tokens

      t.decimal :estimated_cost, precision: 12, scale: 8

      t.integer :latency_ms
      t.integer :retry_count, null: false, default: 0
      t.integer :http_status

      t.string :request_id

      t.string :error_class
      t.text :error_message

      t.datetime :started_at
      t.datetime :completed_at

      t.boolean :streamed, null: false, default: false

      t.jsonb :metadata, null: false, default: {}

      t.timestamps
    end

    add_index :ai_requests, :status
    add_index :ai_requests, :provider
    add_index :ai_requests, :model
    add_index :ai_requests, :created_at
    add_index :ai_requests, :request_id, unique: true
  end
end

Why are conversation and message nullable?

Because not every AI operation has to belong to a chat message.

Later we might have:

AI embedding request
AI summarization
AI classification
AI agent tool call

So:

conversation_id = NULL
message_id = NULL

can still be valid.

Step 12C – Run migration

bin/rails db:migrate

Then verify:

bin/rails dbconsole
\d ai_requests

Step 12D – Create the model

Open:

app/models/ai_request.rb

Use:

class AiRequest < ApplicationRecord
  belongs_to :conversation, optional: true
  belongs_to :message, optional: true

  enum :status, {
    pending: "pending",
    success: "success",
    failed: "failed",
    rate_limited: "rate_limited",
    timeout: "timeout"
  }, validate: true

  validates :provider, :model, :operation, :status, presence: true

  scope :recent, -> { order(created_at: :desc) }
  scope :successful, -> { where(status: :success) }
  scope :failed_requests, -> { where.not(status: :success) }

  def duration_seconds
    return unless latency_ms

    latency_ms / 1000.0
  end

  def total_tokens
    input_tokens.to_i + output_tokens.to_i
  end
end

Step 12E – Add reverse associations

Open:

app/models/conversation.rb

Add:

has_many :ai_requests, dependent: :nullify

So:

class Conversation < ApplicationRecord
  has_many :messages, dependent: :destroy
  has_many :ai_requests, dependent: :nullify
end

And in:

app/models/message.rb

add:

has_many :ai_requests, dependent: :nullify

So:

class Message < ApplicationRecord
  belongs_to :conversation

  has_many :ai_requests, dependent: :nullify

  enum :role, {
    user: "user",
    assistant: "assistant",
    system: "system"
  }, validate: true
end

Step 12F – Why AiRequest instead of putting everything in Message?

This is an important architectural decision.

A message answers:

What was said?

An AI request answers:

What happened while generating it?

For example:

Message
--------------------
role: assistant
content: "Ruby is..."

while:

AiRequest
--------------------
provider: openrouter
model: ...
status: success
input_tokens: 240
output_tokens: 120
latency_ms: 1840
retry_count: 0
http_status: 200

This separation is much cleaner.

Step 12G – Generate the Admin Controller

Run:

bin/rails g controller Admin::AiRequests index show

This creates:

app/controllers/admin/ai_requests_controller.rb

app/views/admin/ai_requests/index.html.erb
app/views/admin/ai_requests/show.html.erb

Step 12H – Admin routes

Open:

config/routes.rb

Add:

namespace :admin do
  resources :ai_requests, only: %i[index show]
end

So your routes become something like:

Rails.application.routes.draw do
  resources :conversations, only: [:create, :show] do
    resources :messages, only: [:create]
  end

  namespace :admin do
    resources :ai_requests, only: %i[index show]
  end

  root "conversations#new"
end

Check:

bin/rails routes | grep ai_requests

You should get:

/admin/ai_requests
/admin/ai_requests/:id

Step 12I – Admin Controller

Open:

app/controllers/admin/ai_requests_controller.rb

Use:

class Admin::AiRequestsController < ApplicationController
  before_action :authenticate_admin!

  def index
    @ai_requests = AiRequest
      .includes(:conversation, :message)
      .recent
      .limit(100)

    @total_requests = AiRequest.count

    @successful_requests =
      AiRequest.successful.count

    @failed_requests =
      AiRequest.failed_requests.count

    @total_input_tokens =
      AiRequest.sum(:input_tokens)

    @total_output_tokens =
      AiRequest.sum(:output_tokens)

    @average_latency =
      AiRequest.where.not(latency_ms: nil).average(:latency_ms)

    @estimated_cost =
      AiRequest.sum(:estimated_cost)
  end

  def show
    @ai_request = AiRequest.includes(
      :conversation,
      :message
    ).find(params[:id])
  end

  private

  def authenticate_admin!
    authenticate_or_request_with_http_basic("AI Admin") do |username, password|
      username == Rails.application.credentials.dig(:admin, :username) &&
        password == Rails.application.credentials.dig(:admin, :password)
    end
  end
end

This means the admin dashboard isn’t publicly accessible.

Step 12J – Configure Admin Credentials

Run:

bin/rails credentials:edit

Add:

admin:
username: admin
password: CHANGE_ME

Obviously use a proper password locally.

Then:

bin/rails c

Verify:

Rails.application.credentials.dig(:admin, :username)

and:

Rails.application.credentials.dig(:admin, :password)

Step 12K – Admin Index View

Open:

app/views/admin/ai_requests/index.html.erb

Use:

<h1>AI Observability</h1>

<section>
  <h2>Summary</h2>

  <dl>
    <dt>Total Requests</dt>
    <dd><%= @total_requests %></dd>

    <dt>Successful</dt>
    <dd><%= @successful_requests %></dd>

    <dt>Failed</dt>
    <dd><%= @failed_requests %></dd>

    <dt>Input Tokens</dt>
    <dd><%= number_with_delimiter(@total_input_tokens) %></dd>

    <dt>Output Tokens</dt>
    <dd><%= number_with_delimiter(@total_output_tokens) %></dd>

    <dt>Average Latency</dt>
    <dd>
      <%= @average_latency ? "#{@average_latency.round} ms" : "N/A" %>
    </dd>

    <dt>Estimated Cost</dt>
    <dd>
      <%= @estimated_cost ? number_to_currency(@estimated_cost) : "N/A" %>
    </dd>
  </dl>
</section>

<hr>

<h2>Recent Requests</h2>

<table>
  <thead>
    <tr>
      <th>ID</th>
      <th>Time</th>
      <th>Provider</th>
      <th>Model</th>
      <th>Operation</th>
      <th>Status</th>
      <th>Tokens</th>
      <th>Latency</th>
      <th>Retries</th>
      <th>HTTP</th>
    </tr>
  </thead>

  <tbody>
    <% @ai_requests.each do |request| %>
      <tr>
        <td>
          <%= link_to request.id,
              admin_ai_request_path(request) %>
        </td>

        <td>
          <%= request.created_at.strftime("%Y-%m-%d %H:%M:%S") %>
        </td>

        <td><%= request.provider %></td>

        <td><%= request.model %></td>

        <td><%= request.operation %></td>

        <td><%= request.status %></td>

        <td><%= number_with_delimiter(request.total_tokens) %></td>

        <td>
          <%= request.latency_ms ? "#{request.latency_ms} ms" : "N/A" %>
        </td>

        <td><%= request.retry_count %></td>

        <td><%= request.http_status || "N/A" %></td>
      </tr>
    <% end %>
  </tbody>
</table>

Step 12L – Request Detail View

Open:

app/views/admin/ai_requests/show.html.erb

Use:

<h1>AI Request #<%= @ai_request.id %></h1>

<p>
  <%= link_to "← Back to AI Requests",
      admin_ai_requests_path %>
</p>

<table>
  <tbody>
    <tr>
      <th>Provider</th>
      <td><%= @ai_request.provider %></td>
    </tr>

    <tr>
      <th>Model</th>
      <td><%= @ai_request.model %></td>
    </tr>

    <tr>
      <th>Operation</th>
      <td><%= @ai_request.operation %></td>
    </tr>

    <tr>
      <th>Status</th>
      <td><%= @ai_request.status %></td>
    </tr>

    <tr>
      <th>Streamed</th>
      <td><%= @ai_request.streamed? ? "Yes" : "No" %></td>
    </tr>

    <tr>
      <th>Input Tokens</th>
      <td><%= @ai_request.input_tokens || "N/A" %></td>
    </tr>

    <tr>
      <th>Output Tokens</th>
      <td><%= @ai_request.output_tokens || "N/A" %></td>
    </tr>

    <tr>
      <th>Total Tokens</th>
      <td><%= @ai_request.total_tokens %></td>
    </tr>

    <tr>
      <th>Estimated Cost</th>
      <td>
        <%= @ai_request.estimated_cost || "N/A" %>
      </td>
    </tr>

    <tr>
      <th>Latency</th>
      <td>
        <%= @ai_request.latency_ms ?
            "#{@ai_request.latency_ms} ms" :
            "N/A" %>
      </td>
    </tr>

    <tr>
      <th>Retries</th>
      <td><%= @ai_request.retry_count %></td>
    </tr>

    <tr>
      <th>HTTP Status</th>
      <td><%= @ai_request.http_status || "N/A" %></td>
    </tr>

    <tr>
      <th>Request ID</th>
      <td><%= @ai_request.request_id || "N/A" %></td>
    </tr>

    <tr>
      <th>Started At</th>
      <td><%= @ai_request.started_at || "N/A" %></td>
    </tr>

    <tr>
      <th>Completed At</th>
      <td><%= @ai_request.completed_at || "N/A" %></td>
    </tr>

    <tr>
      <th>Conversation</th>
      <td>
        <% if @ai_request.conversation %>
          <%= link_to(
            "##{@ai_request.conversation.id}",
            conversation_path(@ai_request.conversation)
          ) %>
        <% else %>
          N/A
        <% end %>
      </td>
    </tr>

    <tr>
      <th>Message</th>
      <td>
        <%= @ai_request.message_id || "N/A" %>
      </td>
    </tr>

    <tr>
      <th>Error Class</th>
      <td><%= @ai_request.error_class || "N/A" %></td>
    </tr>

    <tr>
      <th>Error Message</th>
      <td>
        <pre><%= @ai_request.error_message || "N/A" %></pre>
      </td>
    </tr>

    <tr>
      <th>Metadata</th>
      <td>
        <pre><%= JSON.pretty_generate(@ai_request.metadata) %></pre>
      </td>
    </tr>
  </tbody>
</table>

Step 12M – Create some test data

Before wiring the real AI request into this table, let’s verify the admin UI independently.

Run:

bin/rails c

Create:

AiRequest.create!(
  provider: "openrouter",
  model: "openrouter/free",
  operation: "chat",
  status: :success,
  input_tokens: 120,
  output_tokens: 80,
  latency_ms: 1530,
  retry_count: 0,
  http_status: 200,
  request_id: SecureRandom.uuid,
  started_at: 2.seconds.ago,
  completed_at: Time.current,
  streamed: true
)

Then open:

http://localhost:3000/admin/ai_requests

Browser authentication should ask for:

Username:
Password:

Use your configured admin credentials.

You should see:

AI Observability

Total Requests      1
Successful          1
Failed              0
Input Tokens        120
Output Tokens        80
Average Latency    1530 ms

Click the request ID and you’ll see the complete details.

Step 12N – Now connect this to the real AI request

This is the important part.

We don’t want:

AI request
nothing stored

We want:

ChatService
     ↓
AiRequest.pending
     ↓
Ai::Client
     ↓
LLM
     ↓
AiRequest.success

Eventually:

                 AiRequest
                    │
       ┌────────────┼─────────────┐
       ▼            ▼             ▼
    Message    Conversation      LLM
       │                          │
       └──────────────┬───────────┘
                      ▼
                Admin Dashboard

We’ll modify Ai::ChatService to create and update the record around the provider call.

For the non-streaming path first, use this structure:

class Ai::ChatService
  def initialize(
    ai_client: Ai::Client.new,
    prompt_builder_class: Ai::PromptBuilder
  )
    @ai_client = ai_client
    @prompt_builder_class = prompt_builder_class
  end

  def call(conversation:, user_message:)
    conversation.transaction do
      user_message_record = conversation.messages.create!(
        role: :user,
        content: user_message
      )

      messages = @prompt_builder_class
        .new(conversation: conversation)
        .build

      ai_request = conversation.ai_requests.create!(
        message: user_message_record,
        provider: "openrouter",
        model: Ai::Client::MODEL,
        operation: "chat",
        status: :pending,
        streamed: false,
        started_at: Time.current,
        request_id: SecureRandom.uuid
      )

      started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)

      begin
        result = @ai_client.chat(messages: messages)

        latency_ms =
          (
            Process.clock_gettime(Process::CLOCK_MONOTONIC) -
            started_at
          ) * 1000

        assistant_message = conversation.messages.create!(
          role: :assistant,
          content: result[:content],
          model: result[:model],
          input_tokens: result[:input_tokens],
          output_tokens: result[:output_tokens]
        )

        ai_request.update!(
          message: assistant_message,
          status: :success,
          input_tokens: result[:input_tokens],
          output_tokens: result[:output_tokens],
          latency_ms: latency_ms.round,
          completed_at: Time.current,
          http_status: 200
        )

        assistant_message
      rescue => e
        ai_request.update!(
          status: :failed,
          error_class: e.class.name,
          error_message: e.message,
          completed_at: Time.current
        )

        raise
      end
    end
  end
end

One important architecture note

I used:

rescue => e

here only to demonstrate recording unexpected failures.

In the final production version, we’ll distinguish:

timeout
rate limit
provider error
invalid response
unexpected application bug

and map them to the proper AiRequest.status.

That’s coming immediately after this.


Why this dashboard is worth having

You now have a tangible answer to questions like:

How would you monitor an AI application?

You can say:

“I record each AI invocation separately from the conversation message itself. I track provider, model, status, latency, token consumption, retries, HTTP status and error information, then expose that through an internal observability dashboard.”

Then show page:

/admin/ai_requests

That’s much stronger than saying:

“I would use logging.”


One thing I deliberately did NOT add

I don’t recommend storing the complete prompt by default in AiRequest.

Why?

Because prompts can contain:

PII
customer data
confidential company information
secrets

Instead we can later store safe metadata such as:

{
"message_count": 8,
"prompt_tokens": 1200,
"temperature": 0.2
}

and keep sensitive content under the normal conversation access controls.


Issue 1:Fix AI Response: User Safety

Currently when I tested I get the AI Response like:
User Safety: safeResponse Safety: safe

This is a model-selection problem, not a Rails problem.

The response:

User Safety: safeResponse Safety: safe

is characteristic of a content-safety/guardrail model, not a normal conversational model. OpenRouter currently lists Nemotron 3.5 Content Safety (free) as a moderation model whose intended output is exactly safety classifications such as User Safety and Response Safety. (OpenRouter)

Because we’re using:

MODEL = "openrouter/free"

OpenRouter is free to route that request to an available free model. The free-model router is explicitly designed to select among available free models, so you shouldn’t use it when you need a stable application behavior. (OpenRouter)

Fix: choose an actual chat model

For our course, let’s use a specific free conversational model instead of:

MODEL = "openrouter/free"

A good current option is:

MODEL = "openai/gpt-oss-20b:free"

OpenRouter lists free models separately, including general-purpose models; the exact free catalog changes over time.

Change Ai::Client

Open:

app/services/ai/client.rb

Change:

MODEL = "openrouter/free"

to:

MODEL = "openai/gpt-oss-20b:free"

Then test:

bin/rails c
client = Ai::Client.new
result = client.chat(
messages: [
{
role: "user",
content: "Why Node.js as a backend?"
}
]
)
puts result[:content]

We should now get an actual explanatory answer rather than the safety classification.

Why I want a specific model for our project

This is actually a valuable AI engineering lesson.

Current approach

Ai::Client
openrouter/free
??? model

The model can change depending on routing.

Better application architecture

Ai::Client
specific model
predictable behavior

For production systems, model choice should generally be deliberate rather than an accidental consequence of a router.

The openrouter/free router is useful for experimentation, but for our course we’ll use an explicit free model so our behavior stays understandable. OpenRouter itself recommends openrouter/free as a convenient way to sample available free models, which is precisely why it shouldn’t be treated as a fixed model identity.


One more thing: our RAG work needs an embedding model

Don’t use the chat model for embeddings.

We’ll have:

Chat:
openai/gpt-oss-20b:free
Embeddings:
separate embedding model

OpenRouter currently lists free embedding models as well, including NVIDIA’s Nemotron 3 Embed 1B, which is specifically intended for retrieval/RAG. (OpenRouter)

We’ll choose the embedding model separately when we implement Ai::EmbeddingService.

For now

Make this one-line change:

MODEL = "openai/gpt-oss-20b:free"

After that, we’ll continue with Step 13 – generating embeddings and storing the first real vector in document_chunks.


Issue 2: OpenAI::Errors::NotFoundError

Our server Log:

OpenAI::Errors::NotFoundError ({url: "https://openrouter.ai/api/v1/chat/completions", status: 404, body: {error: {message: "This model is unavailable for free. The paid version is available now - use this slug instead: openai/gpt-oss-20b", code: 404}, user_id: ... 

Since we’re using the openai Ruby SDK, our rescue layer should use OpenAI::Errors::*, not Faraday exceptions. The SDK maps HTTP status codes such as 400, 401, 403, 404, 409, 422, 429 and 500+ into its own typed exceptions, and it has separate APIConnectionError / APITimeoutError classes. (https://github.com/openai/openai-ruby/blob/main/lib/openai/errors.rb)

Also, our 404 message tells us something important:

OpenRouter’s current free catalog does include openai/gpt-oss-20b:free, but free endpoints can change availability. (OpenRouter)

Our earlier 404 specifically said that the endpoint was unavailable for free at that moment and suggested the paid slug. Since OpenRouter currently lists the :free variant as free, this looks like provider/availability inconsistency, not that our slug was fundamentally wrong. OpenRouter also notes that free variants are rate-limited and availability can vary. (OpenRouter)

1. Fix the model

Let’s use the explicit free model again:

MODEL = "openai/gpt-oss-20b:free"

OpenRouter currently lists that exact slug as free with zero input/output pricing. (OpenRouter)

If that endpoint temporarily fails, we can switch to another currently listed free model rather than using openrouter/free.

2. Fix Ai::Client error handling

Also change our Ai::Client chat rescues from: Faraday::TooManyRequestsError 
Faraday::TimeoutError 
Faraday::Error 
to: similar to: OpenAI::Errors::NotFoundError etc, 

check: https://github.com/openai/openai-ruby/blob/main/lib/openai/errors.rb

Let’s use the actual SDK error hierarchy.

The important classes are:

OpenAI::Errors::BadRequestError
OpenAI::Errors::AuthenticationError
OpenAI::Errors::PermissionDeniedError
OpenAI::Errors::NotFoundError
OpenAI::Errors::ConflictError
OpenAI::Errors::UnprocessableEntityError
OpenAI::Errors::RateLimitError
OpenAI::Errors::InternalServerError
OpenAI::Errors::APIConnectionError
OpenAI::Errors::APITimeoutError

The current SDK maps HTTP 404 → NotFoundError, 429 → RateLimitError, and 500+ → InternalServerError. (GitHub)

So replace our old Faraday rescues entirely.

app/services/ai/client.rb

Use:

class Ai::Client
  MODEL = "openai/gpt-oss-20b:free"
  BASE_URL = "https://openrouter.ai/api/v1"

  def initialize
    api_key = Rails.application.credentials.dig(:openrouter, :api_key)

    raise "OpenRouter API key is missing" if api_key.blank?

    @client = OpenAI::Client.new(
      api_key: api_key,
      base_url: BASE_URL
    )
  end

  def chat(messages:)
    response = @client.chat.completions.create(
      model: MODEL,
      messages: messages
    )

    {
      content: response.choices.first.message.content,
      model: response.model,
      input_tokens: response.usage&.prompt_tokens,
      output_tokens: response.usage&.completion_tokens
    }
  rescue OpenAI::Errors::RateLimitError => e
    raise Ai::RateLimitError, e.message

  rescue OpenAI::Errors::APITimeoutError => e
    raise Ai::TimeoutError, e.message

  rescue OpenAI::Errors::APIConnectionError => e
    raise Ai::ProviderError, e.message

  rescue OpenAI::Errors::BadRequestError,
          OpenAI::Errors::AuthenticationError,
          OpenAI::Errors::PermissionDeniedError,
          OpenAI::Errors::NotFoundError,
          OpenAI::Errors::ConflictError,
          OpenAI::Errors::UnprocessableEntityError,
          OpenAI::Errors::InternalServerError,
          OpenAI::Errors::APIStatusError => e
    raise Ai::ProviderError, e.message
  end
end

The specific NotFoundError you just encountered will therefore be caught here:

rescue OpenAI::Errors::NotFoundError => e

and converted into our application-level:

Ai::ProviderError

3. Why keep Ai::*Error?

This is the architecture we want:

OpenRouter / OpenAI SDK
          ↓
OpenAI::Errors::NotFoundError
          ↓
      Ai::Client
          ↓
    Ai::ProviderError
          ↓
     ChatService
          ↓
 Rails application

Your Rails code shouldn’t care whether the provider throws:

OpenAI::Errors::NotFoundError

or some completely different exception tomorrow.

That’s precisely why our abstraction exists.

4. But don’t catch everything as ProviderError

There’s an important distinction.

We should not do:

rescue StandardError => e
raise Ai::ProviderError
end

because a programming bug such as:

NoMethodError

would then masquerade as an LLM provider failure.

Keep provider/API exceptions mapped, but let genuine application bugs surface.

5. Our current custom errors are good

We already created:

class Ai::Error < StandardError
end
class Ai::ProviderError < Ai::Error
end
class Ai::RateLimitError < Ai::Error
end
class Ai::TimeoutError < Ai::Error
end

That’s still a good design.

Now the relationship is:

OpenAI::Errors::RateLimitError
Ai::RateLimitError
OpenAI::Errors::APITimeoutError
Ai::TimeoutError
OpenAI::Errors::NotFoundError
Ai::ProviderError

6. Test the actual exception

Since we currently have a 404 issue, this is a useful test.

In Rails console:

bin/rails c

Then, Try the request with the unavailable model if you want to verify the mapping:

client = Ai::Client.new

client.chat(
  messages: [
    {
      role: "user",
      content: "Why Node.js as a backend?"
    }
  ]
)

You should now receive:

Ai::ProviderError

rather than:

OpenAI::Errors::NotFoundError

That proves our abstraction is working.


Happy Rails AI Integration!

Rails 7+ API error handling that scales ⚖️

A solid API error strategy gives you:

  • Consistent JSON error shapes
  • Correct HTTP status codes
  • Separation of concerns (domain vs transport)
  • Observability without leaking internals

Below is a practical, production-ready approach that covers controller hooks, controllers, models/libs, background jobs, and more—illustrated with a real scenario from Session::CouponCode.

Core principles

  • Keep transport (HTTP, JSON) in controllers; keep domain logic in models/libs.
  • Map known, expected failures to specific HTTP statuses.
  • Log unexpected failures; return a generic message to clients.
  • Centralize API error rendering in a base controller.

1) A single error boundary for all API controllers

Create a base Error::ApiError and rescue it (plus a safe catch‑all) in your ApiController.

# lib/error/api_error.rb
module Error
  class ApiError < StandardError
    attr_reader :status, :details
    def initialize(message, status = :unprocessable_entity, details: nil)
      super(message)
      @status  = status
      @details = details
    end
  end
end
# app/controllers/api_controller.rb
class ApiController < ActionController::Base
  include LocaleConcern
  skip_forgery_protection

  impersonates :user,
               ......

  # Specific handlers first
  rescue_from Error::ApiError,                          with: :handle_api_error
  rescue_from ActionController::ParameterMissing,       with: :handle_bad_request
  rescue_from ActiveRecord::RecordNotFound,             with: :handle_not_found
  rescue_from ActiveRecord::RecordInvalid,              with: :handle_unprocessable
  rescue_from ActiveRecord::RecordNotUnique,            with: :handle_conflict

  # Catch‑all last
  rescue_from StandardError,                            with: :handle_standard_error

  private

  def handle_api_error(e)
    render json: { success: false, error: e.message, details: e.details }, status: e.status
  end

  def handle_bad_request(e)
    render json: { success: false, error: e.message }, status: :bad_request
  end

  def handle_not_found(_e)
    render json: { success: false, error: 'Not found' }, status: :not_found
  end

  def handle_unprocessable(e)
    render json: { success: false, error: e.record.errors.full_messages }, status: :unprocessable_entity
  end

  def handle_conflict(_e)
    render json: { success: false, error: 'Conflict' }, status: :conflict
  end

  def handle_standard_error(e)
    Rollbar.error(e, path: request.fullpath, client_id: try(:current_client)&.id)
    render json: { success: false, error: 'Something went wrong' }, status: :internal_server_error
  end
end
  • Order matters. Specific rescue_from before StandardError.
  • This pattern avoids duplicating rescue_from across controllers and keeps HTML controllers unaffected.

2) Errors in before actions

Because before_action runs inside controllers, the same rescue_from handlers apply.

Two patterns:

  • Render in the hook for simple guard clauses:
before_action :require_current_client

def require_current_client
  return if current_client
  render json: { success: false, error: 'require_login' }, status: :unauthorized
end
  • Raise a domain/auth error and let rescue_from handle JSON:
# lib/error/unauthorized_error.rb
module Error
  class UnauthorizedError < Error::ApiError
    def initialize(message = 'require_login') = super(message, :unauthorized)
  end
end

before_action :require_current_client

def require_current_client
  raise Error::UnauthorizedError unless current_client
end

Prefer raising if you want consistent global handling and logging.

3) Errors inside controllers

Use explicit renders for happy-path control flow; raise for domain failures:

def create
  form = CreateThingForm.new(params.require(:thing).permit(:name))
  result = CreateThing.new(form: form).call

  if result.success?
    render json: { success: true, thing: result.thing }, status: :created
  else
    # Known domain failure → raise an ApiError to map to 422
    raise Error::ApiError.new(result.message, :unprocessable_entity, details: result.details)
  end
end

Common controller exceptions (auto-mapped above):

  • ActionController::ParameterMissing → 400
  • ActiveRecord::RecordNotFound → 404
  • ActiveRecord::RecordInvalid → 422
  • ActiveRecord::RecordNotUnique → 409

4) Errors in models, services, and libs

Do not call render here. Either:

  • Return a result object (Success/Failure), or
  • Raise a domain‑specific exception that the controller maps to an HTTP response.

Example from our scenario, Session::CouponCode:

# lib/error/session/coupon_code_error.rb
module Error
  module Session
    class CouponCodeError < Error::ApiError; end
  end
end
# lib/session/coupon_code.rb
class Session::CouponCode
  def discount_dollars
    # ...
    case
    when coupon_code.gift_card?
      # ...
    when coupon_code.discount_code?
      # ...
    when coupon_code.multiorder_discount_code?
      # ...
    else
      raise Error::Session::CouponCodeError, 'Unrecognized discount code'
    end
  end
end

Then, in ApiController, the specific handler (or the Error::ApiError handler) renders JSON with a 422.

This preserves separation: models/libs raise; controllers decide HTTP.

5) Other important surfaces

  • ActiveJob / Sidekiq
  • Prefer retry_on, discard_on, and job‑level rescue with logging.
  • Return no HTTP here; jobs are async.
class MyJob < ApplicationJob
  retry_on Net::OpenTimeout, wait: 10.seconds, attempts: 3
  discard_on Error::ApiError
  rescue_from(StandardError) { |e| Rollbar.error(e) }
end
  • Mailers
  • Use rescue_from to avoid bubble‑ups crashing deliveries:
class ApplicationMailer < ActionMailer::Base
  rescue_from Postmark::InactiveRecipientError, Postmark::InvalidEmailRequestError do
    # no-op / log
  end
end
  • Routing / 404
  • For APIs, keep 404 mapping at the controller boundary with rescue_from ActiveRecord::RecordNotFound.
  • For HTML, config.exceptions_app = routes + ErrorsController.
  • Middleware / Rack
  • For truly global concerns, use middleware. This is rarely necessary for controller-scoped API errors in Rails.
  • Validation vs. Exceptions
  • Use validations (ActiveModel/ActiveRecord) for expected user errors.
  • Raise exceptions for exceptional conditions (invariants violated, external systems fail unexpectedly).

6) Observability

  • Always log unexpected errors in the catch‑all (StandardError).
  • Add minimal context: client_id, request.fullpath, feature flags.
  • Avoid leaking stack traces or internal messages to clients. Send generic messages on 500s.

7) Testing

  • Unit test domain services to ensure they raise Error::ApiError (or return Failure).
  • Controller/request specs: assert status codes and JSON shapes for both happy path and error path.
  • Ensure before_action guards either render or raise as intended.

Applying this to our scenario

  • /lib/session/coupon_code.rb raises Error::Session::CouponCodeError on unknown/invalid discount values.
  • /app/controllers/api_controller.rb rescues that error and returns JSON:
  • { success: false, error: e.message } with a 422 (or via Error::ApiError base).

This converts prior 500s into clean API responses and keeps error handling centralized.

When to generalize vs. specialize

  • Keep a catch‑all rescue_from StandardError in ApiController to prevent 500s from leaking internals.
  • Still add specific handlers (or subclass Error::ApiError) for known cases to control the correct status code and message.
  • Do not replace everything with only StandardError—you’ll lose semantics and proper HTTP codes.

  • Key takeaways
  • Centralize API‐wide error handling in ApiController using specific handlers + a safe catch‑all.
  • Raise domain errors in models/libs; render JSON only in controllers.
  • Map common Rails exceptions to correct HTTP statuses; log unexpected errors.
  • Prefer Error::ApiError as a base for consistent message/status handling across the API.

Exciting 🔮 features of Ruby Programming Language

Ruby is a dynamic, object-oriented programming language designed for simplicity and productivity. Here are some of its most exciting features:

1. Everything is an Object

In Ruby, every value is an object, even primitive types like integers or nil. This allows you to call methods directly on literals.
Example:

5.times { puts "Ruby!" }      # 5 is an Integer object with a `times` method
3.14.floor                    # => 3 (Float object method)
true.to_s                     # => "true" (Boolean → String)
nil.nil?                      # => true (Method to check if object is nil)

2. Elegant and Readable Syntax

Ruby’s syntax prioritizes developer happiness. Parentheses and semicolons are often optional.
Example:

# A method to greet a user (parentheses optional)
def greet(name = "Guest")
  puts "Hello, #{name.capitalize}!"
end

greet "alice"  # Output: "Hello, Alice!"

3. Blocks and Iterators

Ruby uses blocks (anonymous functions) to create powerful iterators. Use {} for single-line blocks or do...end for multi-line.
Example:

# Multiply even numbers by 2
numbers = [1, 2, 3, 4]
result = numbers.select do |n|
  n.even?
end.map { |n| n * 2 }

puts result # => [4, 8]

4. Mixins via Modules

Modules let you share behavior across classes without inheritance.
Example:

module Loggable
  def log(message)
    puts "[LOG] #{message}"
  end
end

class User
  include Loggable  # Mix in the module
end

user = User.new
user.log("New user created!") # => [LOG] New user created!

5. Metaprogramming

Ruby can generate code at runtime. For example, dynamically define methods.
Example:

class Person
  # Define methods like name= and name dynamically
  attr_accessor :name, :age
end

person = Person.new
person.name = "Alice"
puts person.name # => "Alice"

6. Duck Typing

Focus on behavior, not type. If it “quacks like a duck,” treat it as a duck.
Example:

def print_length(obj)
  obj.length  # Works for strings, arrays, or any object with a `length` method
end

puts print_length("Hello")  # => 5
puts print_length([1, 2, 3]) # => 3

7. Symbols

Symbols (:symbol) are lightweight, immutable strings used as identifiers.
Example:

# Symbols as hash keys (faster than strings)
config = { :theme => "dark", :font => "Arial" }
puts config[:theme] # => "dark"

# Modern syntax (Ruby 2.0+):
config = { theme: "dark", font: "Arial" }

8. Ruby Set

A set is a Ruby class that helps you create a list of unique items. A set is a class that stores items like an array. But with some special attributes that make it 10x faster in specific situations! All the items in a set are guaranteed to be unique.

What’s the difference between a set & an array?
A set has no direct access to elements:

> seen[3]
(irb):19:in '<main>': undefined method '[]' for #<Set:0x000000012fc34058> (NoMethodError)

But a set can be converted into an array any time you need:

> seen.to_a
=> [4, 8, 9, 90]
> seen.to_a[3]
=> 90

Set: Fast lookup times (with include?)

If you need these then a set will give you a good performance boost, and you won’t have to be calling uniq on your array every time you want unique elements.
Reference: https://www.rubyguides.com/2018/08/ruby-set-class/

Superset & Subset

A superset is a set that contains all the elements of another set.

Set.new(10..40) >= Set.new(20..30)

subset is a set that is made from parts of another set:

Set.new(25..27) <= Set.new(20..30)


Example:

> seen = Set.new
=> #<Set: {}>
> seen.add(4)
=> #<Set: {4}>
> seen.add(4)
=> #<Set: {4}>
> seen.add(8)
=> #<Set: {4, 8}>
> seen.add(9)
=> #<Set: {4, 8, 9}>
> seen.add(90)
=> #<Set: {4, 8, 9, 90}>
> seen.add(4)
=> #<Set: {4, 8, 9, 90}>

> seen.to_a
=> [4, 8, 9, 90]
> seen.to_a[3]
=> 90
> seen | (1..10) # set union operator
=> #<Set: {4, 8, 9, 90, 1, 2, 3, 5, 6, 7, 10}>

> seen =  seen | (1..10)
=> #<Set: {4, 8, 9, 90, 1, 2, 3, 5, 6, 7, 10}>
> seen - (3..4)  # set difference operator
=> #<Set: {8, 9, 90, 1, 2, 5, 6, 7, 10}>

set1 = Set.new(1..5)
set2 = Set.new(4..8) 
> set1 & set2  # set intersection operator
=> #<Set: {4, 5}>

9. Rich Standard Library

Ruby’s Enumerable module adds powerful methods to collections.
Example:

# Group numbers by even/odd
numbers = [1, 2, 3, 4]
grouped = numbers.group_by { |n| n.even? ? :even : :odd }
puts grouped # => { :odd => [1, 3], :even => [2, 4] }

10. Convention over Configuration

Ruby minimizes boilerplate code with conventions.
Example:

class Book
  attr_accessor :title, :author  # Auto-generates getters/setters
  def initialize(title, author)
    @title = title
    @author = author
  end
end

book = Book.new("Ruby 101", "Alice")
puts book.title # => "Ruby 101"

11. Method Naming Conventions

Method suffixes clarify intent:

  • ? for boolean returns.
  • ! for dangerous/mutating methods.
    Example:
str = "ruby"
puts str.capitalize! # => "Ruby" (mutates the string)
puts str.empty?      # => false

12. Functional Programming Features

Ruby supports Procs (objects holding code) and lambdas.
Example:

# Lambda example
double = lambda { |x| x * 2 }
puts [1, 2, 3].map(&double) # => [2, 4, 6]

# Proc example
greet = Proc.new { |name| puts "Hello, #{name}!" }
greet.call("Bob") # => "Hello, Bob!"

13. IRB (Interactive Ruby)

Test code snippets instantly in the REPL:

$ irb
irb> [1, 2, 3].sum # => 6
irb> Time.now.year  # => 2023

14. Garbage Collection

Automatic memory management:

# No need to free memory manually
1000.times { String.new("temp") } # GC cleans up unused objects

15. Community and Ecosystem

RubyGems (packages) like:

  • Rails: Full-stack web framework.
  • RSpec: Testing framework.
  • Sinatra: Lightweight web server.

Install a gem:

gem install rails

16. Error Handling

Use begin/rescue for exceptions:

begin
  puts 10 / 0
rescue ZeroDivisionError => e
  puts "Error: #{e.message}" # => "Error: divided by 0"
end

17. Open Classes

Modify existing classes (use carefully!):

class String
  def reverse_and_upcase
    self.reverse.upcase
  end
end

puts "hello".reverse_and_upcase # => "OLLEH"

18. Reflection

Inspect objects at runtime:

class Dog
  def bark
    puts "Woof!"
  end
end

dog = Dog.new
puts dog.respond_to?(:bark) # => true
puts Dog.instance_methods   # List all methods

Ruby’s design philosophy emphasizes developer productivity and joy. These features make it ideal for rapid prototyping, web development (with Rails), scripting, and more.

Enjoy Ruby! 🚀