Sans réponse partielle de batch, un seul enregistrement en échec suffit pour que Lambda renvoie le lot SQS entier en file : neuf messages traités avec succès sont relivrés en même temps que celui qui a réellement échoué. Le correctif comporte deux moitiés obligatoires : déclarer FunctionResponseTypes à ReportBatchItemFailures sur l'event source mapping, et retourner { batchItemFailures } depuis le handler en listant uniquement les enregistrements en échec. Le format de réponse échoue fermé : ses cas limites méritent autant d'attention que le chemin nominal.
Que se passe-t-il par défaut quand un enregistrement du lot échoue ?
Par défaut, l'event source mapping traite le lot comme une unité indivisible : si le handler lève une exception pour un seul enregistrement, Lambda considère l'invocation entière en échec et laisse tous les messages en file, y compris ceux déjà traités avec succès. Après le visibility timeout, tous reviennent et vos effets de bord s'exécutent deux fois.
C'est ainsi que naissent les emails en double, les débits en double et les lignes dupliquées. Cela pollue aussi la dead-letter queue : des messages sains accumulent des réceptions à chaque passage aux côtés d'un message empoisonné, et finissent par dépasser maxReceiveCount pour un échec qui n'a jamais été le leur. Si vous vous demandez encore si une file a sa place dans l'architecture, commencez par notre article sur quand utiliser SQS ; la suite suppose la file déjà en place.
| Par défaut (tout ou rien) | ReportBatchItemFailures | |
|---|---|---|
| Travail dupliqué | Chaque enregistrement réussi d'un lot en échec est retraité | Seuls les échecs signalés retournent en file |
| Bruit dans la DLQ | Des messages sains accumulent des réceptions et peuvent finir en DLQ | Seuls les messages réellement en échec approchent maxReceiveCount |
| Code nécessaire | Aucun | try/catch par enregistrement plus une réponse batchItemFailures |
| Comportement FIFO | Lot entier retenté, ordre préservé par force brute | Vous devez échouer chaque enregistrement après le premier échec |
Comment la réponse partielle change-t-elle la suppression des messages ?
Avec ReportBatchItemFailures actif, Lambda supprime tous les messages du lot sauf ceux listés dans la réponse. Les enregistrements traités quittent la file définitivement ; seuls les identifiants signalés redeviennent visibles après le visibility timeout, chacun avec son propre compteur de réceptions incrémenté. L'unité d'échec passe du lot à l'enregistrement.
La réponse est un contrat entre votre code et l'event source mapping, documenté dans le guide Lambda sur la gestion des erreurs SQS. Votre handler n'appelle jamais DeleteMessage lui-même : le mapping supprime pour vous en fonction de ce que vous retournez. Ne signalez rien et tout est supprimé ; signalez deux identifiants et huit messages sont supprimés pendant que deux repartent en retry.
Comment activer ReportBatchItemFailures ?
Deux changements, tous deux obligatoires. D'abord, déclarer ReportBatchItemFailures dans les FunctionResponseTypes de l'event source mapping. Ensuite, retourner depuis le handler un objet de la forme { batchItemFailures: [{ itemIdentifier: messageId }] }. Le réglage sans la réponse ne change rien ; la réponse sans le réglage est ignorée en silence.
// CDK : activer les réponses partielles de batch sur le mapping
import { SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
handler.addEventSource(
new SqsEventSource(ordersQueue, {
batchSize: 10,
reportBatchItemFailures: true, // définit FunctionResponseTypes sur le mapping
})
);
En SAM ou CloudFormation, l'équivalent est FunctionResponseTypes: [ReportBatchItemFailures] sur la ressource d'event source. Quel que soit l'outil, auditez chaque fonction déclenchée par SQS : d'expérience, ce drapeau est la ligne la plus souvent absente de bases de code serverless par ailleurs solides.
Des effets de bord dupliqués sortent de vos consommateurs SQS ? Décrivez votre système : diagnostic d'une page sous 48 h.
Recevoir mon diagnostic →À quoi ressemble un handler correct ?
Encapsulez chaque enregistrement dans son propre try/catch et collectez le messageId des échecs. Ne laissez jamais une exception sortir du handler : une erreur non rattrapée fait échouer le lot entier, exactement le comportement que vous cherchez à éviter. Un tableau batchItemFailures vide signifie succès complet, le retourner systématiquement en fin de handler est donc correct.
import type { SQSHandler, SQSBatchItemFailure } from "aws-lambda";
export const handler: SQSHandler = async (event) => {
const batchItemFailures: SQSBatchItemFailure[] = [];
for (const record of event.Records) {
try {
await processOrder(JSON.parse(record.body));
} catch (err) {
// Loguer le messageId : c'est la seule clé pour tracer le retry
console.error("record failed", record.messageId, err);
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
// Tableau vide signifie succès complet : tous les messages sont supprimés
return { batchItemFailures };
};
La boucle reste séquentielle pour la lisibilité ; avec Promise.allSettled vous pouvez paralléliser, tant que chaque rejet est rattaché à son messageId.
Quelles erreurs font retenter tout le lot malgré tout ?
Le contrat échoue fermé. Une réponse malformée, un itemIdentifier qui ne correspond à aucun messageId du lot, un identifiant vide ou une exception qui s'échappe du handler : chaque cas compte comme un échec total et Lambda renvoie tous les messages en file. Échouer fermé est le bon défaut, car l'inverse supprimerait en silence des messages non traités.
Conséquence pratique : testez volontairement le chemin malformé. Un test unitaire doit alimenter le handler avec un enregistrement en échec et vérifier la forme JSON exacte de la réponse ; un second doit confirmer qu'une coquille comme itemIdentifer serait attrapée par vos types ou vos assertions. Ce contrat se découvre trop souvent en production, en cherchant pourquoi les retries se multiplient au lieu de diminuer.
Pourquoi l'idempotence reste-t-elle obligatoire ?
Les réponses partielles réduisent les doublons, elles ne les éliminent pas. La livraison SQS standard reste at-least-once : un timeout de fonction après un effet de bord réussi, un visibility timeout qui expire en plein traitement ou un crash avant le retour de la réponse relivreront des messages pourtant traités.
Chaque effet de bord a donc besoin d'une clé d'idempotence, vérifiée avant l'écriture. Nous avons détaillé le raisonnement de déduplication côté consommateur dans notre article EventBridge vers SQS vers Lambda, et il s'applique ici tel quel : la file garantit la livraison, le consommateur garantit l'effet exactement une fois. ReportBatchItemFailures rétrécit la fenêtre de duplication ; l'idempotence la ferme.
Qu'est-ce qui change avec les files FIFO ?
Sur une file FIFO, l'ordre au sein d'un message group doit survivre au retry. Quand un enregistrement échoue, vous devez le signaler en échec ainsi que tous les suivants du lot, même ceux qui auraient réussi : supprimer le message quatre pendant que le trois repart en file réordonnerait le groupe.
for (const [i, record] of event.Records.entries()) {
try {
await process(record);
} catch {
// FIFO : échouer cet enregistrement et tous les suivants pour préserver l'ordre
return {
batchItemFailures: event.Records.slice(i).map((r) => ({
itemIdentifier: r.messageId,
})),
};
}
}
return { batchItemFailures: [] };
Les enregistrements traités avant l'échec sont supprimés normalement ; la queue du lot repart dans l'ordre. C'est aussi pourquoi nous gardons des tailles de batch réduites en FIFO : plus la traîne est longue, plus un seul échec jette de travail.
Quelle interaction avec maxReceiveCount et la DLQ ?
Le compteur de réceptions est suivi par message : avec les réponses partielles, seuls les messages réellement en échec avancent vers maxReceiveCount et la dead-letter queue. La DLQ retrouve enfin son sens : des messages qui ont échoué pour leurs propres raisons, pas des passagers emportés avec un message empoisonné.
Dimensionnez maxReceiveCount pour absorber une panne transitoire en aval (nous descendons rarement sous cinq tentatives) et posez une alarme sur la profondeur de la DLQ. Côté observabilité, émettez une métrique par invocation : le ratio entre la taille de batchItemFailures et celle du lot. Un ratio qui monte depuis presque zéro signale une dépendance qui se dégrade bien avant que la DLQ ne se remplisse, et il distingue un message empoisonné isolé (ratio bas et stable) d'une panne systémique (ratio proche de un).
Check-list de mise en production
FunctionResponseTypescontientReportBatchItemFailuressur chaque event source mapping SQS.- try/catch par enregistrement : aucune exception ne peut sortir du handler.
- Tests unitaires sur la réponse malformée et l'identifiant inconnu (attendre un retry du lot complet).
- Chaque effet de bord porte une clé d'idempotence.
- Les handlers FIFO échouent chaque enregistrement à partir du premier échec.
maxReceiveCountdimensionné pour les pannes transitoires, alarme sur la profondeur de la DLQ.- Ratio d'échecs partiels émis en métrique et visible sur un dashboard.