Skip to content

Cache backend exceptions bypass database fallback in Ecto stores #62

@fuelen

Description

@fuelen

When the cache backend (e.g. Redis via NebulexRedisAdapter) raises an exception (connection timeout, closed connection, etc.), the database fallback in Boruta.Ecto.Clients is never reached because the exception crashes the request process.

The problem

Boruta.Ecto.Clients.get_client/1 has a fallback mechanism:

def get_client(id) do
  case get_client(:from_cache, id) do
    {:ok, client} -> client
    {:error, _reason} -> get_client(:from_database, id)
  end
end

This works when the client is simply not in the cache (ClientStore returns {:error, "Client not cached."}). However, when the cache backend itself is unavailable, cache_backend().get(...) in ClientStore.get_by_id/1 raises an exception rather than returning an error tuple. The {:error, _reason} branch is never reached.

Additionally, even if the get failure were handled, the database fallback would also crash because get_client(:from_database, id) calls ClientStore.put/1 to populate the cache after fetching from the database — and cache_backend().put(...) raises too when the backend is down.

The same pattern affects authorized_scopes/1 and public!/0.

Reproduction

  1. Configure Boruta with a Redis-backed Nebulex cache as cache_backend
  2. Make Redis temporarily unavailable (or simulate a timeout)
  3. Send a request that triggers client lookup (e.g. POST /oauth/introspect)
  4. The request crashes instead of falling back to the database

Stack trace from production:

** (Redix.ConnectionError) unknown POSIX error: timeout
    (nebulex_redis_adapter 2.4.2) lib/nebulex_redis_adapter/command.ex:169: NebulexRedisAdapter.Command.handle_command_response/2
    (nebulex_redis_adapter 2.4.2) lib/nebulex_redis_adapter.ex:525: NebulexRedisAdapter.get/3
    (boruta 2.3.4) lib/boruta/adapters/ecto/stores/client_store.ex:11: Boruta.Ecto.ClientStore.get_client/1
    (boruta 2.3.4) lib/boruta/adapters/ecto/clients.ex:16: Boruta.Ecto.Clients.get_client/1
    (boruta 2.3.4) lib/boruta/oauth/authorization/client.ex:52: Boruta.Oauth.Authorization.Client.authorize/1
    (boruta 2.3.4) lib/boruta/oauth/introspect.ex:32: Boruta.Oauth.Introspect.token/1

Suggested fix

Option A: Add try/rescue in each ClientStore function that calls the cache backend:

defp get_by_id(id) do
  cache_backend().get({Client, id})
rescue
  _ -> nil
end

def put(client) do
  with :ok <- cache_backend().put({Client, client.id}, client) do
    {:ok, client}
  end
rescue
  _ -> {:ok, client}
end

Option B (preferred): Introduce a wrapper module around cache_backend() that centralizes error handling. This avoids sprinkling rescue across every store module (ClientStore, TokenStore, etc.) and provides a single place to handle logging/telemetry for cache failures:

defmodule Boruta.Cache do
  import Boruta.Config, only: [cache_backend: 0]

  require Logger

  def get(key) do
    cache_backend().get(key)
  rescue
    error ->
      Logger.warning("Cache backend error on get: #{inspect(error)}")
      nil
  end

  def put(key, value) do
    cache_backend().put(key, value)
  rescue
    error ->
      Logger.warning("Cache backend error on put: #{inspect(error)}")
      :ok
  end

  def delete(key) do
    cache_backend().delete(key)
  rescue
    error ->
      Logger.warning("Cache backend error on delete: #{inspect(error)}")
      :ok
  end
end

Then ClientStore and other stores would call Boruta.Cache.get/1 instead of cache_backend().get/1 directly.

With either option, when the cache backend is down:

  • reads fall through to the database (existing fallback logic works as intended)
  • writes silently skip caching (the client data is still returned to the caller)
  • the system stays operational with degraded performance (all requests hit the database) rather than being completely down

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions