fix(budget): le circuit breaker des chatbots lit enfin stats_usage
checkBudget filtrait stats_usage sur `timestamp` (where + fields) et recevait un 422 à chaque appel : fail-open silencieux, budget jamais vérifié côté chatbot. Aligné sur le worker : aucune colonne nommée dans la requête, lecture paginée, mois filtré en JS (timestamp, puis CreatedAt, puis created_at). Chaque lecture journalise le budget lu ; un échec journalise « budget NON vérifié » et renvoie verified=false. Test : scripts/test-circuit-breaker.mjs, faux NocoDB qui rejette en 422 toute colonne absente (15/15 ; 11 échecs si le filtre revient). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ebfc744574
commit
6ac5a2bdf7
@@ -204,6 +204,8 @@ cout_eur = ((tokens_in × 0.02 + tokens_out × 0.04) / 1_000_000) × 0.93
|
|||||||
|
|
||||||
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
|
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
|
||||||
|
|
||||||
|
**Côté chatbot (AF5, 29/09)** : `server/utils/circuitBreaker.ts` n'appliquait pas ce contournement. Il envoyait `where=(timestamp,gte,…)&fields=cout_eur,timestamp` et recevait un 422 à chaque appel : fail-open silencieux, budget jamais vérifié depuis la V2. Il suit désormais le worker : **aucune colonne nommée dans la requête** (ni `where`, ni `fields`, ni `sort`), lecture paginée (`limit`/`offset` 1000), mois filtré en JS sur `timestamp`, sinon `CreatedAt`, sinon `created_at`. Chaque lecture écrit au journal `[circuitBreaker] budget lu : X € / 20 € (n lignes AAAA-MM sur N lues)` ; un échec écrit `budget NON vérifié`. Test : `node scripts/test-circuit-breaker.mjs` (faux NocoDB qui rejette en 422 toute colonne absente). Seule `/api/chatbot` vérifie le budget ; `-reseaux` et `-taff` ne le lisent ni ne l'écrivent (asymétrie pré-existante), et les appels servis par un tier gratuit s'inscrivent à 0 €.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Infrastructure
|
## 7. Infrastructure
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test du circuit breaker budget (server/utils/circuitBreaker.ts), AF5.
|
||||||
|
* Charge le VRAI module TS via jiti (comme test-submit-libre.mjs).
|
||||||
|
*
|
||||||
|
* Le faux NocoDB ci-dessous se comporte comme le vrai sur le point qui a cassé :
|
||||||
|
* toute colonne nommée dans `where`, `fields` ou `sort` et absente du schéma
|
||||||
|
* renvoie un 422. Réintroduire un filtre sur un champ absent fait donc échouer
|
||||||
|
* le cas « schéma sans timestamp ».
|
||||||
|
* Usage : node scripts/test-circuit-breaker.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createJiti } from 'jiti'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const jiti = createJiti(import.meta.url)
|
||||||
|
|
||||||
|
const { checkBudget, BUDGET_MAX_EUR } = await jiti.import(
|
||||||
|
join(__dirname, '..', 'server', 'utils', 'circuitBreaker.ts'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-29T10:00:00Z')
|
||||||
|
|
||||||
|
/** Colonnes référencées par une requête NocoDB v2 (where, fields, sort). */
|
||||||
|
function colonnesReferencees(query) {
|
||||||
|
const cols = []
|
||||||
|
if (query.where) {
|
||||||
|
for (const m of String(query.where).matchAll(/\(([^,()]+),/g)) cols.push(m[1].trim())
|
||||||
|
}
|
||||||
|
if (query.fields) cols.push(...String(query.fields).split(',').map((s) => s.trim()))
|
||||||
|
if (query.sort) cols.push(...String(query.sort).split(',').map((s) => s.trim().replace(/^-/, '')))
|
||||||
|
return cols.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Faux NocoDB : schéma de colonnes + lignes ; 422 sur colonne inconnue ; pagination limit/offset. */
|
||||||
|
function fauxNocodb({ schema, rows, panne = false }) {
|
||||||
|
const appels = []
|
||||||
|
const fetchJson = async (url, { headers, query }) => {
|
||||||
|
appels.push({ url, query })
|
||||||
|
if (panne) throw Object.assign(new Error('[GET] 500 Internal Server Error'), { statusCode: 500 })
|
||||||
|
if (headers['xc-token'] !== 'tok') throw Object.assign(new Error('401'), { statusCode: 401 })
|
||||||
|
const inconnues = colonnesReferencees(query).filter((c) => !schema.includes(c))
|
||||||
|
if (inconnues.length) {
|
||||||
|
throw Object.assign(new Error(`[GET] 422 Field '${inconnues[0]}' not found`), { statusCode: 422 })
|
||||||
|
}
|
||||||
|
const limit = Number(query.limit ?? 25)
|
||||||
|
const offset = Number(query.offset ?? 0)
|
||||||
|
const list = rows.slice(offset, offset + limit)
|
||||||
|
return { list, pageInfo: { isLastPage: offset + limit >= rows.length } }
|
||||||
|
}
|
||||||
|
return { fetchJson, appels }
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = { nocodbUrl: 'http://noco', nocodbToken: 'tok', statsTableId: 'mbbq7n47ixy19mc', now: NOW }
|
||||||
|
|
||||||
|
// Schéma supposé de la prod : pas de colonne `timestamp`, date système `CreatedAt`.
|
||||||
|
const SCHEMA_SANS_TS = ['Id', 'model', 'endpoint', 'tokens_in', 'tokens_out', 'cout_eur', 'orga_id', 'CreatedAt', 'UpdatedAt']
|
||||||
|
const SCHEMA_AVEC_TS = [...SCHEMA_SANS_TS, 'timestamp']
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
function check(name, cond, detail = '') {
|
||||||
|
results.push({ name, ok: !!cond, detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Journal capturé pour vérifier la ligne de budget
|
||||||
|
const logs = []
|
||||||
|
const origInfo = console.info
|
||||||
|
const origWarn = console.warn
|
||||||
|
console.info = (...a) => logs.push(['info', a.join(' ')])
|
||||||
|
console.warn = (...a) => logs.push(['warn', a.join(' ')])
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 0. Le faux NocoDB rejette bien l'ancienne requête (sinon le test ne prouverait rien)
|
||||||
|
{
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [] })
|
||||||
|
let status = null
|
||||||
|
try {
|
||||||
|
await fetchJson('x', {
|
||||||
|
headers: { 'xc-token': 'tok' },
|
||||||
|
query: { where: '(timestamp,gte,2026-09-01T00:00:00.000Z)', limit: 1000, fields: 'cout_eur,timestamp' },
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
status = e.statusCode
|
||||||
|
}
|
||||||
|
check('faux NocoDB : ancienne requête (where/fields sur timestamp) → 422', status === 422, `statut ${status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Schéma sans timestamp : lu, mois courant seul, aucune colonne nommée
|
||||||
|
{
|
||||||
|
const rows = [
|
||||||
|
{ Id: 1, cout_eur: 0.5, CreatedAt: '2026-09-03T08:00:00.000Z' },
|
||||||
|
{ Id: 2, cout_eur: '1.25', CreatedAt: '2026-09-28 22:10:00+00:00' },
|
||||||
|
{ Id: 3, cout_eur: 7, CreatedAt: '2026-08-31T23:59:00.000Z' }, // mois précédent
|
||||||
|
{ Id: 4, cout_eur: null, CreatedAt: '2026-09-10T00:00:00.000Z' },
|
||||||
|
]
|
||||||
|
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||||
|
logs.length = 0
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('sans timestamp : budget vérifié (pas de 422)', s.verified === true, JSON.stringify(s))
|
||||||
|
check('sans timestamp : cumul = 1,75 € (août exclu)', Math.abs(s.cumulEur - 1.75) < 1e-9, `cumul ${s.cumulEur}`)
|
||||||
|
check('sans timestamp : 3 lignes du mois sur 4', s.lignesMois === 3 && s.lignesLues === 4)
|
||||||
|
check('aucune colonne nommée dans la requête', appels.every((a) => colonnesReferencees(a.query).length === 0),
|
||||||
|
JSON.stringify(appels.map((a) => a.query)))
|
||||||
|
const ligne = logs.find(([niv, t]) => niv === 'info' && t.includes('[circuitBreaker] budget lu'))
|
||||||
|
check('journal : ligne « budget lu » avec le cumul', ligne && ligne[1].includes('1.7500 €'), ligne?.[1] ?? 'absente')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Schéma avec timestamp : timestamp prioritaire sur CreatedAt (ordre du worker)
|
||||||
|
{
|
||||||
|
const rows = [
|
||||||
|
{ Id: 1, cout_eur: 2, timestamp: '2026-09-15T12:00:00.000Z', CreatedAt: '2026-08-15T12:00:00.000Z' },
|
||||||
|
{ Id: 2, cout_eur: 3, timestamp: '2026-08-15T12:00:00.000Z', CreatedAt: '2026-09-15T12:00:00.000Z' },
|
||||||
|
]
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_AVEC_TS, rows })
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('avec timestamp : timestamp prioritaire (cumul 2 €)', s.verified && s.cumulEur === 2, `cumul ${s.cumulEur}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lignes sans aucune date : ignorées et comptées
|
||||||
|
{
|
||||||
|
const rows = [{ Id: 1, cout_eur: 9 }, { Id: 2, cout_eur: 1, CreatedAt: '2026-09-02T00:00:00Z' }]
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||||
|
logs.length = 0
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('sans date : ignorée, comptée', s.cumulEur === 1 && s.lignesSansDate === 1)
|
||||||
|
check('journal : lignes sans date signalées', logs.some(([, t]) => t.includes('1 sans date ignorées')))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Pagination : 2 500 lignes du mois à 0,01 € → 25 € → bloqué
|
||||||
|
{
|
||||||
|
const rows = Array.from({ length: 2500 }, (_, i) => ({ Id: i + 1, cout_eur: 0.01, CreatedAt: '2026-09-20T00:00:00Z' }))
|
||||||
|
const { fetchJson, appels } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||||
|
logs.length = 0
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('pagination : 2 500 lignes lues en 3 pages', s.lignesLues === 2500 && appels.length === 3, `${s.lignesLues} lignes, ${appels.length} appels`)
|
||||||
|
check(`seuil : 25 € ≥ ${BUDGET_MAX_EUR} € → blocked`, s.blocked === true && s.warning === true)
|
||||||
|
check('journal : BLOQUÉ', logs.some(([, t]) => t.includes('BLOQUÉ')))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Alerte entre 18 et 20 €
|
||||||
|
{
|
||||||
|
const rows = [{ Id: 1, cout_eur: 18.5, CreatedAt: '2026-09-20T00:00:00Z' }]
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows })
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('18,5 € → warning sans blocage', s.warning === true && s.blocked === false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Panne NocoDB : fail-open, mais verified=false et avertissement au journal
|
||||||
|
{
|
||||||
|
const { fetchJson } = fauxNocodb({ schema: SCHEMA_SANS_TS, rows: [], panne: true })
|
||||||
|
logs.length = 0
|
||||||
|
const s = await checkBudget({ ...base, fetchJson })
|
||||||
|
check('panne : fail-open, verified=false', s.blocked === false && s.verified === false)
|
||||||
|
check('panne : avertissement « NON vérifié »', logs.some(([niv, t]) => niv === 'warn' && t.includes('NON vérifié')))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.info = origInfo
|
||||||
|
console.warn = origWarn
|
||||||
|
}
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.ok) failed++
|
||||||
|
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.ok ? '' : ` → ${r.detail}`}`)
|
||||||
|
}
|
||||||
|
console.log(failed ? `\nÉCHEC — ${failed}/${results.length} tests en échec` : `\nPASS — ${results.length}/${results.length} tests passés`)
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
@@ -19,53 +19,117 @@ export interface BudgetStatus {
|
|||||||
cumulEur: number
|
cumulEur: number
|
||||||
blocked: boolean
|
blocked: boolean
|
||||||
warning: boolean
|
warning: boolean
|
||||||
|
/** false si stats_usage n'a pas pu être lu : le budget n'est alors PAS vérifié (fail-open). */
|
||||||
|
verified: boolean
|
||||||
|
/** Lignes lues dans stats_usage (toutes dates). */
|
||||||
|
lignesLues: number
|
||||||
|
/** Lignes retenues pour le mois courant. */
|
||||||
|
lignesMois: number
|
||||||
|
/** Lignes sans aucun champ date reconnu (ignorées, comme dans le worker). */
|
||||||
|
lignesSansDate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page NocoDB v2 : seuls les champs lus ici sont typés. */
|
||||||
|
interface StatsPage {
|
||||||
|
list?: Record<string, unknown>[]
|
||||||
|
pageInfo?: { isLastPage?: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchJson = (
|
||||||
|
url: string,
|
||||||
|
opts: { headers: Record<string, string>; query: Record<string, string | number> },
|
||||||
|
) => Promise<StatsPage>
|
||||||
|
|
||||||
|
const PAGE_SIZE = 1000
|
||||||
|
const MAX_PAGES = 20 // 20 000 lignes : garde-fou contre une boucle, pas une limite métier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date d'une ligne de stats_usage, dans l'ordre du worker (`worker/enrich.js`,
|
||||||
|
* getBudgetMoisCourant) : `timestamp` écrit par les routes et le worker, sinon
|
||||||
|
* la colonne système de NocoDB (`CreatedAt`, ou `created_at` selon la version).
|
||||||
|
*/
|
||||||
|
export function dateLigne(row: Record<string, unknown>): Date | null {
|
||||||
|
const brut = row.timestamp ?? row.CreatedAt ?? row.created_at
|
||||||
|
if (brut === null || brut === undefined || brut === '') return null
|
||||||
|
const d = new Date(brut as string)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
|
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
|
||||||
* Retourne blocked=true si le budget est atteint.
|
* Retourne blocked=true si le budget est atteint.
|
||||||
|
*
|
||||||
|
* AF5 (29/09) : la requête ne nomme plus AUCUNE colonne (ni `where`, ni `fields`,
|
||||||
|
* ni `sort`). L'ancienne version filtrait sur `timestamp` côté NocoDB et recevait
|
||||||
|
* un 422 à chaque appel : budget jamais vérifié côté chatbot, en silence
|
||||||
|
* (fail-open). On lit la table page par page et on filtre le mois en JS,
|
||||||
|
* comme le worker, qui lit la même table sans erreur.
|
||||||
*/
|
*/
|
||||||
export async function checkBudget(config: {
|
export async function checkBudget(config: {
|
||||||
nocodbUrl: string
|
nocodbUrl: string
|
||||||
nocodbToken: string
|
nocodbToken: string
|
||||||
statsTableId: string
|
statsTableId: string
|
||||||
|
/** Injecté par les tests ; `$fetch` de Nitro sinon. */
|
||||||
|
fetchJson?: FetchJson
|
||||||
|
/** Injecté par les tests ; maintenant sinon. */
|
||||||
|
now?: Date
|
||||||
}): Promise<BudgetStatus> {
|
}): Promise<BudgetStatus> {
|
||||||
const { nocodbUrl, nocodbToken, statsTableId } = config
|
const { nocodbUrl, nocodbToken, statsTableId } = config
|
||||||
|
const fetchJson: FetchJson = config.fetchJson ?? ((url, opts) => $fetch<StatsPage>(url, opts))
|
||||||
|
const now = config.now ?? new Date()
|
||||||
|
const annee = now.getUTCFullYear()
|
||||||
|
const mois = now.getUTCMonth()
|
||||||
|
|
||||||
// Premier du mois courant à minuit UTC
|
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||||
const now = new Date()
|
let cumulEur = 0
|
||||||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
let lignesLues = 0
|
||||||
const monthStartIso = monthStart.toISOString()
|
let lignesMois = 0
|
||||||
|
let lignesSansDate = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch toutes les entrées du mois courant (NocoDB v2)
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
const res = await fetchJson(url, {
|
||||||
|
|
||||||
const res = await $fetch<{ list: { cout_eur: number | null; timestamp: string }[] }>(
|
|
||||||
url,
|
|
||||||
{
|
|
||||||
headers: { 'xc-token': nocodbToken },
|
headers: { 'xc-token': nocodbToken },
|
||||||
query: {
|
query: { limit: PAGE_SIZE, offset: page * PAGE_SIZE },
|
||||||
where: `(timestamp,gte,${monthStartIso})`,
|
})
|
||||||
limit: 1000,
|
|
||||||
fields: 'cout_eur,timestamp',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const rows = res?.list ?? []
|
const rows = res?.list ?? []
|
||||||
const cumulEur = rows.reduce((sum, row) => sum + (Number(row.cout_eur) || 0), 0)
|
for (const row of rows) {
|
||||||
|
lignesLues++
|
||||||
return {
|
const d = dateLigne(row)
|
||||||
cumulEur,
|
if (!d) {
|
||||||
blocked: cumulEur >= BUDGET_MAX_EUR,
|
lignesSansDate++
|
||||||
warning: cumulEur >= BUDGET_WARN_EUR,
|
continue
|
||||||
|
}
|
||||||
|
if (d.getUTCFullYear() === annee && d.getUTCMonth() === mois) {
|
||||||
|
lignesMois++
|
||||||
|
cumulEur += Number(row.cout_eur) || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.length < PAGE_SIZE || res?.pageInfo?.isLastPage) break
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
|
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
|
||||||
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget non vérifié:', (e as Error).message)
|
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget NON vérifié:', (e as Error).message)
|
||||||
return { cumulEur: 0, blocked: false, warning: false }
|
return { cumulEur: 0, blocked: false, warning: false, verified: false, lignesLues, lignesMois, lignesSansDate }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status: BudgetStatus = {
|
||||||
|
cumulEur,
|
||||||
|
blocked: cumulEur >= BUDGET_MAX_EUR,
|
||||||
|
warning: cumulEur >= BUDGET_WARN_EUR,
|
||||||
|
verified: true,
|
||||||
|
lignesLues,
|
||||||
|
lignesMois,
|
||||||
|
lignesSansDate,
|
||||||
|
}
|
||||||
|
const mm = String(mois + 1).padStart(2, '0')
|
||||||
|
const suffixe = status.blocked ? ' — BLOQUÉ' : status.warning ? ' — alerte' : ''
|
||||||
|
const sansDate = lignesSansDate ? `, ${lignesSansDate} sans date ignorées` : ''
|
||||||
|
console.info(
|
||||||
|
`[circuitBreaker] budget lu : ${cumulEur.toFixed(4)} € / ${BUDGET_MAX_EUR} € ` +
|
||||||
|
`(${lignesMois} lignes ${annee}-${mm} sur ${lignesLues} lues${sansDate})${suffixe}`,
|
||||||
|
)
|
||||||
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user