Salta al contenuto principale

Redis è giù: fail-open o fail-closed, uso per uso

Revoca JWT, idempotenza, lock, token e cache hanno una risposta diversa quando Redis non risponde. La tabella dal codice e il timeout di 2 secondi misurato.

Cosa ne pensi di questo articolo?

Redis è giù: fail-open o fail-closed, uso per uso
Redisfedericocalo.dev

In Breve

Revoca JWT, idempotenza, lock, token e cache hanno una risposta diversa quando Redis non risponde. La tabella dal codice e il timeout di 2 secondi misurato.

In breve. Quando Redis non risponde, ogni suo uso nel mio backend ha già una risposta decisa nel codice, e non è la stessa dappertutto. La revoca dei JWT nega (fail-closed): se non può controllare, tratta il token come revocato e la richiesta prosegue da anonima. La chiave di idempotenza e il lock lasciano passare (fail-open): l'operazione si esegue senza protezione. Il token monouso delle prenotazioni e il filtro dei battiti video non decidono nulla: l'eccezione risale e l'utente vede un errore 500. E la cache Spring, che non ha un gestore degli errori, si comporta come questi ultimi. Non è incoerenza: decide il costo dell'errore. Ma conta un dato che nessun catch mostra: con Redis fermo ma non spento, ogni chiamata costa due secondi prima di cadere nel ramo scelto, per il timeout di lettura che il progetto ha configurato. Ho misurato quel tempo su una Redis di prova: 2.001 ms per ogni comando, contro 0,1-0,2 ms da sana.

Metodo. Le celle della tabella vengono dalla lettura del codice del backend (file e righe indicati) e dalla documentazione di Spring; non ho avviato l'applicazione né eseguito i suoi test, quindi nessuna cella è stata osservata come risposta HTTP. Ho invece misurato il comportamento di un client contro una Redis 8.0.5 di prova (porta 6394) in tre stati: sana, in pausa e spenta. Il client di prova è Python, non Lettuce: la forma dei tempi è informativa, le cifre del client Java non le conosco.

La domanda e la tabella

Che cosa fa il backend quando Redis non risponde?

La tabella ha sette righe, una per uso. Per ciascuna ho letto il punto in cui l'errore di Redis diventa una decisione. La colonna «Come lo so» distingue ciò che ho letto nel codice da ciò che ho dedotto dalla documentazione:

UsoCon Redis giùDoveCome lo so
Revoca JWTnega: isRevoked risponde true, la richiesta prosegue come anonimaJwtRevocationService, JwtAuthenticationFiltercodice letto
Chiave di idempotenzalascia passare: tryReserveIdempotencyKey risponde trueIdempotencyServicecodice letto
Locklascia passare: restituisce comunque un handle, nessuna mutua esclusioneIdempotencyService.tryAcquireLockcodice letto
Token monouso, emissionefallisce: l'eccezione impedisce di consegnare il tokenBookingTokenService.generateAndRegistercodice letto
Token monouso, consumol'eccezione risale: errore 500BookingTokenService.validateAndBurn, ApproveBookingUseCaseImplcodice letto, nessun catch nel percorso
Dedup dei battiti videol'eccezione risale: errore 500RedisHeartbeatDedupGate, LessonEngagementControllercodice letto, nessun catch nel percorso
Cache Springl'eccezione risale al chiamanteRedisCacheConfigdocumentazione di Spring più assenza di un gestore nel repository

La tabella si legge per colonna di mezzo. Le prime tre righe hanno un catch che sceglie un valore: due scelgono «sì, procedi», una «no, nega». Le ultime quattro non hanno nessuna scelta, e l'assenza di una scelta è una scelta: l'errore passa al gestore generico, che risponde 500. Nel resto dell'articolo guardo prima chi decide, poi chi non decide, poi quanto costa arrivarci.

Una riga fuori tabella: il rate limit non usa Redis

Il rate limit non compare perché non usa Redis, anche se due commenti nel repository lo suggeriscono. La classe è basata su bucket in memoria in una cache Caffeine, e il commento dice che per più istanze andrebbe sostituita con un archivio su Redis. L'ho verificato con una ricerca:

$ 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

Ne segue una conseguenza vera: se Redis cade il rate limit continua a funzionare, e con due istanze del backend ognuna avrebbe i propri contatori. Il commento nel file di cache, che elenca i contatori del rate limit fra le chiavi Redis, è quindi sbagliato: ho seguito il codice e non il commento.

Chi decide: fail-closed e fail-open

La revoca dei JWT nega

Il controllo di revoca interroga Redis in due modi, cercando il jti del token in una lista di rifiuto e confrontando l'istante di emissione con un marcatore per utente. Qualunque eccezione, compresa un'assenza di connessione, porta a restituire true, cioè «revocato». Questo è l'estratto, dal repository, non eseguito da 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);
        // ... confronto di issuedAt con il marcatore ...
        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;
    }
}

Il filtro che chiama questo metodo non risponde 401: se il token risulta revocato salta la creazione del contesto di sicurezza e lascia proseguire la richiesta, che arriva ai controller come anonima. Il commento del filtro lo dice senza ambiguità: «skip populating SecurityContext; request proceeds as anonymous». Le pagine pubbliche continuano a rispondere, quelle protette rispondono come per un utente senza sessione. La scelta è di sicurezza: un token revocato non deve funzionare, e non poter dimostrare che non lo è equivale, in questo disegno, a dimostrare che lo è.

L'idempotenza e il lock lasciano passare

La riserva della chiave di idempotenza fa il contrario. Se la setIfAbsent lancia, il catch registra un avviso e restituisce true, cioè «prima volta, vai pure»:

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;
}

Il lock ha la stessa forma, e ne ho parlato nell'articolo sul lock senza proprietario: tryAcquireLock restituisce un handle anche quando non ha potuto scrivere nulla, e il rilascio cattura le sue eccezioni. In entrambi i casi il servizio resta disponibile e perde la protezione. L'unico chiamante di entrambi è DatasetQueryService, che con Redis giù esegue la query e consuma i crediti senza deduplicare né serializzare: una doppia richiesta può addebitare due volte. Non so se sia mai successo, e non lo dico.

Perché la revoca nega e l'idempotenza lascia passare?

La differenza si spiega con il costo dell'errore. Se il controllo di revoca lascia passare per sbaglio, un token che l'utente ha chiuso (un logout da un dispositivo rubato) resta valido fino alla scadenza dell'access token; se nega per sbaglio, gli utenti loggati diventano anonimi per la durata del guasto. L'idempotenza ha il bilancio inverso: negare significherebbe rifiutare ogni query dei dataset finché Redis non torna, lasciar passare significa rischiare un doppio addebito su un evento raro. Nessuna delle due è «giusta». Quello che conta è che sia stata scritta, e che chi legge il codice trovi la parola nel commento. L'articolo sulla sicurezza delle API con JWT tratta la parte dei token; quello sull'idempotenza dei consumatori la parte della chiave.

Chi non decide: l'errore risale

Il token monouso

Per le prenotazioni, il backend emette un token firmato e ne registra una copia su Redis; il consumo la brucia con un solo comando. Nell'emissione l'ordine è voluto e commentato: il token viene scritto su Redis prima di essere restituito, così se Redis è giù l'eccezione impedisce di consegnare un token senza controparte. Il consumo, invece, non ha nessun try:

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;
}

Il chiamante è BookingDecisionEngine.decide, a sua volta chiamato da ApproveBookingUseCaseImpl.execute e dal suo gemello per il rifiuto. Nessuno dei tre cattura niente: l'eccezione sale fino al gestore generico, che nel file GlobalExceptionHandler risponde con stato 500 e il codice INTERNAL_ERROR. Per chi clicca sul link dell'email di conferma, con Redis giù la prenotazione non viene né approvata né respinta: vede un errore, e di norma il token non è stato bruciato, quindi può riprovare (se il timeout scatta dopo che Redis ha già eseguito il comando non posso escluderlo). È il verso sicuro.

I battiti del video

