skip to content

How do you make cache reads resilient to backing-store failures using CacheErrorHandler, and what are the risks?

level: principalimportance: must knowfreq 45%

answer

  1. Four callbacks: get/put/evict/clear
  2. Default SimpleCacheErrorHandler rethrows
  3. Swallow GET => failure becomes a miss (fail-open)
  4. Swallowing EVICT => stale data risk
  5. Register via CachingConfigurer.errorHandler(); handles store errors not method errors

basics

~20 s

Implement CacheErrorHandler and override handleCacheGetError to log and swallow the exception so a failed cache read (e.g. Redis down) becomes a miss and the method runs. The default SimpleCacheErrorHandler rethrows. Be careful swallowing put/evict errors, which can cause stale data.

solid answer

~50 s

CacheErrorHandler intercepts exceptions thrown by the cache store itself — not by your business method. It has four callbacks: handleCacheGetError, handleCachePutError, handleCacheEvictError, handleCacheClearError. The default SimpleCacheErrorHandler rethrows all of them, so a Redis outage would surface as a request failure. For resilient reads you provide a custom handler that logs and swallows handleCacheGetError, turning a store failure into a cache miss so the target method executes against the source of truth. You register it globally via CachingConfigurer.errorHandler(). The subtle risk is on writes: swallowing handleCacheEvictError or handleCachePutError can leave stale entries — a failed evict during an update means readers keep getting old data. So many teams swallow get errors for availability but let evict/clear errors propagate (or trigger alerting), and consider a short TTL as a safety net. Note the handler only covers cache-infrastructure errors, not exceptions from the cached method.

code

java · 25 lines
java
public class ResilientCacheErrorHandler implements CacheErrorHandler {
    private static final Logger log = LoggerFactory.getLogger(ResilientCacheErrorHandler.class);

    @Override public void handleCacheGetError(RuntimeException ex, Cache cache, Object key) {
        // fail-open: treat store failure as a miss so the method still runs
        log.warn("Cache GET failed on '{}' key={}; falling through to source", cache.getName(), key, ex);
    }
    @Override public void handleCachePutError(RuntimeException ex, Cache cache, Object key, Object value) {
        log.warn("Cache PUT failed on '{}' key={}", cache.getName(), key, ex);
    }
    @Override public void handleCacheEvictError(RuntimeException ex, Cache cache, Object key) {
        // do NOT silently swallow: a missed eviction can serve stale data
        throw ex;
    }
    @Override public void handleCacheClearError(RuntimeException ex, Cache cache) {
        throw ex;
    }
}

@Configuration
@EnableCaching
class CacheConfig implements CachingConfigurer {
    @Bean CacheManager cacheManager() { return new CaffeineCacheManager(); }
    @Override public CacheErrorHandler errorHandler() { return new ResilientCacheErrorHandler(); }
}

go deeper

for a junior

Know the default rethrows and a custom handler can turn cache failures into misses.

for a middle

Name the four callbacks and implement a fail-open get handler registered via CachingConfigurer.

for a senior

Distinguish get vs evict swallowing risks and add TTL/observability safeguards.

for a principal

Define an org-wide policy: fail-open reads, propagate/alert on invalidation failures, pair with metrics/circuit breaking and bounded TTLs for correctness.

## What CacheErrorHandler covers (and doesn't) `org.springframework.cache.interceptor.CacheErrorHandler` handles exceptions thrown **by the cache store operations**, i.e. when the caching layer talks to the backing provider and that call fails (connection refused, timeout, serialization error). It does **not** handle exceptions from your annotated business method — those propagate normally. This distinction is crucial: the error handler is about the *cache being unavailable/broken*, not about the domain logic failing. ## The four callbacks ``` void handleCacheGetError(RuntimeException ex, Cache cache, Object key); void handleCachePutError(RuntimeException ex, Cache cache, Object key, Object value); void handleCacheEvictError(RuntimeException ex, Cache cache, Object key); void handleCacheClearError(RuntimeException ex, Cache cache); ``` Each receives the offending exception plus context. Returning normally = **swallow** (the framework proceeds as if the cache operation didn't happen); rethrowing = propagate the failure to the caller. ## Default behaviour `SimpleCacheErrorHandler` **rethrows** every error. So with the default, if Redis is down, a `@Cacheable` read fails the whole request — the cache becomes a hard dependency. That is often undesirable for a cache, whose whole point is to be an optional accelerator. ## Resilient reads pattern For a **fail-open** cache, override `handleCacheGetError` to log and return (swallow). Effect: a failed lookup is treated as a **cache miss**, so Spring invokes the target method and hits the source of truth (DB/service). Availability is preserved at the cost of extra load during the outage. This is the canonical 'cache is best-effort' posture. ## The write-side danger Swallowing is *not* automatically safe for mutations: - **handleCacheEvictError**: if an `@CacheEvict` on an update fails and you swallow it, the stale entry survives and subsequent reads return outdated data — a correctness bug, not just a performance one. - **handleCachePutError**: a swallowed `@CachePut` failure means the cache silently misses the new value; usually less dangerous than a failed evict but still leaves the cache inconsistent. - **handleCacheClearError**: swallowing a failed clear can leave a whole cache stale. A common principled policy: **swallow get errors (availability), but let evict/clear errors propagate or at minimum alert loudly**, because a failed invalidation risks serving wrong data. A defensive complement is a **modest TTL** so any missed invalidation self-heals within a bounded window. ## Registration Provide the handler globally through `CachingConfigurer.errorHandler()` (the one place for shared caching infrastructure under `@EnableCaching`). There is no per-`@Cacheable` `errorHandler` attribute. ## Interaction with circuit breakers / resilience At scale, teams pair a fail-open `handleCacheGetError` with observability (metrics on swallowed errors) and sometimes a circuit breaker so the app stops hammering a dead cache. The error handler is the Spring-native seam; it doesn't itself implement backoff. ## Gotchas - Only cache-store exceptions are handled — not method exceptions. Don't use it for business error handling. - Swallowing indiscriminately (all four callbacks) can mask serious inconsistency; be selective. - Log with enough context (cache name, key) but avoid logging sensitive values. - A swallowed get during a stampede means many concurrent misses hit the DB — combine with request coalescing/limits if that's a concern. - The handler runs for every failing cache op; keep it cheap and non-throwing (an exception thrown *from* the handler propagates).

  • Why is swallowing handleCacheEvictError more dangerous than swallowing handleCacheGetError?
    A swallowed get just causes a miss and a recomputation — correct but slower. A swallowed evict leaves a stale entry, so later reads return outdated data: a correctness bug. Missed invalidations should propagate or alert, and a TTL bounds the staleness.
  • Does CacheErrorHandler catch exceptions thrown by your @Cacheable method?
    No. It only handles exceptions from the cache-store operations (get/put/evict/clear). Business-method exceptions propagate normally and bypass the cache.

saying these in an interview costs you the question

  • Thinking CacheErrorHandler catches business-method exceptions
  • Blindly swallowing all four callbacks, including evict/clear, risking stale data
  • Believing SimpleCacheErrorHandler swallows errors (it rethrows)
  • Expecting a per-@Cacheable errorHandler attribute (it's global via CachingConfigurer)

context