In short. When Redis does not answer, every use of it in my backend already has an answer decided in the code, and it is not the same everywhere. JWT revocation denies (fail-closed): if it cannot check, it treats the token as revoked and the request carries on as anonymous. The idempotency key and the lock let it through (fail-open): the operation runs without protection. The single-use booking token and the video heartbeat filter decide nothing: the exception bubbles up and the user sees a 500 error. And the Spring cache, which has no error handler, behaves like the latter. It is not inconsistency: the cost of the error decides. But there is a fact no catch shows: with Redis stalled but not switched off, every call costs two seconds before falling into the chosen branch, because of the read timeout the project has configured. I measured that time on a test Redis: 2,001 ms for each command, against 0.1-0.2 ms when healthy.
Method. The cells of the table come from reading the backend's code (files and lines indicated) and from the Spring documentation; I did not start the application nor run its tests, so no cell was observed as an HTTP response. What I did measure is the behaviour of a client against a test Redis 8.0.5 (port 6394) in three states: healthy, paused and switched off. The test client is Python, not Lettuce: the shape of the timings is informative, I do not know the Java client's figures.
The question and the table
What does the backend do when Redis does not answer?
The table has seven rows, one per use. For each I read the point where the Redis error becomes a decision. The "How I know" column separates what I read in the code from what I inferred from the documentation:
| Use | With Redis down | Where | How I know |
|---|---|---|---|
| JWT revocation | denies: isRevoked answers true, the request carries on as anonymous | JwtRevocationService, JwtAuthenticationFilter | code read |
| Idempotency key | lets it through: tryReserveIdempotencyKey answers true | IdempotencyService | code read |
| Lock | lets it through: it returns a handle anyway, no mutual exclusion | IdempotencyService.tryAcquireLock | code read |
| Single-use token, issuing | fails: the exception prevents handing out the token | BookingTokenService.generateAndRegister | code read |
| Single-use token, consuming | the exception bubbles up: 500 error | BookingTokenService.validateAndBurn, ApproveBookingUseCaseImpl | code read, no catch on the path |
| Video heartbeat dedup | the exception bubbles up: 500 error | RedisHeartbeatDedupGate, LessonEngagementController | code read, no catch on the path |
| Spring cache | the exception bubbles up to the caller | RedisCacheConfig | Spring documentation plus the absence of a handler in the repository |
The table reads by its middle column. The first three rows have a catch that picks a value: two pick "yes, go ahead", one "no, deny". The last four make no choice at all, and the absence of a choice is a choice: the error goes to the generic handler, which answers 500. In the rest of the article I look first at who decides, then at who does not, then at how much it costs to get there.
One row outside the table: the rate limit does not use Redis
The rate limit is not in the table because it does not use Redis, even though two comments in the repository suggest it. The class is based on in-memory buckets in a Caffeine cache, and the comment says that for multiple instances it would have to be replaced with a Redis-backed store. I checked with a search:
$ grep -n "in-memory\|Caffeine\|bucket4j-redis" backend/src/main/java/dev/federicocalo/portfolio/infrastructure/ratelimit/RateLimitConfig.java | head -4
3:import com.github.benmanes.caffeine.cache.Caffeine;
15: * Rate-limiting configuration backed by Bucket4j in-memory buckets.
31: * a Caffeine cache (max 10 000 entries, expire-after-access 1 h) that provides
33: * For multi-instance deployments replace with a Redis-backed store via
A real consequence follows: if Redis goes down the rate limit keeps working, and with two backend instances each would have its own counters. The comment in the cache file, which lists the rate limit counters among the Redis keys, is therefore wrong: I followed the code and not the comment.
Who decides: fail-closed and fail-open
JWT revocation denies
The revocation check queries Redis in two ways, looking up the token's jti in a denylist and comparing the issue instant with a per-user marker. Any exception, including a missing connection, leads to returning true, that is "revoked". This is the excerpt, from the repository, not run by me:
public boolean isRevoked(String jti, Long userId, Instant issuedAt) {
try {
if (Boolean.TRUE.equals(redis.hasKey(DENYLIST_PREFIX + jti))) { return true; }
String markerValue = redis.opsForValue().get(VALID_AFTER_PREFIX + userId);
// ... comparison of issuedAt with the marker ...
return false;
} catch (Exception e) {
// Fail CLOSED: Redis down -> treat token as revoked to prevent access
log.warn("[JWT-REVOKE] Redis unavailable ... failing CLOSED", jti, userId, e.getMessage());
return true;
}
}
The filter that calls this method does not answer 401: if the token turns out to be revoked it skips creating the security context and lets the request carry on, so it reaches the controllers as anonymous. The filter's comment says it unambiguously: "skip populating SecurityContext; request proceeds as anonymous". Public pages keep answering, protected ones answer as for a user without a session. The choice is a security one: a revoked token must not work, and being unable to prove that it is not revoked amounts, in this design, to proving that it is.
Idempotency and the lock let it through
The idempotency key reservation does the opposite. If setIfAbsent throws, the catch logs a warning and returns true, that is "first time, go ahead":
try {
Boolean reserved = redis.opsForValue()
.setIfAbsent(buildIdemKey(scope, key), IN_FLIGHT_MARKER, IDEM_TTL);
return reserved == null || reserved;
} catch (Exception e) {
log.warn("Idempotency reserve failed (Redis down?): {} - degrading to single-flight", e.getMessage());
return true;
}
The lock has the same shape, and I covered it in the article on the lock with no owner: tryAcquireLock returns a handle even when it could not write anything, and the release catches its own exceptions. In both cases the service stays available and loses the protection. The only caller of both is DatasetQueryService, which with Redis down runs the query and consumes the credits without deduplicating or serialising: a double request can charge twice. I do not know whether it has ever happened, and I do not say so.
Why does revocation deny while idempotency lets it through?
The difference is explained by the cost of the error. If the revocation check lets a request through by mistake, a token the user has closed (a logout from a stolen device) stays valid until the access token expires; if it denies by mistake, logged-in users become anonymous for the duration of the outage. Idempotency has the opposite balance: denying would mean refusing every dataset query until Redis comes back, letting it through means risking a double charge on a rare event. Neither is "right". What matters is that it has been written down, and that whoever reads the code finds the word in the comment. The article on API security with JWT covers the token side; the one on consumer idempotency covers the key side.
Who does not decide: the error bubbles up
The single-use token
For bookings, the backend issues a signed token and records a copy of it on Redis; consuming it burns it with a single command. In issuing, the order is deliberate and commented: the token is written to Redis before being returned, so if Redis is down the exception prevents handing out a token with no counterpart. Consuming, instead, has no try at all:
public Optional<TokenClaims> validateAndBurn(String token) {
Optional<TokenClaims> claims = validate(token);
if (claims.isEmpty()) { return Optional.empty(); }
String burned = redisTemplate.opsForValue().getAndDelete(BK_TOKEN_PREFIX + token);
if (burned == null) { return Optional.empty(); }
return claims;
}
The caller is BookingDecisionEngine.decide, itself called by ApproveBookingUseCaseImpl.execute and by its twin for rejection. None of the three catches anything: the exception climbs up to the generic handler, which in the GlobalExceptionHandler file answers with status 500 and the code INTERNAL_ERROR. For whoever clicks the link in the confirmation email, with Redis down the booking is neither approved nor rejected: they see an error, and as a rule the token has not been burned, so they can try again (if the timeout fires after Redis has already run the command I cannot rule it out). It is the safe direction.
The video heartbeats
The same goes for the heartbeat filter. RedisHeartbeatDedupGate makes a single setIfAbsent with no error handling; it is called by RecordHeartbeatUseCase, which has no try, and by LessonEngagementController.heartbeat, which has none either. The heartbeat is an automatic call from the video player at regular intervals (the deduplication window is 20 seconds): with Redis down every heartbeat answers 500. The damage is small (a bit of lesson progress is lost) but the noise in the logs is not, because the player retries.
Here the behaviour is not a policy but an omission, and it is honest to say the difference. Whoever wrote revocation and idempotency thought about a Redis failure; whoever wrote the heartbeat gate did not, or judged it not worth it. If I had to choose today, I would let it through: losing a heartbeat costs less than answering with an error to a video that is playing.
Does a Spring cache propagate the Redis error?
The cache has no error handler in the repository. I verify it with a search and, to understand what it means, with the documentation:
$ grep -rln "CacheErrorHandler\|CachingConfigurer" backend/src/main | wc -l
0
The Spring Framework documentation, in the attributes table of <cache:annotation-driven/>, says that the default handler is SimpleCacheErrorHandler and that "by default, any exception thrown during a cache related operation is thrown back at the client". So a method with @Cacheable, with Redis down, does not fall back to the database: it throws. I did not verify it by starting the application. The repository has many cache annotations (my count of lines with the word is 154, among annotations, imports and comments), so a Redis failure propagates to every endpoint that uses them.
How much it costs to reach the decision
The timeout the project chose
In the configuration file Redis has a two-second timeout:
spring:
data:
redis:
port: ${REDIS_PORT:6379}
timeout: 2000ms
Spring Boot describes the spring.data.redis.timeout property as "Read timeout". It means that a command with no answer within two seconds fails, and only then does the catch fire. There is no time for "Redis is surely down, don't bother": every call pays its own timeout. To find out how much, I measured a client with the same threshold against a test Redis, in three states.
The measurement: healthy, paused, switched off
The "paused" state is obtained with CLIENT PAUSE 6000 ALL, which suspends the execution of all commands for six seconds without closing the connections: it simulates a busy or silently unreachable Redis, the worst case. The "switched off" state is the terminated process. The client is Python with a 2-second timeout and no retries:
# 1) Healthy Redis
EXISTS jwt:denylist:x 0.1 ms ok 0
SET NX PX (lock) 0.2 ms ok True
# 2) Paused Redis: CLIENT PAUSE 6000 ALL
EXISTS jwt:denylist:x (timeout 2 s) 2001.6 ms TimeoutError
SET NX PX (timeout 2 s) 2002.0 ms TimeoutError
GETDEL token (timeout 2 s) 2002.4 ms TimeoutError
# 3) Switched-off Redis (process terminated)
EXISTS jwt:denylist:x 0.3 ms ConnectionError
SET NX PX (lock) 0.1 ms ConnectionError
When healthy a command costs a tenth of a millisecond. Paused, every command costs 2,000 ms: the threshold, in full, on every call. Switched off, the refused connection fails immediately. The practical consequence is in the first row: with Redis paused, every authenticated request waits two seconds in the JWT filter before becoming anonymous. The real unavailability, the one you think of, is the cheapest case; the expensive one is the slowdown.
The calls add up
The dataset case shows the sum. A query goes through the lock and the idempotency key reservation, that is two independent Redis commands, each with its own fail-open catch. With Redis paused, according to my measurement and the code I read, the two calls cost two seconds each before the query starts: about four seconds of waiting for an operation that is then executed without protection. It is a calculation from two measurements and a reading, not a measurement of the whole endpoint.
A note on caution: the first time I ran the test with the Python client's default settings, the same command while paused cost 6,092 ms, not 2,000, because the client retried three times. An automatic retry policy multiplies the timeout. What Lettuce's policy is in my backend, and how it behaves with a refused connection, I do not know: I did not measure it, and I do not rely on what I remember.
What I would do
I would keep fail-closed for revocation and fail-open for idempotency and for the lock, because the costs of the error are what they are, and I would not change a line of the three. I would intervene at three points. First, the heartbeat gate: a catch that lets it through, because a 500 error at every heartbeat is noise with no value. Second, the cache: an error handler that logs and falls back to the database, so that a Redis failure slows the endpoints down instead of breaking them. Third, the timeout: two seconds is a lot for a cache and a very great deal for a security filter that sits on every request. I discarded doing nothing because the reading shows three different behaviours without any file saying so, and I discarded an automatic open circuit because it requires a library and a policy I have not measured.
- Write in a table what each use does when Redis is down, next to the code: whoever reads must find the decision, not discover it.
- Decide from the cost of the error: a security check denies, an economic operation must ask itself which error costs less.
- Measure the "slow" case, not only the "switched off" one: the timeout is paid on every call and adds up.
- A missing
catchis a choice: declare it or fix it. - Try switching Redis off in a test environment and watch the endpoints, not the tests: here I read, you can see.
The five rules are in order of cost. The first is documentation and takes an hour; the second is a piece of reasoning to do once per use; the third and fourth are code checks; the last is the only one that replaces reading with observation, and it is the one that would make this article stronger: the table above has cells that come from reading, and a cell that was read remains a cell to confirm.
What you can do now
You can list the uses of Redis in a backend and, for each, say what happens when it does not answer: it denies, it lets through, or it lets the error climb. You know that the absence of a catch is an implicit decision, that a Spring cache without a handler propagates the exception to the caller, and that the real cost of a partial failure is not the failure but the timeout paid on every call. You can measure that timeout with CLIENT PAUSE without stopping anything.
For what survives a Redis restart, which is the failure that comes before this one, there is the article on RDB, AOF and restart; for the cache and its version prefix, Spring Cache on Redis; for the lock and its limit, the lock with an owner. The general principle of failing safely, outside Redis, is in the article on the OWASP Top 10, and authentication in sessions and cookies. The next step is measuring Redis: latency, SLOWLOG and memory per key.
Want to know what your backend does when Redis stops answering? Book a call →
Sources
- Spring Framework, Declarative Annotation-based Caching - the attributes table of
annotation-driven: the default error handler isSimpleCacheErrorHandlerand "any exception thrown during a cache related operation is thrown back at the client". Consulted on 2026-11-12. - Spring Boot, Common Application Properties - the
spring.data.redis.timeoutproperty, described as "Read timeout". Consulted on 2026-11-12. - Redis, CLIENT PAUSE - suspending command execution for a given time, used to simulate a Redis that does not answer. Consulted on 2026-11-12.
- IETF, The Idempotency-Key HTTP Header Field - it is a draft (version 08, 22 October 2026, expiring 25 April 2027), not an RFC: I cite it for the header's name, not for an obligation. Consulted on 2026-11-12.
- This site's code, read on 12 November 2026:
JwtRevocationService.java,JwtAuthenticationFilter.java,IdempotencyService.java,BookingTokenService.java,BookingDecisionEngine.java,ApproveBookingUseCaseImpl.java,RedisHeartbeatDedupGate.java,RecordHeartbeatUseCase.java,LessonEngagementController.java,GlobalExceptionHandler.java(theExceptionhandler),RateLimitConfig.javaandapplication.yml(the timeout). - Own measurements: the script in the section on cost, run on 12 November 2026 on native Redis 8.0.5 (port 6394), Python client with a 2-second timeout and no retries.
The first four entries are documentation: the first supports the cache cell, the second explains the two-second figure, the third the way I simulated the failure, and the fourth only serves to say that that header is still a draft. The last two can be redone by opening the files and running the script; where a file's comment and its code diverge, I followed the code.
Commenti
Caricamento commenti...
Sign in to comment