Gli errori di AgileV visti dal vostro client: sei regole

Per chi: chiunque chiami AgileV da codice proprio — e in particolare chi il client se lo scrive in casa, che a quanto pare siamo quasi tutti: l'harness dei box Agile vieta di eseguire codice scaricato da fuori. Da dove nasce: il 28/9 trpg, cercando una cosa che avevamo segnalato noi, ha trovato due difetti nel proprio client. Non sono difetti loro: sono difetti di classe, e li abbiamo trovati anche nei tre client di riferimento che diamo noi. Questa pagina è quello che abbiamo imparato, con la misura sotto; se il vostro client lo avete scritto voi, controllate questi tre punti prima di aspettare che si vedano da soli.

Cosa era successo da trpg

Il loro client leggeva detail e error dalla nostra risposta e li infilava in una stringa. Da noi sono strutture, non stringhe: così ogni errore di AgileV diventava da loro la parola «Array» — letteralmente Voyage API error: Array. E non era un log: quel messaggio lo legge l'utente finale quando l'indicizzazione di un documento fallisce. In pratica, da quando sono passati a noi, KEY_MISSING, KEY_INVALID e RATE_LIMIT non arrivavano mai a chi guarda: se domani la loro chiave scadesse, il cliente leggerebbe «Array».

Il secondo difetto è peggiore perché non fa rumore: una nostra risposta 200 di forma inattesa passava per successo con zero vettori, e chi chiamava riceveva NULL in silenzio.

Regola 1 — ramificate su error.code, che è una struttura

Tutti gli errori di AgileV tranne uno hanno questo corpo, e il code è stabile: è l'unica cosa su cui vale la pena ramificare (lo stato HTTP vi dice la famiglia, il code vi dice cosa fare).

{"error": {"code": "KEY_REVOKED", "message": "chiave revocata"}}

error e detail sono oggetti: in PHP json_decode($t, true) ve li dà come array, e concatenarli in una stringa produce «Array» più un avviso. In Python, str(e["error"]) produce il repr di un dizionario. Costruite il messaggio da mostrare voi, con tre pezzi soli: stato HTTP, error.code, error.message.

Nota storica: alcune versioni annidavano lo stesso oggetto sotto detail ({"detail":{"error":{…}}}). Un client robusto legge error in cima oppure sotto detail. Oggi arriva sempre in cima.

Dove sta la tabella: ogni code che potete ricevere, con cosa fare, sta nella tabella degli errori. Guardatela una volta per i 403 che non parlano della vostra chiave ma del contratto sotto cui sta: li riceve ogni chiave dello stesso contratto nello stesso istante, e sono gli unici in cui chiederci una chiave nuova non è il rimedio. Qui non li ricopiamo: la tabella è una sola, così non può restare indietro.

Regola 2 — il 422 è diverso: non ha code, e vi rimanda indietro i vostri testi

Il 422 è l'errore che un client nuovo incontra per primo (un campo sbagliato nel corpo: input su /embed invece di texts, documents mancante su /v1/rerank). Arriva prima dell'autenticazione — corpo sbagliato e chiave mancante danno 422, non 401 — non consuma nulla, non porta le intestazioni X-RateLimit-*, ed è l'unica risposta che non ha la nostra forma: è il corpo di FastAPI.

{"detail": [{"type": "missing", "loc": ["body", "input"],
             "msg": "Field required", "input": {"texts": ["…IL VOSTRO TESTO…"]}}]}

Due conseguenze. La prima: se ramificate solo su error.code, per il 422 non trovate niente e il vostro messaggio diventa muto. Leggete detail[].loc e detail[].msg: vi dicono quale campo è sbagliato, che è esattamente l'informazione che serve. La seconda: detail[].input era l'eco di quello che avete inviato, e dal 17/10 alle 03:37:52Z non c'è più. Fino a quel giorno un lotto da 64 testi (89.600 caratteri) tornava come 89.909 byte di errore; trpg l'aveva misurato dal proprio box con 16 testi, 25.969 byte inviati e 26.085 byte di errore tornati. Adesso la risposta pesa 402 byte per un testo e per sessantaquattro, misurati al bordo, e dentro non c'è nessun pezzo del vostro corpo. Quindi ritiriamo la messa in guardia che questa riga vi dava — «non registrate i corpi d'errore per intero» — perché era un rischio nostro che scaricavamo su di voi: oggi registrare un corpo d'errore nostro per intero non vi fa finire nei log nemmeno un carattere dei vostri testi.

Regola 3 — 200 non vuol dire «i vettori sono tutti»

Contate. data deve avere tanti elementi quanti i testi che avete inviato, e gli index devono essere 0…n-1. Se ne arrivano meno e voi accoppiate i testi ai vettori con uno zip (o un foreach parallelo), indicizzate il testo sbagliato col vettore sbagliato e non ve ne accorgete: il guasto si vede mesi dopo, come qualità di ricerca che peggiora. Sul rerank vale la stessa regola con un'avvertenza: results ha min(top_n, documents) elementi quando top_n è positivo — meno di documents è lecito se avete chiesto top_n, mai di più — e ogni index deve stare nell'intervallo dei documenti che avete mandato. Un top_n zero o negativo non si applica: tornano tutti i documenti e la risposta lo dice in ignored_values: ["top_n"], quindi non calcolate l'atteso con un min nudo — min(-1, 4) fa -1, e un client che si fermava lì accusava il servizio (attesi -1 elementi in results, ricevuti 3) per un valore sbagliato suo. Era il caso dei nostri due client pubblicati, corretto il 7/10 insieme al servizio.

Regola 4 — una lista vuota non è una risposta

Questa l'ha trovata trpg in casa propria il 28/9, tirando il filo di un nostro avviso, ed è la più cattiva delle quattro perché non produce nessun errore e nessun risultato. La loro ricerca scartava in silenzio — un continue nudo, in due punti — ogni frammento la cui dimensione non combaciava con quella della domanda. Conseguenza: un indice costruito con un modello e interrogato con un altro non dà un errore, dà zero risultati, e l'assistente risponde senza fonti, plausibilmente e a vuoto. Misurato da loro in sola lettura, con un servizio finto al posto nostro: domanda a 512 valori su indice a 1024, nessuna eccezione, 0 risultati; indice vecchio dopo un cambio di modello, 0 risultati su 471 frammenti tutti scartati, senza una traccia da nessuna parte. L'ingresso del danno, da loro, erano i pacchetti d'installazione col nome del modello cablato e l'indirizzo del servizio assente: un'installazione presso un cliente poteva avere AgileV in configurazione e costruire comunque l'indice sul fornitore storico a 512 valori, senza nessun segnale.

Cinque contromisure, tutte a costo zero tranne l'ultima. La (a) e la (e) valgono sull'indice che avete GIÀ: la (b), la (c) e la (d) proteggono quello che scrivete da domani, mentre la (a) si applica ai frammenti scritti mesi fa, perché la loro dimensione si misura e non serve che qualcuno l'abbia dichiarata. Ce l'ha fatto notare trpg mettendola in opera, e per loro vale più delle altre proprio per questo. La (e) è arrivata dopo, ed è il rimedio per chi la (d) non ce l'ha.

Regola 5 — nessuna risposta deve uscire dal vostro client con codice «non so»

Questa l'abbiamo trovata noi, il 29/9, contando quante forme può prendere una risposta di AgileV. Il 422 della Regola 2 non è un caso isolato: le risposte senza error.code erano quattro, il 5/10 ne abbiamo trovata una quinta e l'8/10 una sesta che non avevamo contato, e non le scrive AgileV — le scrive il framework sotto di noi o il bordo davanti a noi, prima che il nostro codice veda la richiesta.

