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
- Configure Boruta with a Redis-backed Nebulex cache as
cache_backend
- Make Redis temporarily unavailable (or simulate a timeout)
- Send a request that triggers client lookup (e.g.
POST /oauth/introspect)
- 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
When the cache backend (e.g. Redis via
NebulexRedisAdapter) raises an exception (connection timeout, closed connection, etc.), the database fallback inBoruta.Ecto.Clientsis never reached because the exception crashes the request process.The problem
Boruta.Ecto.Clients.get_client/1has a fallback mechanism:This works when the client is simply not in the cache (
ClientStorereturns{:error, "Client not cached."}). However, when the cache backend itself is unavailable,cache_backend().get(...)inClientStore.get_by_id/1raises an exception rather than returning an error tuple. The{:error, _reason}branch is never reached.Additionally, even if the
getfailure were handled, the database fallback would also crash becauseget_client(:from_database, id)callsClientStore.put/1to populate the cache after fetching from the database — andcache_backend().put(...)raises too when the backend is down.The same pattern affects
authorized_scopes/1andpublic!/0.Reproduction
cache_backendPOST /oauth/introspect)Stack trace from production:
Suggested fix
Option A: Add
try/rescuein eachClientStorefunction that calls the cache backend:Option B (preferred): Introduce a wrapper module around
cache_backend()that centralizes error handling. This avoids sprinklingrescueacross every store module (ClientStore,TokenStore, etc.) and provides a single place to handle logging/telemetry for cache failures:Then
ClientStoreand other stores would callBoruta.Cache.get/1instead ofcache_backend().get/1directly.With either option, when the cache backend is down: