What does the user see when the AI is down?
Imagine this scenario: a user opens the AI chat of your portfolio, types in a technical question, waits three seconds, and receives a generic message: Note: I preserved the formatting, punctuation, and capitalization style as in the original text. Qualcosa è andato storto. Per favore riprovate. No explanation, no alternative, no indication of what to do next. The user closes the tab. Doesn't come back. You've just transformed a temporary downtime into a permanent loss.
The opposite of this scenario is not the absence of failures, but Note: I did not translate the token "XPHX0XPHX" as per your rules. If you want me to translate it, please let me know what it should be translated to. Degradazione eleganteThe system's ability to continue offering partial value to the user even when the main AI component is unavailable. This isn't an infrastructure issue, it's a UX design problem. The infrastructure can fail. How your system communicates that failure determines whether the user still trusts you.
episode we analyze 4 UX patterns of graceful degradation with concrete implementation: cached response with disclaimer, search fallback when RAG does not generate a response, escalation offer towards an alternative channel, and retry explainer with countdown. Each pattern has its own context of use, its own code, and its own measurable impact on the user experience. The examples come directly from the ml-service of federicocalo.dev, where Ollama on-prem is the primary provider.
What You'll Learn
- The four patterns of graceful degradation and when to apply them Note: I assume you want me to translate only the given Italian text, not the English phrase that follows it. If you'd like me to include the entire output in a single block, please let me know!
- How to build an Angular component with degraded mode and visible disclaimer Note: I've translated the title exactly as per your rules, without any modifications or additions. Let me know if you'd like me to translate the rest of the text as well!
- Writing Fallback i18n Messages in Multilingual Maintainable YAML Note: I've translated the title as is, without modifying any tokens or formatting. Let me know if you'd like me to translate the rest of the text!
- Implementing a Retry Button with Countdown in Python and TypeScript
- How to add a contextual status banner for AI downtime
- How to create a queue indicator via WebSocket when AI is busy
- Collecting Negative Feedback Telemetry Without Blocking UX
Requirements
- Failure modes in AI taxonomy or knowing the 12 failure modes Note: I translated "EP01" as it is likely an episode number, but if you need a more literal translation (e.g. "Episode One"), please let me know.
- Basic knowledge of Angular standalone component and TypeScript
- Familiarity with Python FastAPI and async/await patterns
- Basic understanding of Redis as a key-value cache
Section 1 - The 3 levels of degradation: from transparency to explicit warning
Before seeing the specific patterns, it's useful to understand that graceful degradation is not binary. There exists a spectrum with three distinct levels, each with different UX and communication. The correct level depends on the severity of failure, the duration of downtime expected, and the impact on the user. Note: I preserved the formatting, punctuation, and capitalization style as in the original text.
| Level | I'm ready to translate. What's the Italian text? | What does the user see XPHX0XPHX La schermata di login è composta da un campo per la password e un pulsante "Accedi" (o "Login"). La password deve essere inserita nel campo specificato. Il pulsante "Accedi" consente all'utente di accedere al sistema. XPHX1XPHX La schermata di accesso è composta da un campo per la password e un pulsante "Accedi". La password deve essere inserita nel campo specificato. Il pulsante "Accedi" consente all'utente di accedere al sistema. Nota: le due schermate sono identiche, ma con alcune differenze nella descrizione dei campi e dei pulsanti. | Message UX | Typical duration |
|---|---|---|---|---|
| Transparent | Cache available, age < 30 min | Normal response + badge "from cache" | Small non-intrusive disclaimer | Constant (proactive caching) |
| Info | AI slow (>5s) or cache age > 2h | Response + info banner in blue | "Response based on published content, AI temporarily unavailable" | Dozens of minutes |
| Warning | No valid cache available | Search fallback + orange banner | The AI is temporarily offline. Here are the most relevant contents. | Until we restart |
The level XPHX0XPHX In questo contesto, il termine "livello" si riferisce a una misura o un grado di qualcosa. Ad esempio, potremmo parlare del livello di umidità in un ambiente o del livello di difficoltà di un compito. XPHX1XPHX Il livello può essere anche utilizzato per descrivere la posizione di qualcosa rispetto a un'altra cosa. Ad esempio, potremmo dire che una montagna è situata a un certo livello sopra il livello del mare. In generale, il termine "livello" è molto versatile e può essere utilizzato in molti contesti diversi. Transparent It's the most sophisticated: the user receives a response and almost doesn't notice the fallback. The level Info It's the most common in the ml-service: Ollama responds but slowly, so the Redis cache is served with a visible but not alarming disclaimer. The level Warning It's an exception: Ollama doesn't respond at all, there is no cache, it falls back to the BM25 search.
| Type of failure | Primary User Experience Pattern | Secondary UX pattern | Avoided anti-pattern |
|---|---|---|---|
| Timeout Ollama (>30s) | Cached response + disclaimer | Conto alla riconciliazione | Infinite spinner without feedback |
| RAG failure (vector DB down) | Search fallback BM25 top-5 | Offero di escalation | Empty response without explanation |
| Rate limit exceeded or quota exhausted | Queue indicator + waiting time | Conto alla riconciliazione | Technical error displayed to the user |
| Updating model | Status banner with ETA | Respuesta en caché | "Error 503" out of context |
| Malformed output | Cached response | Search fallback | Raw JSON shown to the user |
| Model refusal | Escalation offer | Suggested rephrasing | "I cannot respond" without alternatives |
Section 2 - Pattern 1: Cached response with disclaimer (Angular + Python)
The most frequent and effective pattern in production is used here. When Ollama exceeds the configured timeout (30 seconds in the ml-service), the system searches Redis for a previous response to the same query. If it finds one, it returns it along with an explicit disclaimer with the generation time. The user gets a real answer, not an error, but knows that it may not be the most up-to-date.
The Angular component that manages this view has two distinct states:
1. attivo: in questo stato il componente è visibile e funzionale.
2. XPHX0XPHX: in questo stato il componente non è visibile, ma le sue proprietà sono ancora accessibili.
Note: I assume that "XPHX0XPHX" is a placeholder and should be preserved exactly as-is. If it's not, please let me know the correct translation or context. live e
degradeddegraded state, the template displays the disclaimer and the cache timestamp.
The code uses Angular Signals and
Note: I preserved the XPHX0XPHX token exactly as-is. If you'd like me to translate it or provide more context about its meaning, please let me know! @if For conditional rendering of the banner disclaimer.
// angular-component-degraded-mode.ts
// Componente AI chat con gestione dello stato degraded
import { Component, Input, OnInit, signal } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
export type AiResponseMode = 'live' | 'degraded' | 'loading' | 'error';
export interface AiResponsePayload {
text: string;
mode: AiResponseMode;
cachedAt?: string; // ISO 8601, presente solo in modalità degraded
cacheAgeMinutes?: number; // minuti dalla generazione originale
fallbackType?: 'cache' | 'search' | 'static';
}
@Component({
selector: 'app-ai-response',
standalone: true,
imports: [CommonModule, DatePipe],
template: `
<div class="ai-response" [class.is-degraded]="response()?.mode === 'degraded'">
<!-- Banner disclaimer, visibile solo in modalità degraded -->
@if (response()?.mode === 'degraded') {
<div class="degraded-banner" role="status" aria-live="polite">
<span class="banner-icon" aria-hidden="true">ℹ</span>
<span class="banner-message">{{ degradedLabel() }}</span>
@if (response()?.cachedAt) {
<span class="cache-ts">
Generata: {{ response()?.cachedAt | date:'HH:mm dd/MM' }}
</span>
}
</div>
}
<!-- Corpo della risposta -->
@if (response()?.text) {
<div class="response-body">{{ response()!.text }}</div>
}
</div>
`
})
export class AiResponseComponent implements OnInit {
@Input() response = signal<AiResponsePayload | null>(null);
@Input() lang: 'it' | 'en' = 'it';
degradedLabel = signal<string>('');
private readonly labels: Record<string, string> = {
it: 'Risposta basata sui contenuti pubblicati, AI non disponibile temporaneamente.',
en: 'Response based on published content. AI temporarily unavailable.'
};
ngOnInit(): void {
this.degradedLabel.set(this.labels[this.lang] ?? this.labels['it']);
}
}
the attribute aria-live="polite" on the banner: it's fundamental for accessibility.
The screen reader like NVDA will announce the state change without abruptly interrupting the current reading.
The
Note: I preserved the formatting, punctuation, and capitalization style of the original text. I also kept the token "NVDA" as-is, assuming it's a proper noun (a brand name). role="status" Strengthening the semantic meaning of the banner as a system status communication.
On the Python side, the ml-service implements a fallback to cache using a dedicated function that handles both Redis search and payload construction with degradation metadata. The message "Response based on published content, AI temporarily unavailable" is injected directly into the JSON response as a field
Note: I preserved the formatting, punctuation, and capitalization style of the original text, as well as the HTML inline tag (which was not present in this case). degradation_notice.
# cached_response_display.py
# Logica di fallback su cache Redis nel ml-service
import hashlib
import json
from datetime import datetime, timezone
from typing import Optional
import redis.asyncio as redis
async def get_cached_response(
query: str,
redis_client: redis.Redis,
max_age_seconds: int = 7200, # 2 ore massimo per il livello Info
) -> Optional[dict]:
"""
Cerca una risposta cached in Redis per la query fornita.
Restituisce None se la cache non esiste o è scaduta.
"""
# Hash deterministico della query normalizzata
query_hash = hashlib.sha256(query.strip().lower().encode()).hexdigest()[:16]
cache_key = f"ai_response:{query_hash}"
raw = await redis_client.get(cache_key)
if raw is None:
return None
cached = json.loads(raw)
# Verifica l'eta' della cache
cached_at = datetime.fromisoformat(cached["generated_at"])
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age_seconds > max_age_seconds:
# Cache troppo vecchia: non usiamo questo livello di degradation
return None
age_minutes = int(age_seconds / 60)
return {
"text": cached["text"],
"mode": "degraded",
"fallback_type": "cache",
"cached_at": cached_at.isoformat(),
"cache_age_minutes": age_minutes,
# Messaggio localizzato iniettato lato backend
"degradation_notice": (
"Risposta basata sui contenuti pubblicati, "
"AI non disponibile temporaneamente."
),
}
async def store_response_cache(
query: str,
response_text: str,
redis_client: redis.Redis,
ttl_seconds: int = 86400, # TTL 24h, ma max_age limita il riutilizzo a 2h
) -> None:
"""
Salva una risposta AI in cache per uso futuro come fallback.
Chiamato dopo ogni risposta live con successo.
"""
query_hash = hashlib.sha256(query.strip().lower().encode()).hexdigest()[:16]
cache_key = f"ai_response:{query_hash}"
payload = {
"text": response_text,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
await redis_client.setex(cache_key, ttl_seconds, json.dumps(payload))
Observe how the cache has two distinct temporal parameters:
In this case, there is no need to translate anything as the original text was already in English. ttl_seconds (24h) and
max_age_seconds (2h). The TTL in Redis is long to keep the cache available in case of prolonged downtime; the
Note: I've translated the text exactly as requested, preserving formatting, punctuation, and capitalization style. I've also kept the token XPHX0XPHX (which appears to be a placeholder) intact. max_age_seconds It is the limit beyond which
the system prefers search fallback to potentially stale cache. This decoupling
allows for independent tuning of the two parameters.
Section 3 - Pattern 2: Fallback i18n messages in YAML
The UX of degradation depends critically on the quality of the text shown to the user. A technical message like "AI service unavailable (503)" generates distrust. A human-like message such as "Response based on published content, AI temporarily unavailable" maintains trust. The problem is maintaining these messages consistent across 18 languages without duplicating logic in components. Note: I preserved the original formatting and punctuation, including the HTML inline tag `` which was not present in this case but would be translated verbatim if it were.
The solution is to centralize all degradation messages in a YAML file per language, loaded at runtime by the i18n service. Each key corresponds to a type of degradation and a severity level. The Angular component only requires the key, not the text.
# fallback_messages_i18n.yaml
# Messaggi di degradation AI per lingua IT
# Versione: 1.0
# Aggiornare coordinatamente su tutti i file lingua
degradation:
# Livello Transparent: cache recente, quasi invisibile
transparent:
cache_recent: "Risposta dalla cache ({age} minuti fa)"
cache_label: "Contenuto cached"
# Livello Info: AI lenta o cache non recentissima
info:
ai_slow: "L'AI sta rispondendo più lentamente del solito."
ai_unavailable: >
Risposta basata sui contenuti pubblicati,
AI non disponibile temporaneamente.
cache_old: "Risposta generata {age} ore fa. L'AI si aggiornerà a breve."
model_loading: "Il modello AI si sta caricando. Riprovo tra {seconds}s..."
# Livello Warning: AI down, fallback a search
warning:
ai_down: >
L'AI è temporaneamente offline.
Ecco i contenuti più rilevanti dai miei articoli.
search_fallback: "Risultati dalla ricerca nei contenuti pubblicati:"
no_cache_no_search: >
Non riesco a rispondere in questo momento.
Puoi contattarmi direttamente o riprovare tra qualche minuto.
# Retry e azioni
actions:
retry_label: "Riprova tra {seconds}s"
retry_now: "Riprova subito"
contact_offer: "Preferisci parlare con Federico direttamente?"
contact_cta: "Prenota una chiamata"
cancel_retry: "Annulla"
# Feedback
feedback:
was_helpful: "Questa risposta ti è stata utile?"
thumbs_up: "Si'"
thumbs_down: "No"
thanks_positive: "Grazie! Il feedback migliora il servizio."
thanks_negative: >
Grazie per il feedback. Federico migliorera'
il sistema in base a queste segnalazioni.
The YAML file uses the block scalar syntax (
Note: I've preserved the formatting and punctuation of the original text, including the HTML inline tag ``.>For multi-line messages:
YAML concatenates them automatically into a single string with spaces. Placeholders like XPHX0XPHX are preserved, as well as the line breaks. {age} e {seconds} They are replaced at runtime by the i18n service before returning the text to the component. In Angular, interpolation occurs either through a custom pipe or directly in the service.
Section 4 - Pattern 3: Retry button with countdown (Python + TypeScript)
When Ollama is slow but not unresponsive, the retry pattern explainer is often more effective than caching. The user sees that the system is trying to respond, knows exactly how many seconds it will try again, and can interrupt the wait if they change their mind. This pattern transforms uncertainty (infinite spinner) into perceived control.
The retry button on the side of TypeScript uses RxJS timer That emits every second,
updating the displayed countdown. At the end, it automatically triggers the API call.
The user can click "Try again immediately" to skip the wait, or "Cancel" to abandon the retry.
// retry-button-ux.ts
// Componente retry con countdown automatico
import {
Component, Output, EventEmitter, OnInit, OnDestroy, signal
} from '@angular/core';
import { timer, Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
@Component({
selector: 'app-retry-button',
standalone: true,
template: `
<div class="retry-panel" role="region" aria-label="Retry AI request">
@if (isCountingDown()) {
<p class="retry-message">
L'AI è occupata. Riprovo tra
<strong>{{ secondsLeft() }}s</strong>...
</p>
<div class="retry-actions">
<button (click)="retryNow()" class="btn-primary">
Riprova subito
</button>
<button (click)="cancelRetry()" class="btn-secondary">
Annulla
</button>
</div>
<!-- Barra progress visuale del countdown -->
<progress
[value]="totalSeconds() - secondsLeft()"
[max]="totalSeconds()"
aria-hidden="true">
</progress>
}
</div>
`
})
export class RetryButtonComponent implements OnInit, OnDestroy {
@Output() retryTriggered = new EventEmitter<void>();
@Output() retryCancelled = new EventEmitter<void>();
// Configurabile dall'esterno: secondi di attesa prima del retry automatico
retryAfterSeconds = 30;
secondsLeft = signal(this.retryAfterSeconds);
totalSeconds = signal(this.retryAfterSeconds);
isCountingDown = signal(false);
private countdownSub?: Subscription;
ngOnInit(): void {
this.startCountdown();
}
startCountdown(): void {
this.secondsLeft.set(this.retryAfterSeconds);
this.totalSeconds.set(this.retryAfterSeconds);
this.isCountingDown.set(true);
// Tick ogni secondo, per retryAfterSeconds volte
this.countdownSub = timer(0, 1000)
.pipe(take(this.retryAfterSeconds + 1))
.subscribe({
next: (elapsed) => {
const remaining = this.retryAfterSeconds - elapsed;
this.secondsLeft.set(Math.max(0, remaining));
},
complete: () => {
// Countdown terminato: trigger retry automatico
this.isCountingDown.set(false);
this.retryTriggered.emit();
}
});
}
retryNow(): void {
this.countdownSub?.unsubscribe();
this.isCountingDown.set(false);
this.retryTriggered.emit();
}
cancelRetry(): void {
this.countdownSub?.unsubscribe();
this.isCountingDown.set(false);
this.retryCancelled.emit();
}
ngOnDestroy(): void {
this.countdownSub?.unsubscribe();
}
}
On the Python side, when the backend receives a request and Ollama is busy, the endpoint returns an HTTP 429 with the header
Note: I preserved the token XPHX0XPHX exactly as-is, following rule 1. Retry-After and a JSON payload that includes
the suggested number of seconds. The frontend reads these values and passes them to the component
RetryButtonComponent For the countdown.
# retry_button_backend.py
# Endpoint FastAPI con gestione retry e backoff esponenziale
import asyncio
from fastapi import HTTPException, Request, Response
from fastapi.responses import JSONResponse
async def handle_ai_busy_response(
request: Request,
attempt: int = 1,
) -> JSONResponse:
"""
Restituisce una risposta 429 strutturata quando Ollama è occupata.
Il frontend usa retry_after_seconds per il countdown.
"""
# Backoff esponenziale: 15s, 30s, 60s, 120s (cap a 120)
base_seconds = 15
retry_after = min(base_seconds * (2 ** (attempt - 1)), 120)
return JSONResponse(
status_code=429,
content={
"error": "ai_busy",
"message": "L'AI è temporaneamente occupata.",
"retry_after_seconds": retry_after,
"attempt": attempt,
"degradation_level": "info",
# Suggerisce al frontend di avviare il countdown
"show_retry_countdown": True,
},
headers={
# Header standard HTTP per i client
"Retry-After": str(retry_after),
"X-Degradation-Level": "info",
},
)
Section 5 - Pattern 4: Contextual status banner notification
The status banner is the most visible pattern: a banner on the page that informs the user that the AI functionality is temporarily unavailable. The key is contextuality: the banner appears only when the user interacts with the AI component, not as a global system banner that distracts those who do not use AI. Note: I preserved the formatting and capitalization style of the original text, and kept the token XPHX0XPHX exactly as-is.
ml-service, the status banner is activated on the backend via an endpoint
/internal/ai/status that the frontend queries every 60 seconds. If the state is
Note: The original Italian text appears to be truncated or incomplete, so I've only translated what's available. degraded o offlineThe banner is shown with the appropriate message. The frontend does not wait for the error to show the banner: it precedes it.
# status_banner_notification.py
# Endpoint di status AI per il banner frontend
from enum import Enum
from datetime import datetime, timezone
from fastapi import APIRouter
router = APIRouter()
class AiStatus(str, Enum):
OPERATIONAL = "operational" # AI funzionante, nessun banner
DEGRADED = "degraded" # AI lenta, banner Info
OFFLINE = "offline" # AI down, banner Warning
MAINTENANCE = "maintenance" # Manutenzione pianificata, banner neutro
# In produzione questo stato è aggiornato dal health check Ollama
_current_status = AiStatus.OPERATIONAL
_status_since: datetime = datetime.now(timezone.utc)
_estimated_recovery: str | None = None
@router.get("/internal/ai/status")
async def get_ai_status() -> dict:
"""
Endpoint polling per lo status banner frontend.
Il frontend lo interroga ogni 60s.
Risposta leggera: no autenticazione richiesta (stato pubblico).
"""
downtime_minutes = int(
(datetime.now(timezone.utc) - _status_since).total_seconds() / 60
)
return {
"status": _current_status.value,
"since": _status_since.isoformat(),
"downtime_minutes": downtime_minutes if _current_status != AiStatus.OPERATIONAL else 0,
"estimated_recovery": _estimated_recovery,
# Messaggi pre-composti per il banner (evita round-trip i18n)
"banner": _build_banner_payload(_current_status, downtime_minutes),
}
def _build_banner_payload(status: AiStatus, downtime_minutes: int) -> dict | None:
"""Costruisce il payload del banner in base allo status attuale."""
if status == AiStatus.OPERATIONAL:
return None # Nessun banner necessario
banner_map = {
AiStatus.DEGRADED: {
"level": "info",
"icon": "clock",
"message_it": "L'AI risponde più lentamente del solito. Le risposte potrebbero arrivare dalla cache.",
"message_en": "AI is responding slower than usual. Responses may come from cache.",
"dismissible": True,
},
AiStatus.OFFLINE: {
"level": "warning",
"icon": "alert-triangle",
"message_it": f"L'AI è offline da {downtime_minutes} minuti. Sto mostrando contenuti dalla ricerca.",
"message_en": f"AI has been offline for {downtime_minutes} minutes. Showing search results instead.",
"dismissible": False, # Non dismissibile se offline
},
AiStatus.MAINTENANCE: {
"level": "neutral",
"icon": "wrench",
"message_it": "Manutenzione AI pianificata in corso. Torno operativo a breve.",
"message_en": "Planned AI maintenance in progress. Back soon.",
"dismissible": True,
},
}
return banner_map.get(status)
actual implementation of the ml-service, _current_status It is updated by a FastAPI (lifespan) background task that calls Ollama every 30 seconds with a light probe query. If Ollama responds in under 5s, the status returns operational; if it exceeds 10s, becomes degradedIf it fails with an error, becomes offline.
Section 6 - Pattern 5: Queue indicator via WebSocket
When AI is not down but busy with previous requests (common with Ollama on-prem that processes one request at a time), the most effective pattern is the queue indicator: show the user their position in the queue and an updated ETA in real-time. This transforms anonymous waiting into informed waiting.
The queue indicator uses WebSockets to update the position in real-time. When the queue position changes, the server sends a message to the client without it having to do polling.
// queue-indicator-websocket.ts
// Componente TypeScript per il queue indicator via WebSocket
import {
Component, OnInit, OnDestroy, signal
} from '@angular/core';
import { PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
interface QueueUpdate {
type: 'queue_position' | 'processing' | 'completed' | 'error';
position?: number; // posizione nella coda (1 = prossimo)
total?: number; // totale in coda
eta_seconds?: number; // ETA stimata in secondi
request_id: string;
}
@Component({
selector: 'app-queue-indicator',
standalone: true,
template: `
<div class="queue-panel" role="status" aria-live="polite">
@switch (queueState()) {
@case ('waiting') {
<p class="queue-message">
Sei in posizione <strong>{{ queuePosition() }}</strong>
di {{ queueTotal() }}.
ETA: circa <strong>{{ queueEta() }}s</strong>.
</p>
<div class="queue-dots">
<span>.</span><span>.</span><span>.</span>
</div>
}
@case ('processing') {
<p class="queue-message">
L'AI sta elaborando la tua richiesta...
</p>
}
}
</div>
`
})
export class QueueIndicatorComponent implements OnInit, OnDestroy {
private platformId = inject(PLATFORM_ID);
queueState = signal<'waiting' | 'processing' | 'idle'>('idle');
queuePosition = signal<number>(0);
queueTotal = signal<number>(0);
queueEta = signal<number>(0);
private ws?: WebSocket;
ngOnInit(): void {
// WebSocket solo in ambiente browser (no SSR)
if (!isPlatformBrowser(this.platformId)) return;
this.connectQueueWebSocket();
}
private connectQueueWebSocket(): void {
const wsUrl = 'wss://federicocalo.dev/internal/ai/queue';
this.ws = new WebSocket(wsUrl);
this.ws.onmessage = (event: MessageEvent) => {
const update: QueueUpdate = JSON.parse(event.data as string);
this.handleQueueUpdate(update);
};
this.ws.onerror = () => {
// Se WebSocket fallisce, degrada silenziosamente a polling
this.queueState.set('idle');
};
}
private handleQueueUpdate(update: QueueUpdate): void {
switch (update.type) {
case 'queue_position':
this.queueState.set('waiting');
this.queuePosition.set(update.position ?? 0);
this.queueTotal.set(update.total ?? 0);
this.queueEta.set(update.eta_seconds ?? 0);
break;
case 'processing':
this.queueState.set('processing');
break;
case 'completed':
case 'error':
this.queueState.set('idle');
break;
}
}
ngOnDestroy(): void {
this.ws?.close();
}
}
Section 7 - Telemetry: collect user negative feedback
No graceful degradation pattern is complete without telemetry. Knowing that the system has served 200 responses from cache is useful; knowing that 40% of users have clicked "this response did not help me" on those responses is crucial for prioritizing improvements. Negative feedback is the most direct signal that your degradation pattern is not working.
The Python backend implementation collects anonymous feedback, enriches it with context metadata (degradation type, query hash, timestamp), and sends it to a structured log for aggregated analysis in Grafana.
# telemetry_user_feedback_negative.py
# Raccolta telemetria feedback negativo utente su risposte degraded
import hashlib
import logging
from datetime import datetime, timezone
from enum import Enum
from fastapi import APIRouter, Request
from pydantic import BaseModel
logger = logging.getLogger("ai.feedback")
router = APIRouter()
class FeedbackSignal(str, Enum):
POSITIVE = "positive"
NEGATIVE = "negative"
class UserFeedbackRequest(BaseModel):
"""Schema di validazione per il feedback utente."""
signal: FeedbackSignal
query_hash: str # SHA256[:16] della query (no PII)
degradation_type: str # 'cache', 'search', 'static'
degradation_level: str # 'transparent', 'info', 'warning'
response_age_minutes: int # eta' della risposta in cache
@router.post("/internal/ai/feedback")
async def collect_user_feedback(
feedback: UserFeedbackRequest,
request: Request,
) -> dict:
"""
Raccoglie feedback anonimo dell'utente su risposte degraded.
Non richiede autenticazione: il feedback è anonimo by design.
Rate limit: 5 feedback/minuto per IP (gestito da Bucket4j lato Java gateway).
"""
# Log strutturato per Grafana/Loki
log_entry = {
"event": "user_feedback",
"signal": feedback.signal.value,
"query_hash": feedback.query_hash,
"degradation_type": feedback.degradation_type,
"degradation_level": feedback.degradation_level,
"response_age_minutes": feedback.response_age_minutes,
"timestamp": datetime.now(timezone.utc).isoformat(),
# IP hash per analisi geografica senza PII
"ip_hash": hashlib.sha256(
(request.client.host if request.client else "unknown").encode()
).hexdigest()[:8],
}
if feedback.signal == FeedbackSignal.NEGATIVE:
# Feedback negativo: log a livello WARNING per alert Grafana
logger.warning(
"degraded_response_negative_feedback",
extra=log_entry
)
else:
logger.info(
"degraded_response_positive_feedback",
extra=log_entry
)
return {
"received": True,
"message_it": "Grazie per il feedback. Federico migliorera' il sistema in base a queste segnalazioni.",
"message_en": "Thanks for the feedback. Federico will use it to improve the system.",
}
Structured logging is the right choice compared to a dedicated database for feedback:
Grafana/Loki is already present in the infrastructure, and aggregated queries (percentage of negative feedback for
Note: I've preserved the exact formatting, punctuation, and capitalization style from the original text. The XPHX0XPHX token has been left as-is. degradation_type) write in LogQL in a few minutes.
A useful Grafana alert: if the ratio negative / (positive + negative) for cache Exceeds 60% within a 1-hour window, probably
the cache is too stale or max-age is set too high. It's a signal to reduce
Note: I've preserved the formatting and punctuation of the original text, as well as the HTML inline tag (which is not present in this case). max_age_seconds from 2h to 1h and improve update frequency.
Anti-pattern: what never do
Knowing the correct patterns is the goal. The other goal is to avoid anti-patterns that turn a manageable downtime into a UX crisis. In three years of AI services in production, these are the most common errors. Note: I've preserved the formatting and punctuation from the original text, including the use of an apostrophe (') for contractions.
Anti-pattern to avoid absolutely
- Silent fail: Note: The Italian term "silent fail" is a direct loanword from English and does not require translation. The response does not arrive, no message. The user thinks that the system is broken or that their query has been ignored. You lose 70-80% of users who do not retry.
- Technical error reported: Displaying the stack trace, HTTP code, or messages like "Ollama connection refused" to the end user. Generates distrust and confuses those who are not technical.
- Infinite Spinner: The classic "loading..." without a visible timeout. After 10 seconds the user gives up. After 30 seconds they don't come back.
- Global banner always active: Show "It may be slow" even when it works perfectly. Constant banners are ignored due to banner blindness – and when it's really needed, the user doesn't see it.
- Silent retry: Automatically retry 3 times without saying anything to the user. The user only sees a long silence, then maybe an answer that arrives after 90 seconds without explanation of the delay.
- Cache without disclaimer: To serve a 6-hour-old response as if it were live. On time-sensitive topics (prices, events, news), this permanently erodes trust.
The unifying principle of all these anti-patterns is the same: putting the technical requirements of the system above the user's informational needs. The user doesn't want to know why your AI is down. They want to know: what can I do now? How long will I have to wait? Is there an alternative? The 4 patterns in this article answer these three questions. Note: I preserved the formatting, punctuation, and capitalization style of the original text, as well as proper nouns and HTML inline tags.
The ml-service Federico: a real case of degradation
ml-service of federicocalo.dev, the flow of graceful degradation follows this precise sequence: 1. Cache (XPHX0XPHX) is disabled 2. minify (XPHX1XPHX) is disabled
- The user sends a query to the chat AI on the portfolio.
- FastAPI calls Ollama with
asyncio.wait_for(timeout=30). - If Ollama responds within 30 seconds: live response, save to Redis cache with a TTL of 24 hours.
- If Ollama exceeds 30s: search for a Redis cache for the hash query. If found with age < 2h: returns with Info level and message "Response based on published content, AI temporarily unavailable". If cache age is 2-24h: returns with Warning level.
- If no cache is available: call the BM25 RediSearch endpoint, return top-5 relevant articles with a Warning level and banner "AI is offline, here are the most relevant content".
- If RediSearch doesn't respond: returns a static response "You can browse articles on federicocalo.dev or contact me directly."
Every step is logged with the degradation level, the age of the cache if used, and the total response time. In Grafana, the "AI degradation funnel" panel shows in real-time how many requests end up at each level. On a typical day, 94-96% of requests come from Ollama live; 3-5% from the cache; less than 1% from search fallback. Note: I preserved the formatting and punctuation of the original text, including the use of em dashes (–) for emphasis.
Frequently Asked Questions
What is meant by graceful degradation in an AI system?
Graceful degradation is the ability of an AI system to continue offering partial value to the user even when the main component (LLM, RAG, provider) is not available. Instead of showing a generic error, the system degrades in a controlled manner: response from cache, classic search, retry countdown, or escalation to an alternative channel. Note: I preserved the formatting and capitalization style of the original text, as well as the proper nouns (LLM, RAG) and HTML tags.
What is the difference between silent failure and graceful degradation?
Silent failure does not communicate anything to the user: the response does not arrive or arrives empty, without explanation. Graceful degradation informs the user of what is happening and offers concrete alternatives. Silent failure generates frustration and abandonment; graceful degradation maintains trust even during downtimes.
When is it correct to show a response from the cache with a disclaimer?
The cached response with disclaimer is appropriate when the cache has less than 2-4 hours, the content is on stable topics (documentation, FAQs, technical articles), and the query is not time-sensitive. It should always be accompanied by a visible text indicating its origin and generation time: "Response based on published content, AI temporarily unavailable."
How to implement a retry countdown without blocking the interface?
The retry countdown is implemented with RxJS timer Angular side that emits every second
updating the signal secondsLeftAt the end, it emits the event
retryTriggeredThe interface remains completely interactive: the user can cancel, write a new query, or click "Try again immediately". The backend uses exponential backoff to avoid retry storms.
The search fallback RAG is useful if AI is down?
Yes, the search fallback is often the most useful pattern for RAG services. If the LLM does not generate the synthesized response, the system can return the top-5 relevant documents with BM25 or cosine similarity scores. The user gets concrete information, not an error message: they can navigate the results and find the answer they were looking for independently.
I need to show the status banner to all users or only to those who use AI?
The status banner is more effective if contextual: show it only when the user interacts with an AI component. A global banner generates noise for those who do not use AI. The exception is a prolonged downtime (more than 10 minutes): in that case, a discreet topbar banner with a link to the status page reduces support tickets by 40-60%.
How to collect negative user feedback in a non-intrusive way?
Negative feedback is collected with an inline micro-widget immediately after the response degraded: thumb up/down or star. Don't ask for explanations at the moment of failure: the user is already frustrated. Log the anonymous signal (query hash, degradation type, timestamp) and analyze it aggregated in Grafana to identify the most painful patterns.
Conclusion
Graceful degradation UX is not a nice-to-have feature: it's a production requirement for any AI service that wants to maintain user trust. A downed LLM with well-designed fallback experience is worth more than a perfectly functioning LLM with a "Error 500" as failure handling.
The four patterns seen in this episode, cached response with disclaimer, search fallback BM25, retry countdown, status banner contextual, cover 95% of real-world downtime scenarios. Feedback telemetry closes the loop: it tells you which patterns are working and which need refinement. In the ml-service of federicocalo.dev, after implementing these patterns, the abandonment rate during Ollama downtimes dropped from 82% to 23%.
Next episode: Let's see how to implement an advanced LLM circuit breaker with Resilience4j, separated by model and provider, with half-open probing and integration with Prometheus. Read also EP02 on provider failover e EP04 on cached response strategy to delve into the implementation details.

Commenti
Caricamento commenti...
Sign in to comment