QuandoCosa riceveteChi la scrive
corpo della richiesta non valido422 con {"error":{"code":"BODY_INVALID"},"detail":[{...}]} — chiusa il 17/10 alle 03:37:52Z (Regola 2)AgileV
metodo HTTP sbagliato (GET su /v1/embeddings) — è quella del 13/9 qui sotto405 con {"detail":"Method Not Allowed"} — chiusa il 9/10 alle 07:31Z: adesso è {"error":{"code":"METHOD_NOT_ALLOWED",…}}, ed è la stessa riga che fino a ieri prometteva quel codice «col prossimo caddy reload»FastAPI, riscritta dal bordo
corpo oltre gli 8.000.000 byte del bordo — ci si arriva restando dentro ai limiti pubblicati, con testo multibyte: 128 testi da 32.000 caratteri CJK sono 24,5 MB413 con zero byte di corpo, nessun Content-Type, Connection: close — chiusa il 9/10 alle 07:31Z: adesso è {"error":{"code":"BODY_TOO_LARGE",…}}, ed è la stessa riga che fino a ieri prometteva quel codice «col prossimo caddy reload»il bordo (Caddy)
il motore dietro il bordo non risponde: la riceve chiunque mandi una richiesta mentre il motore si riavvia, cioè a ogni rilascio502/503/504 con zero byte di corpo, nessun Content-Type — chiusa il 9/10 alle 07:31Z: adesso è {"error":{"code":"SERVICE_UNAVAILABLE",…}}, ed è la stessa riga che fino a ieri prometteva quel codice «col prossimo caddy reload»il bordo (Caddy)
rotta che non esistechiusa il 5/10 alle 06:40Z (scritta l'1/10, in linea col reload del 5/10; in mezzo ne sono uscite altre 48, 5 a chi usa il servizio): era 404 con {"detail":"Not Found"}, adesso il bordo la riscrive in {"error":{"code":"NOT_FOUND",…}}FastAPI, riscritta dal bordo
URL di base sbagliato, o percorso fuori dall'APIchiusa il 29/9, in linea dall'1/10 alle 02:00 di Roma: era 404 con not found in testo semplice, nemmeno JSONil bordo (Caddy)

Erano quattro; poi misurando in gabbia ne abbiamo trovate altre due, e oggi non ne resta aperta nessuna. L'ultima era il 422 del corpo sbagliato, e l'abbiamo chiusa noi il 17/10 alle 03:37:52Z: adesso ha error.code BODY_INVALID, e non vi rimanda più indietro i testi che ci avete inviato — era la ragione per cui questa pagina vi diceva di non registrare i corpi d'errore per intero. Le tre di prima — il 405, il 413 del corpo troppo grande (trovato il 5/10) e i tre 50x del motore che non risponde (trovati l'8/10) — sono chiuse dal 9/10 alle 07:31Z. In tutte detail era una stringa (o il corpo non era nemmeno JSON, o non c'era corpo per niente), quindi cadevano tutte nello stesso ramo del vostro lettore d'errore: quello che non sa cosa fare. Se lì mettete ?, chi vi chiama riceve un allarme che non si può smistare né contare — è la Regola 1 vestita da caso limite. Le due che si sono chiuse le ha chiuse il bordo, senza toccare l'app: il 29/9 i quattro punti in cui ferma una richiesta da sé, e il 5/10 alle 06:40:01Z — scritto l'1/10, in linea col reload del 5/10 — il 404 che gli torna dal motore — /v1/* passa intero, quindi /v1/embed o /v1/keys arrivavano a FastAPI e tornavano senza code (misurato: 0 su 7 → 7 su 7 fra le richieste a tentoni sotto /v1/). Il 405 fino all'8/10 restava, e la ragione era questa: la rotta esiste, e dirle «rotta inesistente» sarebbe un consiglio falso. La ragione vale ancora — il 405 non riusa il corpo del 404 — ma escludeva una cura, non la cura: dal 9/10 alle 07:31:16Z ha un code suo, METHOD_NOT_ALLOWED, e il messaggio dice quale metodo vuole quella rotta, preso dall'Allow che il framework manda già. E non sono ipotesi: il 13/9/2026 alle 01:26:32Z un cliente ha chiamato /v1/embeddings in GET e ha ricevuto il 405; il 15/9 alle 12:43:55Z un altro ha ricevuto un 502 del bordo perché due secondi prima avevamo riavviato il servizio. Nessuno dei due ce l'ha segnalato: l'abbiamo letto noi nel log del bordo, e dal loro lato era un errore senza nome.

Cosa fare. Non indovinare dal corpo: quei corpi non sono nostri e nessuno ha promesso cosa ci metterà dentro. Dedurre il codice dallo stato HTTP, che è l'unica parte garantita — e sono cinque comportamenti diversi, che con ? erano uno solo:

StatoCodice che ci mettiamo noiRitentare?
404NOT_FOUND — percorso inesistente: controllate l'URL di base e la rottano: è l'indirizzo
405BAD_METHOD — metodo sbagliato per questa rotta. Dal 9/10, alle 07:31Z, questa risposta ha la nostra forma, METHOD_NOT_ALLOWED, col metodo giusto dentro il messaggio: questa riga resta valida com'è scritta, e il vostro client non cambia di un carattere — è il senso della regola. Il messaggio nostro potete mostrarlo, perché quello sì è nostrono: è il verbo
3xxREDIRECT — un rimando: quasi sempre una barra finale di troppo nell'URLno: togliete la barra, oppure seguite il Location
5xx senza formaUPSTREAM_ERROR — il bordo non è arrivato al servizio, o il servizio è caduto senza forma. Dal 9/10, alle 07:31Z, il caso più comune — il motore che si riavvia — ha la nostra forma, SERVICE_UNAVAILABLE, e questa riga resta per gli altrisì
qualunque altroNO_CODE, con lo stato dentro al messaggio — sconosciuto sì, muto nodipende dallo stato

Il corollario, ed è la parte che si dimentica: il corpo di quelle risposte non va messo nel messaggio. Una pagina d'errore di un proxy di mezzo può contenere qualunque cosa, e finirebbe nei vostri log etichettata come un errore di AgileV. È la stessa regola dell'eco del 422 (Regola 2), applicata a corpi che non sono nostri.

La barra in fondo: il 307 che non è un errore (1/10)

Una rotta vera chiamata con una barra finale — POST /v1/embeddings/ invece di POST /v1/embeddings — non risponde 200 e non risponde con un nostro errore: risponde 307, corpo vuoto, con Location: verso la stessa rotta senza barra. Lo scrive il framework (redirect_slashes), non il nostro codice, e il 307 conserva metodo e corpo: un client che segue i rimandi non se ne accorge nemmeno. Un client che non li segue — e tanti li disattivano sulle POST, ed è una scelta prudente, perché un rimando su una POST rispedisce il corpo — vede uno stato che non è 200 e non ha error.code. Non è una terza forma senza codice: non è un errore, è la stessa risposta all'indirizzo giusto.

Quello che serve sapere, tutto misurato (bench/misura-la-barra-in-fondo.py): seguirlo è sicuro, perché il Location è assoluto e in https e il corpo del 307 è vuoto (0 byte: non torna indietro niente di quello che avete mandato); non consuma (la stessa chiamata conta 1 richiesta senza barra, 1 seguendo il rimando, 0 fermandosi al 307: il rimando non si paga); non porta le intestazioni X-RateLimit-*, quindi chi regola il ritmo su X-RateLimit-Remaining su un 307 non legge niente; e non si comporta uguale su tutte le rotte — GET /health/ e POST /embed/ non arrivano nemmeno all'app e ricevono il 404 NOT_FOUND del bordo, che dice «rotta inesistente» di una rotta che esiste. Dal vivo l'1/10, sulle otto rotte pubbliche: 5 rispondono 307, due il 404 del bordo.

Cosa fare: non scrivete la barra finale. I tre client che diamo noi non possono metterla da soli (tagliano la barra dell'indirizzo di base) e dall'1/10 hanno il ramo 3xx della tabella qui sopra, che restituisce REDIRECT con la cura invece di un codice «non so»: misurato 1 client su 3 → 3 su 3 che ne ricavano qualcosa di smistabile.

La misura, sui client che diamo noi

Non è un consiglio a occhio: è quello che abbiamo trovato addosso. bench/misura-errori-client.py cattura i corpi d'errore veri del servizio (da una copia del codice caricata in memoria, senza mandare una richiesta alla produzione), li rimanda da un server finto locale e registra cosa vede il chiamante nei tre client di riferimento: il 28/9 12 scene x 3 client = 36 righe, dal 29/9 15 scene x 3 client = 45 righe (le tre aggiunte sono il 405, il 404 dell'app e il 404 in testo semplice del bordo: forme reali che nessuno aveva mai passato ai client). Le ultime due oggi non arrivano più così — il bordo le riscrive — ma le scene restano: un client deve sapere cosa fare anche della forma vecchia, perché un client vecchio parla con un bordo nuovo e viceversa, e perché la Regola 5 (il codice si deduce dallo stato) è scritta proprio per non dipendere dal corpo.

Cosa si misuraPrima (28/9)Dopo (28/9)Dopo (29/9)
Righe in cui il chiamante non vede un codice nostro15 su 363 su 36 (il 502 HTML del bordo)0 su 45 — con le tre scene nuove erano diventate 12 su 45
Righe in cui il messaggio visibile contiene l'eco del testo inviato900
Righe in cui tornano meno vettori dei testi, in silenzio300

I tre erano tutti da correggere, e nello stesso modo. Il 422 diventa il codice BAD_BODY, col messaggio corpo della richiesta non valido - body.input: Field required (solo loc, msg e type: input non si legge mai). Un data mutilato o assente diventa BAD_RESPONSE, col messaggio attesi 2 elementi in data, ricevuti 1. Dal 29/9 le quattro forme senza code escono con NOT_FOUND, BAD_METHOD, UPSTREAM_ERROR e NO_CODE, e il corpo non entra più nel messaggio. Questi codici li mette il client, non il servizio: servono a voi per ramificare, e sono uguali nel Python, nel PHP e nell'adattatore LangChain — parola per parola.

Le stesse 45 righe misurate catturando i corpi d'errore dal codice 1.1 che è in produzione oggi e dalla 1.2 danno lo stesso risultato: potete ricopiare i client adesso, non dovete aspettare un nostro deploy.

E quante volte è successo davvero, letto dal log del nostro bordo di settembre in sola lettura (nessuna richiesta al servizio, nessun indirizzo nell'uscita): su 4.310 richieste servite, 20 risposte senza un codice del contratto sono arrivate a qualcuno che il servizio lo usa — 13 su una rotta vera e 7 mentre cercava la rotta. Il conto onesto è costato due correzioni: il percorso non basta a dire chi è (uno scanner che prova /v1/.env non è un cliente) e 131 di quei "clienti in difficoltà" erano il nostro stesso banco di accettazione, che chiede apposta dei 404 per verificarli. Da 151 a 20. Se non ve lo diciamo così, il numero sembra un allarme e non è.

17 test nuovi tengono il comportamento (fra questi uno che passa otto errori veri del servizio ai tre lettori e pretende che nessuno faccia uscire l'eco), e 11 mutazioni del codice dei client le fanno cadere tutte: se qualcuno rimette il lettore vecchio, il banco lo vede. Per la Regola 5, altri 9 test e 16 mutazioni (15 che devono cadere e una che, non cambiando niente, non deve cadere), provate su tutti e tre i client — tre copie della stessa regola sono tre posti dove può restare quella vecchia.

Regola 6 — la configurazione che sparisce dentro un server web

Questa non ce l'ha insegnata un errore: ce l'ha insegnata trpg misurando in casa propria il 29/9. Nel loro contenitore, sotto Apache $_SERVER['LLM_API_URL'] è null mentre getenv() e $_ENV portano il valore. Da riga di comando, sul nostro box, è l'opposto: getenv() e $_SERVER portano il valore e $_ENV è vuoto (variables_order predefinito, senza «E»). E php-fpm con clear_env = yes, che è il valore predefinito, svuota l'ambiente del processo: lì resta solo $_SERVER, riempito dalle righe env[...] del pool. Tre stanze, tre risultati diversi per la stessa variabile: chi ne legge una sola sta scommettendo sulla stanza, e quando perde la scommessa non c'è nessun errore — c'è una configurazione che funziona in ogni prova da riga di comando e non c'è più dentro il server web.

Perché per AgileV è peggio che per gli altri: la variabile che sparisce è l'indirizzo. Se avete puntato il client su un fronte privato — una WireGuard, un secondo box, lo smistatore — e AGILEV_URL non arriva, il client non si ferma: prende il predefinito pubblico e i vostri testi escono su Internet, senza una parola, con tutte le prove verdi.

Cosa abbiamo trovato in casa nostra, prima di scrivere questa regola. Censiti i sei esempi che pubblichiamo: la chiave si fermava 6 volte su 6, l'indirizzo mai. Quattro celle finivano in silenzio sull'indirizzo pubblico; due esempi, con AGILEV_KEY vuota (non assente: vuota), spedivano il testo alla produzione pubblica con un Bearer vuoto; e in ricerca_ibrida.php l'indirizzo si decideva dentro embed(), quindi non era controllabile senza fare una richiesta, nemmeno da noi. Adesso è 0, 0 e 0 (bench/misura-indirizzo-che-sparisce.py, gruppo di controllo preso da git, 22 mutazioni su 22 viste).

Le quattro regole, se il client lo scrivete voi: (1) leggete l'ambiente da tutte le sorgenti che la stanza può avere — in PHP getenv(), $_SERVER, $_ENV, in quest'ordine, perché getenv() è l'ambiente del processo e non qualcosa che arriva con la richiesta; (2) un valore presente ma vuoto è un errore che nomina la variabile, mai un ripiego verso il predefinito; (3) dite sempre da dove arriva l'indirizzo, e della chiave dite la sorgente e mai il valore; (4) se l'indirizzo deve essere privato, mettete una guardia che si ferma sul predefinito — e scrivetela nel codice, non in una variabile d'ambiente: una guardia che vive nello stesso ambiente sparisce insieme a ciò che deve proteggere, proprio nella stanza in cui serve.

La diagnosi in un comando, quando «da riga di comando funziona e sotto il server web no»: php ricerca_ibrida.php --dove oppure python agilev_client.py --prova stampano indirizzo e sorgente (argomento|getenv|server|env|ambiente|predefinito) e non stampano mai la chiave.

Quello che non affermiamo: la stanza di php-fpm non l'abbiamo misurata noi — php-fpm non è installato sul nostro box, e quella riga è documentazione, non una nostra misura. Quella di Apache è di trpg. La nostra è la riga di comando. Le tre righe della tabella dicono chi ha misurato cosa, apposta.

Cosa dovete fare

Se usate i nostri client (agilev_client.py, agilev_client.php, agilev_langchain.py): ricopiateli. La correzione è dentro, non cambia nessuna firma e non cambia niente sul percorso felice. Dentro c'è anche la (a), fatta come regge — una riga sola, all'avvio:

av.controlla_dim(1024)        # Python     $av->controllaDim(1024)   # PHP
emb.controlla_dim(1024)       # LangChain (AgileVEmbeddings)

Legge dim da /health (senza chiave, senza consumo), si ferma con DIM_MISMATCH se non è la dimensione del vostro indice, e non guarda il nome. Da quel momento fa anche la (c) per voi: un vettore della misura sbagliata diventa un BAD_RESPONSE («attesi vettori da 1024 valori, ricevuti da 512») invece di entrare nell'indice, e due lunghezze diverse nello stesso lotto sono un errore anche senza aver chiamato controlla_dim.

Se il client lo avete scritto voi: i punti sopra sono da guardare uno per uno. Il più rapido è la Regola 2 — cercate nel vostro codice se registrate il corpo di una risposta d'errore per intero. Il più importante è la Regola 4, perché è quella che sporca un indice senza far rumore; e leggetela tutta, compreso il paragrafo sul nome: la guardia scritta sul valore sbagliato è peggio che nessuna guardia, perché vi fa disattivare anche quella buona.

In entrambi i casi, una prova che dura un minuto: fate fallire una chiamata per davvero (una chiave sbagliata, e un corpo con un campo mancante) e guardate con gli occhi il messaggio che arriva a chi lo legge — un utente, un ticket, un log. Se contiene la parola «Array», un repr di dizionario, un ? o un pezzo dei vostri testi, avete trovato il difetto. È quello che ha fatto trpg.

Niente di tutto questo cambia il vostro client, ed è il punto. Il 404 la nostra forma ce l'ha dal 5/10 e il 405 dal 9/10 alle 07:31:16Z: li ha chiusi il bordo, senza toccare l'app e senza che voi ricopiate niente, perché il codice lo deducete dallo stato. E il 422 non resta: la nostra forma gliel'abbiamo data noi, nell'app, il 17/10 alle 03:37:52Z — code stabile (BODY_INVALID) e senza eco dei vostri testi — e non è stato un cambio di contratto: il contratto è rimasto 1.2, e nessuna chiave si è dovuta riemettere. Dettagli in API.md, sezione «Errori». Questa pagina è autonoma, senza script e senza risorse remote: potete leggerla e ricopiarla dove volete.

this page in English