Gestion d'erreurs et fallbacks
Gestion d’erreurs et fallbacks
Le function calling introduit une difficulté que le code classique ne connaît pas : quand votre fonction échoue, il y a deux destinataires de l’erreur. Votre application, comme d’habitude — et le modèle, qui attend un résultat pour poursuivre. L’oublier produit le symptôme le plus déroutant de la mise en production : une conversation qui s’interrompt sans explication, parce qu’une exception a été avalée côté serveur et que le modèle attend toujours.
La règle qui découle de là structure toute la leçon : renvoyez toujours un function_call_output, même en cas d’échec. Un modèle informé qu’une API est indisponible peut proposer une alternative, demander une précision ou expliquer la situation à l’utilisateur. Un modèle qui ne reçoit rien ne peut rien faire.
Renvoyer les erreurs au modèle
La forme de l’erreur compte autant que le fait de la renvoyer. « Erreur » tout court n’apprend rien au modèle ; un objet JSON qui distingue le type d’erreur, sa nature transitoire ou définitive et la marche à suivre lui permet de décider. C’est un point contre-intuitif : on écrit ici des messages d’erreur destinés à être lus par une machine qui raisonne, pas journalisés.
import json
from openai import OpenAI
client = OpenAI()
def executer_fonction(name: str, arguments: str, call_id: str) -> dict:
try:
args = json.loads(arguments)
resultat = dispatcher[name](**args)
return {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps(resultat)
}
except KeyError:
return {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps({
"error": "fonction_inconnue",
"message": f"La fonction '{name}' n'existe pas"
})
}
except json.JSONDecodeError:
return {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps({
"error": "arguments_invalides",
"message": "Les arguments JSON sont malformes"
})
}
except Exception as e:
return {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps({
"error": "erreur_interne",
"message": str(e)
})
}
Retry avec backoff exponentiel
Le retry ne vaut que pour ce qui a une chance d’aboutir au deuxième essai : coupure réseau, 429, indisponibilité passagère. Réessayer une requête refusée pour authentification invalide ou paramètre incorrect ne fait que retarder l’échec en multipliant les appels. D’où le tri explicite dans le code ci-dessous — c’est lui qui fait le travail, le backoff n’étant que la politesse qu’on doit au service d’en face.
import time
import random
def executer_avec_retry(func, args, max_retries=3, base_delay=1.0):
"""Retry avec backoff exponentiel et jitter."""
for tentative in range(max_retries + 1):
try:
return func(**args)
except (ConnectionError, TimeoutError) as e:
if tentative == max_retries:
raise
delay = base_delay * (2 ** tentative) + random.uniform(0, 0.5)
time.sleep(delay)
except ValueError:
# Erreur de données : pas de retry
raise
def executer_fonction_robuste(name, arguments, call_id):
args = json.loads(arguments)
try:
resultat = executer_avec_retry(dispatcher[name], args)
return {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps(resultat)
}
except Exception as e:
return {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps({
"error": type(e).__name__,
"message": str(e),
"retries_effectues": 3
})
}
Timeouts par fonction
Un timeout unique pour toutes les fonctions est toujours mal réglé : trop court, il tue les traitements lourds ; trop long, il fait attendre l’utilisateur sur une simple consultation de cache. Calez chaque valeur sur la durée observée en conditions normales, avec une marge — et non sur le pire cas imaginable, qui reviendrait à n’avoir aucun timeout.
import signal
from functools import wraps
TIMEOUTS = {
"rechercher_client": 5,
"generer_rapport": 30,
"envoyer_email": 10,
}
def avec_timeout(func, timeout_seconds):
"""Exécute une fonction avec un timeout."""
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(func)
try:
return future.result(timeout=timeout_seconds)
except concurrent.futures.TimeoutError:
raise TimeoutError(
f"Fonction interrompue apres {timeout_seconds}s"
)
def executer_avec_timeout(name, arguments, call_id):
args = json.loads(arguments)
timeout = TIMEOUTS.get(name, 10) # 10s par defaut
try:
resultat = avec_timeout(
lambda: dispatcher[name](**args),
timeout
)
return {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps(resultat)
}
except TimeoutError as e:
return {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps({
"error": "timeout",
"message": str(e),
"suggestion": "Réessayez avec des critères plus précis"
})
}
Fallback entre fonctions
Le fallback est un arbitrage, pas un réflexe : servir une donnée de cache vieille de six heures vaut mieux que rien pour un tableau de bord, et beaucoup moins bien que rien pour un solde bancaire. Quand vous dégradez, dites-le — dans le résultat renvoyé au modèle, pour qu’il puisse en avertir l’utilisateur au lieu de présenter une donnée de secours comme une donnée fraîche.
async def rechercher_produit(terme: str) -> dict:
"""Recherche avec fallback : API principale -> cache -> base locale."""
# Tentative 1 : API catalogue
try:
return await api_catalogue.rechercher(terme)
except Exception:
pass
# Tentative 2 : cache Redis
try:
cached = await redis_client.get(f"produit:{terme}")
if cached:
resultat = json.loads(cached)
resultat["_source"] = "cache"
resultat["_avertissement"] = "Données potentiellement obsoletes"
return resultat
except Exception:
pass
# Tentative 3 : base locale SQLite
try:
row = db.execute(
"SELECT * FROM produits WHERE nom LIKE ?",
(f"%{terme}%",)
).fetchone()
if row:
return {
"nom": row["nom"],
"prix": row["prix"],
"_source": "base_locale",
"_avertissement": "Données de la dernière synchronisation"
}
except Exception:
pass
return {
"error": "indisponible",
"message": "Aucune source de données accessible pour cette recherche"
}
Circuit breaker
Le circuit breaker répond à ce que le retry ne sait pas traiter : un service durablement hors service. Après N échecs, il coupe et renvoie l’erreur immédiatement, sans même essayer, puis laisse repasser une requête de temps en temps pour tester le retour à la normale. Le gain est double — vos utilisateurs cessent d’attendre un timeout à chaque requête, et le service en difficulté n’est pas maintenu à terre par vos réessais.
from datetime import datetime, timedelta
class CircuitBreaker:
def __init__(self, seuil_echecs=5, duree_ouverture=60):
self.seuil = seuil_echecs
self.duree = duree_ouverture
self.echecs = 0
self.dernier_echec = None
self.etat = "ferme" # ferme, ouvert, semi-ouvert
def peut_executer(self) -> bool:
if self.etat == "ferme":
return True
if self.etat == "ouvert":
if datetime.now() - self.dernier_echec > timedelta(seconds=self.duree):
self.etat = "semi-ouvert"
return True
return False
return True # semi-ouvert : laisser passer un essai
def enregistrer_succes(self):
self.echecs = 0
self.etat = "ferme"
def enregistrer_echec(self):
self.echecs += 1
self.dernier_echec = datetime.now()
if self.echecs >= self.seuil:
self.etat = "ouvert"
# Un circuit breaker par service externe
breakers = {
"api_meteo": CircuitBreaker(seuil_echecs=3, duree_ouverture=30),
"api_catalogue": CircuitBreaker(seuil_echecs=5, duree_ouverture=60),
}
Boucle robuste complète
Le pattern complet rassemble les cinq mécanismes précédents. Une précaution le rend réellement sûr, et elle est facile à omettre : borner le nombre d’itérations. Un modèle qui rappelle en boucle une fonction dont le résultat ne le satisfait pas est rare, mais sans plafond il consomme le budget jusqu’à épuisement — et c’est le genre d’incident qu’on découvre sur la facture.
def boucle_robuste(prompt, tools, max_tours=10):
messages = prompt
for tour in range(max_tours):
response = client.responses.create(
model="gpt-5.6-terra",
input=messages,
tools=tools
)
appels = [i for i in response.output if i.type == "function_call"]
if not appels:
return response.output_text
resultats = []
for appel in appels:
resultat = executer_fonction_robuste(
appel.name, appel.arguments, appel.call_id
)
resultats.append(resultat)
messages = response.output + resultats
return "Workflow interrompu : limite de tours atteinte"
Points clés à retenir
- Renvoyez toujours un
function_call_output, même en cas d’erreur - Structurez les erreurs en JSON pour que le modèle les exploite
- Retentez uniquement les erreurs transitoires avec backoff exponentiel
- Implémentez des fallbacks entre sources de données
- Un circuit breaker protège les services défaillants