Lo stesso vale per il filtro dei battiti. RedisHeartbeatDedupGate fa una sola setIfAbsent senza gestione di errore; lo chiama RecordHeartbeatUseCase, che non ha un try, e lo chiama LessonEngagementController.heartbeat, che nemmeno. Il battito è una chiamata automatica del lettore video a intervalli regolari (la finestra di deduplicazione è di 20 secondi): con Redis giù ogni battito risponde 500. Il danno è piccolo (si perde un po' di avanzamento nella lezione) ma il rumore nei log no, perché il lettore ripete.

Qui il comportamento non è una politica ma un'omissione, e la differenza è onesta da dire. Chi ha scritto la revoca e l'idempotenza ha pensato al guasto di Redis; chi ha scritto il gate dei battiti no, o ha giudicato che non valesse la pena. Se oggi dovessi scegliere, lascerei passare: perdere un battito costa meno di rispondere con un errore a un video che sta andando.

Una cache Spring propaga l'errore di Redis?

La cache non ha un gestore degli errori nel repository. Lo verifico con una ricerca e, per capire che cosa significhi, con la documentazione:

$ grep -rln "CacheErrorHandler\|CachingConfigurer" backend/src/main | wc -l
0

La documentazione di Spring Framework, nella tabella degli attributi di <cache:annotation-driven/>, dice che il gestore predefinito è SimpleCacheErrorHandler e che «by default, any exception thrown during a cache related operation is thrown back at the client». Quindi un metodo con @Cacheable, con Redis giù, non ripiega sul database: lancia. Non l'ho verificato avviando l'applicazione. Il repository ha molte annotazioni di cache (il mio conteggio di righe con la parola è 154, fra annotazioni, import e commenti), quindi il guasto di Redis si propaga a tutti gli endpoint che le usano.

Quanto costa arrivare alla decisione

Il timeout che il progetto ha scelto

Nel file di configurazione Redis ha un timeout di due secondi:

spring:
  data:
    redis:
      port: ${REDIS_PORT:6379}
      timeout: 2000ms

Spring Boot descrive la proprietà spring.data.redis.timeout come «Read timeout». Significa che un comando senza risposta entro due secondi fallisce, e solo allora scatta il catch. Non c'è un tempo per «Redis è sicuramente giù, non ti provare»: ogni chiamata paga il suo timeout. Per sapere quanto, ho misurato un client con la stessa soglia contro una Redis di prova, in tre stati.

La misura: sana, in pausa, spenta

Lo stato «in pausa» si ottiene con CLIENT PAUSE 6000 ALL, che sospende l'esecuzione di tutti i comandi per sei secondi senza chiudere le connessioni: simula un Redis occupato o irraggiungibile in modo silenzioso, il caso peggiore. Lo stato «spento» è il processo terminato. Il client è Python con un timeout di 2 secondi e senza ripetizioni:

# 1) Redis sano
EXISTS jwt:denylist:x                   0.1 ms  ok 0
SET NX PX (lock)                        0.2 ms  ok True
# 2) Redis in pausa: 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) Redis spento (processo terminato)
EXISTS jwt:denylist:x                   0.3 ms  ConnectionError
SET NX PX (lock)                        0.1 ms  ConnectionError

Da sana un comando costa un decimo di millisecondo. In pausa, ogni comando costa 2.000 ms: la soglia, per intero, a ogni chiamata. Da spenta, la connessione rifiutata fallisce subito. La conseguenza pratica è nella prima riga: con Redis in pausa, ogni richiesta autenticata aspetta due secondi nel filtro JWT prima di diventare anonima. L'indisponibilità vera, quella a cui si pensa, è il caso meno costoso; quello costoso è il rallentamento.

Le chiamate si sommano

Il caso del dataset mostra la somma. Una query passa dal lock e dalla riserva della chiave di idempotenza, cioè da due comandi Redis indipendenti, ciascuno con il suo catch fail-open. Con Redis in pausa, secondo la mia misura e il codice letto, le due chiamate costano due secondi l'una prima che la query parta: circa quattro secondi di attesa per un'operazione che poi viene eseguita senza protezione. È un calcolo da due misure e dalla lettura, non una misura dell'endpoint completo.

Una nota sulla prudenza: la prima volta che ho lanciato la prova con le impostazioni predefinite del client Python, lo stesso comando in pausa è costato 6.092 ms, non 2.000, perché il client ripeteva il tentativo tre volte. Una politica di ripetizione automatica moltiplica il timeout. Quale sia la politica di Lettuce nel mio backend, e come si comporti con la connessione rifiutata, non lo so: non l'ho misurato, e non mi baso su quello che ricordo.

Che cosa farei

Terrei fail-closed per la revoca e fail-open per l'idempotenza e per il lock, perché i costi dell'errore sono quelli, e non cambierei una riga delle tre. Interverrei su tre punti. Primo, il gate dei battiti: un catch che lascia passare, perché un errore 500 a ogni battito è rumore senza valore. Secondo, la cache: un gestore degli errori che registri e ricada sul database, in modo che il guasto di Redis rallenti gli endpoint invece di romperli. Terzo, il timeout: due secondi sono tanti per una cache e tantissimi per un filtro di sicurezza che sta su ogni richiesta. Ho scartato di non fare niente perché la lettura mostra tre comportamenti diversi senza che nessun file lo dica, e ho scartato un circuito aperto automatico perché richiede una libreria e una politica che non ho misurato.

  • Scrivi in una tabella cosa fa ogni uso quando Redis è giù, accanto al codice: chi legge deve trovare la decisione, non scoprirla.
  • Decidi dal costo dell'errore: un controllo di sicurezza nega, un'operazione economica deve chiedersi quale errore costa meno.
  • Misura il caso «lento», non solo «spento»: il timeout si paga a ogni chiamata e si somma.
  • Un'assenza di catch è una scelta: dichiarala o correggila.
  • Prova a spegnere Redis in un ambiente di prova e guarda gli endpoint, non i test: io qui ho letto, tu puoi vedere.

Le cinque regole sono in ordine di costo. La prima è documentazione e si fa in un'ora; la seconda è un ragionamento da fare una volta per uso; la terza e la quarta sono verifiche di codice; l'ultima è la sola che sostituisce la lettura con l'osservazione, ed è quella che renderebbe questo articolo più forte: la tabella sopra ha celle di lettura, e una cella letta resta una cella da confermare.

Cosa sai fare adesso

Sai elencare gli usi di Redis in un backend e, per ciascuno, dire che cosa succede quando non risponde: nega, lascia passare o lascia salire l'errore. Sai che l'assenza di un catch è una decisione implicita, che una cache Spring senza gestore propaga l'eccezione al chiamante, e che il costo vero di un guasto parziale non è il fallimento ma il timeout pagato a ogni chiamata. Sai misurare quel timeout con CLIENT PAUSE senza fermare nulla.

Per cosa sopravvive a un riavvio di Redis, che è il guasto che precede questo, c'è l'articolo su RDB, AOF e riavvio; per la cache e il suo prefisso di versione, Spring Cache su Redis; per il lock e il suo limite, il lock con proprietario. Il principio generale del fallire in modo sicuro, fuori da Redis, è nell'articolo sull'OWASP Top 10, e l'autenticazione in sessioni e cookie. Il passo dopo è misurare Redis: latenza, SLOWLOG e memoria per chiave.

Vuoi sapere cosa fa il tuo backend quando Redis smette di rispondere? Prenota una call →

Fonti

  • Spring Framework, Declarative Annotation-based Caching - la tabella degli attributi di annotation-driven: il gestore degli errori predefinito è SimpleCacheErrorHandler e «any exception thrown during a cache related operation is thrown back at the client». Consultata il 2026-11-12.
  • Spring Boot, Common Application Properties - la proprietà spring.data.redis.timeout, descritta come «Read timeout». Consultata il 2026-11-12.
  • Redis, CLIENT PAUSE - la sospensione dell'esecuzione dei comandi per un tempo dato, usata per simulare un Redis che non risponde. Consultata il 2026-11-12.
  • IETF, The Idempotency-Key HTTP Header Field - è una bozza (versione 08, 22 ottobre 2026, scadenza 25 aprile 2027), non un RFC: la cito per il nome dell'intestazione, non per un obbligo. Consultata il 2026-11-12.
  • Codice di questo sito, letto il 12 novembre 2026: JwtRevocationService.java, JwtAuthenticationFilter.java, IdempotencyService.java, BookingTokenService.java, BookingDecisionEngine.java, ApproveBookingUseCaseImpl.java, RedisHeartbeatDedupGate.java, RecordHeartbeatUseCase.java, LessonEngagementController.java, GlobalExceptionHandler.java (il gestore di Exception), RateLimitConfig.java e application.yml (il timeout).
  • Misure proprie: lo script della sezione sul costo, eseguito il 12 novembre 2026 su Redis 8.0.5 nativo (porta 6394), client Python con timeout di 2 secondi e senza ripetizioni.

Le prime quattro voci sono documentazione: la prima regge la cella della cache, la seconda spiega il numero dei due secondi, la terza il modo in cui ho simulato il guasto, e la quarta serve solo a dire che quella intestazione è ancora una bozza. Le ultime due si rifanno aprendo i file e lanciando lo script; dove il commento di un file e il suo codice divergono, ho seguito il codice.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti