SDK TypeScript per consumer di API AI: streaming SSE, error recovery, cost tracking lato client

SDK TypeScript per consumer di API AI: streaming SSE, error recovery, cost tracking lato client

L'SDK che descrivo qui vive nel mio monorepo personale da fine dicembre 2025 e ha avuto sei iterazioni prima di arrivare alla forma attuale. Lo sviluppo avviene sulla mia workstation Linux (Ryzen 7 7700X, 64 GB RAM DDR5, 2x NVMe 2 TB, Debian 12 Bookworm), ambiente Node.js 22 LTS con TypeScript 5.7, bundler tsup che produce ESM, CJS e una versione browser IIFE in un unico passaggio. Il backend con cui l'SDK dialoga è il servizio Node.js di streaming che ho descritto nell'articolo su Node.js e TypeScript per streaming real-time di LLM, che a sua volta fa da proxy verso Claude Sonnet 4.6. L'SDK si chiama @private/ai-stream-client e le sue 1.100 righe TypeScript coprono sette aree operative che sono la lista di controllo di questo articolo: reconnect robusto, classificazione errori, cost tracking client-side, validazione tipizzata delle risposte, cancellation pulita su navigazione, buffering su tab inattivo, metriche di observability. Le prime versioni erano 200 righe e fallivano in modi precisi ogni volta che il cliente usava l'applicazione in scenari reali - connessione mobile instabile, tab in background per 40 minuti, chiusura del browser a metà risposta, navigazione in mezzo a uno stream. Ogni fallimento ha aggiunto una riga alla lista.

Perché il fetch API nativo del browser non basta per un SDK di streaming AI?

La risposta breve è che né il fetch streaming con ReadableStream.getReader() né l'EventSource standard del browser (MDN Server-Sent Events) coprono la combinazione di requisiti che un'applicazione AI consumer-facing richiede. EventSource fa reconnect automatico su drop di rete, ma non supporta header Authorization (il browser lo impedisce per design), non permette body POST, e il suo retry è una finestra temporale fissa che non conosce la differenza fra "server ha rifiutato la credenziale" e "rete è caduta per un tunnel in autostrada". Il fetch streaming permette body POST e header arbitrari, ma il reconnect va scritto a mano e il parsing SSE pure. In pratica entrambe le API standard richiedono un livello di astrazione sopra, ed è quello che un SDK ragionevole fornisce.

La decisione architetturale di partenza è quindi usare fetch con ReadableStream sotto, fornire al consumer un'API che sembra EventSource ma con credenziali e payload gestiti come si deve, e mettere al centro dell'SDK una state machine esplicita che gestisce connessione, reconnection, errori, cancellation e fine naturale dello stream. Senza quella state machine esplicita, ogni developer che usa l'SDK finisce a reimplementare pezzi di reconnect, e gli errori sono sempre diversi. Con la state machine, il comportamento è deterministico e testabile.

Se vuoi vedere come progetto librerie client e server per applicazioni AI dove ogni layer sa esattamente cosa fare, nel mio hub sull'integrazione AI per aziende trovi articoli su streaming, gateway, SDK e data pipeline con criterio comune di separazione di responsabilità invece di librerie monolitiche.

1. Reconnect automatico con Last-Event-ID e backoff esponenziale

L'EventSource nativo fa reconnect ma da zero: il server deve essergli in grado di riprendere da dove era. Nel mio SDK, ogni evento ricevuto viene taggato con il suo id progressivo dal server; al reconnect, l'SDK include un header Last-Event-ID con l'ultimo id visto. Il server, come descrivo nell'articolo sullo streaming Node.js citato sopra, conserva in Redis i payload serializzati per 5 minuti e può riprendere dal token N+1 senza buchi né duplicati.

Il backoff che implemento è esponenziale con jitter (secondi: 1, 2, 4, 8, 15, fino a 5 tentativi). Dopo 5 tentativi falliti consecutivi l'SDK emette un evento permanent_failure e smette di riprovare - scaricare infinitamente su un server morto drena batteria sul mobile e non aiuta nessuno.

private async reconnect(): Promise<void> {
    for (let attempt = 0; attempt < this.maxAttempts; attempt++) {
        const baseDelay = Math.min(2 ** attempt, 16) * 1000;
        const jitter = Math.random() * baseDelay * 0.3;
        await this.sleep(baseDelay + jitter);

        try {
            await this.openStream({ lastEventId: this.lastEventId });
            return; // successo, reconnesso
        } catch (err) {
            if (this.isPermanent(err)) throw err;
            // transient: prova di nuovo
        }
    }
    this.emit('permanent_failure', { reason: 'max_reconnect_attempts' });
}

Il jitter è cruciale: se l'applicazione ha 500 utenti che perdono la connessione simultaneamente (evento rete comune, p.es. restart di un load balancer), senza jitter tutti riproverebbero esattamente dopo 2 secondi e sincronizzerebbero uno storm di richieste che peggiorerebbe il problema. Con jitter del 30%, i tentativi si spalmano su una finestra di 2,6 secondi e il server assorbe in gradualità.

2. Classificazione dell'errore: transient vs permanent

Il singolo concetto che cambia più di ogni altro la qualità dell'SDK è distinguere fra errori da riprovare e errori su cui arrendersi. La mia tassonomia ha quattro righe.

  • 401 Unauthorized / 403 Forbidden - permanent, zero retry, emetti evento auth_failure - l'utente deve rifare login
  • 402 Payment Required (quota esaurita) - permanent, zero retry, emetti evento quota_exhausted
  • 429 Too Many Requests con Retry-After header - transient, rispetta il delay dell'header
  • 5xx - transient, backoff esponenziale standard
  • Errore di rete (fetch rejected con TypeError) - transient, backoff standard

La classificazione vive in un singolo metodo che l'SDK chiama prima di decidere se ritentare o propagare al consumer.

private isPermanent(err: unknown): boolean {
    if (err instanceof AiStreamError) {
        return err.status === 401 || err.status === 403 || err.status === 402;
    }
    return false; // tutti gli altri sono transient
}

Il risultato visibile all'utente cambia radicalmente: su un 401 il browser non spende 5 tentativi inutili e reindirizza subito al login, su un 429 l'SDK aspetta il tempo che il server chiede esplicitamente, su un errore di rete l'SDK continua a provare silenziosamente mentre la UI mostra lo spinner "riconnessione in corso". Un SDK che tratta tutti gli errori allo stesso modo fa perdere 30-60 secondi all'utente a ogni problema di auth.

3. Cost tracking lato client: mostrare il consumo in tempo reale

Il backend conosce il costo di ogni chiamata LLM ma non lo trasmette al client di default. L'SDK sottoscrive un evento usage dedicato che il server emette a chiusura di ogni stream con {input_tokens, output_tokens, cost_usd}. Lato client, l'SDK mantiene un accumulator del consumo della sessione e lo espone via callback.

interface UsageEvent {
    input_tokens: number;
    output_tokens: number;
    cached_tokens: number;
    cost_usd: number;
    model: string;
}

class CostTracker {
    private sessionTotal = 0;
    private perModel = new Map<string, number>();

    record(event: UsageEvent): void {
        this.sessionTotal += event.cost_usd;
        this.perModel.set(
            event.model,
            (this.perModel.get(event.model) ?? 0) + event.cost_usd
        );
    }

    sessionCostUsd(): number {
        return this.sessionTotal;
    }
}

Su un'applicazione chat personale dove l'utente paga per-use, il counter visibile in UI cambia il comportamento: vedere "hai speso 0,07 dollari in questa sessione" spinge a formulare prompt più mirati. Su applicazioni B2B dove l'azienda paga, il tracker serve allo stesso utente finale per capire quanto sta consumando del budget del team, ed è parte della governance dei costi che descrivo nell'articolo su Laravel Horizon per chiamate LLM asincrone - lato server tracci nel ledger, lato client mostri all'utente.

4. Validazione tipizzata delle risposte con Zod

Un backend AI restituisce spesso JSON strutturato: classificazione con campi specifici, estrazioni di entità, risposte a domande con citation. L'SDK non deve accettare ciecamente il payload: deve validarlo contro uno schema al ricevimento e rigettare quelli malformati con un errore tipizzato.

Uso Zod perché è la libreria TypeScript-native più usata, ha bundle piccolo (~12 KB gz), e produce errori di validazione leggibili. Ogni streaming endpoint del backend ha un suo schema condiviso lato client e server.

import { z } from 'zod';

const ClassificationResponseSchema = z.object({
    category: z.enum(['billing', 'technical', 'sales', 'other']),
    confidence: z.number().min(0).max(1),
    reasoning: z.string().max(500),
    citations: z.array(z.object({
        source: z.string(),
        snippet: z.string().max(200),
    })).max(5),
});

type ClassificationResponse = z.infer<typeof ClassificationResponseSchema>;

async function classify(input: string): Promise<ClassificationResponse> {
    const raw = await fetchApiResult('/classify', { input });
    const parsed = ClassificationResponseSchema.safeParse(raw);
    if (!parsed.success) {
        throw new SchemaValidationError(parsed.error.format());
    }
    return parsed.data;
}

La validazione previene una classe di bug che vedo spesso: backend che, sotto load, restituisce un oggetto con un campo mancante o un tipo sbagliato, e il client manifesta il problema come undefined.map is not a function in uno stack trace oscuro. Con Zod, il problema è rilevato al confine e l'errore è esplicito - debug in minuti invece di ore.

5. Abort pulito su unmount component e navigation

Se l'utente naviga via da una pagina che ha uno stream attivo, l'SDK deve fermare la richiesta lato server (per non sprecare token Anthropic) e liberare le risorse lato client. Il pattern che uso è AbortController propagato, più un hook React dedicato che automatizza il cleanup.

export function useAiStream(endpoint: string, payload: unknown) {
    const [tokens, setTokens] = useState<string>('');
    const [state, setState] = useState<StreamState>('idle');

    useEffect(() => {
        const controller = new AbortController();
        const stream = client.stream(endpoint, payload, { signal: controller.signal });

        stream.on('token', (t) => setTokens(prev => prev + t));
        stream.on('done', () => setState('done'));
        stream.on('error', () => setState('error'));

        return () => {
            controller.abort();
            stream.close();
        };
    }, [endpoint, JSON.stringify(payload)]);

    return { tokens, state };
}

Il cleanup sul return della useEffect è il punto che tanti SDK open source sbagliano: se il componente viene smontato durante uno stream e il cleanup non chiude la connessione, il browser mantiene la fetch aperta consumando banda e token server-side che nessuno leggerà mai. Il controller.abort() propaga il segnale fino al fetch che lancia AbortError, l'SDK lo intercetta, lo classifica come cancellation voluta (non errore) e chiude pulito.

6. Buffering su tab inattivo: non perdere token quando il browser rallenta

I browser moderni (Chrome dal 2021, Firefox dal 2022) mettono in throttle aggressivo i timer e i callback quando un tab è in background. Uno stream SSE aperto su un tab in background riceve ancora i dati, ma il message handler del tuo SDK potrebbe essere invocato con batching di 50-100 token alla volta invece di uno per uno. Se il tuo handler tiene il rendering su ogni token (scorrimento auto, animazione di typing), il ritorno al foreground scatena un jank visibile di mezzo secondo mentre la UI recupera.

La soluzione è buffering esplicito nell'SDK: dopo il parsing di ogni messaggio SSE, accumulo i token in un buffer e li rendo disponibili al consumer via un evento batched ogni 16 ms (un frame a 60fps). Quando il tab è in background, il buffering accumula e basta - al ritorno in foreground, l'evento batched emette tutti i token in ordine ma in un unico frame di render.

private flushBuffer(): void {
    if (this.tokenBuffer.length === 0) return;
    const batch = this.tokenBuffer.join('');
    this.tokenBuffer = [];
    this.emit('tokens', batch);
}

private scheduleFlush(): void {
    if (this.flushScheduled) return;
    this.flushScheduled = true;
    requestAnimationFrame(() => {
        this.flushScheduled = false;
        this.flushBuffer();
    });
}

Il requestAnimationFrame ha l'ulteriore proprietà di essere throttled esso stesso in background, quindi il buffering si auto-regola senza che io debba gestire esplicitamente i casi di tab attivo vs inattivo.

7. Metriche client-side per observability

L'ultimo pezzo è quello più trascurato. Un'applicazione AI ha failure mode specifici (stream che si bloccano senza errore, tempo al primo token anomalo, reconnect ripetuti) che il server non vede e che solo il client può osservare. L'SDK emette un evento metrics che il consumer può instradare verso il suo analytics backend.

interface StreamMetrics {
    sessionId: string;
    model: string;
    timeToFirstToken_ms: number;
    totalDuration_ms: number;
    tokensReceived: number;
    reconnectCount: number;
    lastError: string | null;
}

client.on('session_complete', (metrics: StreamMetrics) => {
    sendToAnalytics('ai_stream', metrics);
});

Le cinque metriche sopra, raccolte su migliaia di sessioni, rivelano pattern che nessun log server-side mostrerebbe. TTFT medio salito del 40% nelle ultime 48 ore? Problema di rete fra i client e il datacenter, o il backend sotto carico sta throttling-ando. reconnectCount anomalo su iOS ma non Android? Bug di Safari nel WebKit di quella versione. lastError con alta frequenza di abort ma totalDuration_ms sotto la media? Gli utenti stanno abbandonando prematuramente gli stream - forse la UX indica male che la risposta sta arrivando. Nessuno di questi insight è visibile senza metriche client-side.

Quando un SDK custom è sproporzionato

Se la tua applicazione ha un singolo punto di chiamata LLM, un form che restituisce un risultato non streaming, un prototipo con 20 utenti interni, non serve un SDK. Un fetch diretto con un try-catch e un setState è 15 righe e regge perfettamente. Se stai usando un LLM vendor che fornisce il suo SDK ufficiale (Anthropic SDK, OpenAI SDK, Google AI SDK) e non stai aggiungendo una superficie unificata tua sopra, usalo - aggiungere il tuo SDK intermedio duplica il problema senza risolvere nulla. Se la tua applicazione fa esclusivamente chiamate non-streaming - classifica e restituisce un oggetto JSON, non streama token - il livello di complessità che serve è un ordine di grandezza inferiore, e probabilmente bastano wrapper sottili attorno a fetch con Zod per la validazione.

Un SDK dedicato come quello che ho descritto si giustifica quando hai contemporaneamente: un'applicazione consumer-facing con tempo di permanenza dell'utente significativo (minuti, non secondi), streaming SSE come pattern principale di UI, più di un punto di chiamata sparso nell'applicazione che beneficerebbe di una API unificata, requisito di reconnect robusto su reti mobili o VPN aziendali instabili, e necessità di mostrare all'utente cost e progress tracking. In quel punto di ottimo, l'investimento di 2-3 settimane di sviluppo e test dell'SDK si ripaga in mesi di debug risparmiato e di UX che "funziona e basta" invece che dipendere dalla qualità della rete del singolo utente.

La differenza fra un'applicazione AI che sembra magica e una che sembra fragile non è il modello dietro - è la qualità del layer di integrazione. Un utente non vede l'event loop, non vede il reconnect, non vede il cost tracker: vede che quando la sua connessione cade in un tunnel, al ritorno il testo continua da dove era rimasto. Quella esperienza è interamente nell'SDK, non nel backend. Lo stesso Claude Sonnet 4.6, servito attraverso un SDK ben progettato, produce una sensazione di robustezza che i concorrenti con integrazione sciatta non riescono a dare. E su applicazioni consumer, questa sensazione di robustezza è il vantaggio competitivo vero.

Se stai costruendo un'applicazione AI consumer-facing dove gli utenti passano minuti in stream conversazionali e vuoi capire se il tuo client-side layer è ingegnerizzato correttamente o se stai accumulando debito tecnico, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.

Ultima modifica: