Gérer les erreurs
Pourquoi
Le 3 février, la mairie reçoit comme chaque matin son fichier signalements-2026-02-03.csv.gz. Il pèse zéro octet. Personne ne s'en aperçoit avant le 12, quand le service voirie s'étonne de ne plus voir de nids-de-poule dans son tableau de bord. Côté Signalements, tout était vert : le minuteur signalements-publication.timer affichait chaque nuit un service terminé avec succès.
Voici le script de Camille, tel qu'il tournait alors :
#!/bin/bash
cd /srv/donnees/exports
fichier=signalements-$(date +%F).csv
gzip -c $fichier > $fichier.gz
sha256sum $fichier.gz > $fichier.gz.sha256
aws s3 cp $fichier.gz s3://sig-exports-mairie/
aws s3 cp $fichier.gz.sha256 s3://sig-exports-mairie/
echo "export publié"Cette nuit-là, l'export Python n'a pas pu joindre sig-db et n'a produit aucun CSV. La suite est mécanique. gzip échoue, mais la redirection > $fichier.gz a déjà créé un fichier vide (elle est faite par le shell avant de lancer gzip, comme l'a montré la leçon 6 de Premiers pas). sha256sum calcule tranquillement l'empreinte de ce fichier vide. aws dépose les deux fichiers sans broncher, puisqu'ils existent. Le script affiche « export publié » et se termine avec le code de sa dernière commande, echo, c'est-à-dire 0. systemd, qui ne juge un service que sur son code de sortie, conclut au succès.
La leçon 4 a ajouté la vérification qui manquait sur l'entrée : le CSV existe, n'est pas vide, a le bon en-tête. Elle aurait évité l'incident du 3 février. Mais elle ne couvre que ce que l'on a pensé à vérifier. Le disque /srv/donnees peut se remplir pendant la compression et laisser une archive tronquée ; la clé d'API de l'Object Storage peut expirer ; un cd peut échouer et laisser le script travailler dans le mauvais répertoire. Un script de production a besoin de deux choses complémentaires :
- une règle par défaut : toute commande qui échoue sans que le script l'ait prévu arrête le script, avec un code non nul et un message qui dit où ;
- des vérifications explicites là où l'échec est attendu, ou là où l'outil ne signale pas l'échec par son code de sortie.
Bash fournit la première avec set -e, set -u, pipefail et trap ERR. Ces options ont une réputation contrastée, et elle est méritée : leurs règles sont pleines d'exceptions, que cette leçon établit une par une, essais à l'appui. Une fois ces exceptions connues, on peut s'en servir comme d'un filet de sécurité, sans croire qu'il remplace la vérification.
Les concepts
Tout repose sur le code de sortie
Rappel de la leçon 6 de Premiers pas et de la leçon 4 : chaque commande se termine par un code de sortie entre 0 et 255, 0 pour le succès, toute autre valeur pour un échec. Le code d'un tube est celui de sa dernière commande ; celui d'une fonction, celui de la dernière commande qu'elle a exécutée (ou la valeur de return) ; celui d'un script, celui de la dernière commande exécutée (ou la valeur de exit).
Cette dernière règle est celle qui a trahi Camille : un script sans gestion d'erreur renvoie le code de sa dernière ligne, et tout ce qui précède peut avoir échoué sans laisser de trace dans ce code.
Deux manières de réagir à un échec
On peut organiser la gestion des erreurs d'un script de deux façons :
- Vérifier explicitement chaque commande qui peut échouer :
cd -- "$REPERTOIRE_EXPORTS" || mourir "...",if ! gzip ...; then .... C'est précis, chaque échec reçoit son message et son code, mais il suffit d'un oubli pour retrouver le comportement du script de Camille. - Demander au shell de s'arrêter dès qu'une commande échoue : c'est l'option
errexit, activée parset -e. Aucun oubli possible... sauf que le shell ne sait pas quels échecs sont graves, et qu'il ne s'arrête pas dans un nombre surprenant de situations.
La pratique solide combine les deux : set -e comme filet, pour les échecs que l'on n'a pas imaginés, et des vérifications explicites pour tous ceux que l'on a imaginés. Le débat est ancien. La page BashFAQ/105 du wiki de Greg Wooledge (Greg's Wiki, référence communautaire sur Bash) rapporte les avis tranchés de plusieurs habitués : certains déconseillent set -e et ne jurent que par la vérification explicite, d'autres l'utilisent en connaissant ses pièges. Ce cours prend la seconde position, et la leçon donne les moyens de connaître les pièges.
set -e : la règle
D'après le manuel de Bash, avec -e (nom long errexit), le shell se termine immédiatement si un tube (éventuellement réduit à une seule commande), une liste ou une commande composée se termine avec un code non nul. Le code de sortie du shell est alors celui de la commande qui a échoué. Si un trap sur ERR est défini (on y vient), il est exécuté juste avant.
L'option existe depuis le Bourne shell et la norme POSIX la définit ; ce n'est pas une invention de Bash. Elle s'active de trois façons équivalentes : set -e, set -o errexit, ou l'option -e sur la ligne #! (#!/bin/bash -e), cette dernière étant à éviter parce qu'elle disparaît dès que quelqu'un lance le script par bash script.
Les exceptions : le contexte de condition
Le manuel énumère ensuite les cas où le shell ne se termine pas, même si la commande échoue :
- la commande fait partie de la condition d'un
whileou d'ununtil; - elle fait partie du test d'un
ifou d'unelif; - elle fait partie d'une liste
&&ou||, sauf la commande qui suit le dernier&&ou||; - elle est dans un tube, mais pas en dernière position ;
- son code est inversé par
!.
Ces exceptions ont une logique commune : dans chacun de ces cas, le code de sortie est consommé par le script, qui en fait une décision. if grep -q motif fichier ne doit évidemment pas arrêter le script quand grep ne trouve rien : l'échec est la réponse attendue à une question. On parlera de contexte de condition pour désigner une position où le code de sortie est consommé.
Le manuel ajoute deux phrases dont les conséquences sont considérables. Quand une commande composée ou une fonction s'exécute dans un contexte où -e est ignoré, « none of the commands executed within the compound command or function body will be affected by the -e setting » : aucune des commandes de son corps n'est concernée par -e. Et si la fonction réactive elle-même set -e, le réglage n'a pas d'effet avant la fin de la commande qui contient l'appel.
Autrement dit, le contexte de condition est contagieux : une fonction appelée dans un if, ou à gauche d'un ||, s'exécute entièrement sans errexit. Une commande qui échoue au milieu de la fonction ne l'arrête pas ; la fonction continue jusqu'à sa dernière ligne, et c'est le code de cette dernière ligne que voit le if. C'est le piège le plus coûteux de set -e, démontré plus bas.
Le tableau des cas, vérifié
Chaque ligne ci-dessous a été exécutée avec Bash 5.2.21 dans un shell neuf, sous la forme bash -c 'set -e; <cas>; echo survécu'. « Arrêt » signifie que survécu ne s'affiche pas.
| Cas | Résultat | Pourquoi |
|---|---|---|
false | arrêt, code 1 | la règle |
if false; then :; fi | continue | test d'un if |
false && echo x | continue | pas la dernière commande de la liste |
true && false | arrêt, code 1 | dernière commande de la liste |
false || true | continue | rattrapé |
! true | continue | code inversé (et jamais d'arrêt avec !, quel que soit le résultat) |
false | true | continue | seul le dernier élément du tube compte, sauf avec pipefail |
set -o pipefail; false | true | arrêt, code 1 | voir pipefail |
f() { false; echo dans-f; }; f | arrêt dans f | la fonction hérite de errexit |
même f, appelée par if f; then | dans-f puis continue | contexte de condition contagieux |
même f, appelée par f || echo rattrapé | dans-f puis continue | idem, et rattrapé ne s'affiche pas, puisque f a fini par echo |
x=$(false) | arrêt, code 1 | une affectation prend le code de la substitution |
echo "valeur : $(false)" | continue | le code de echo est 0, la substitution est perdue |
local x=$(false) (dans une fonction) | continue | le code est celui de local, qui a réussi |
export x=$(false), declare x=$(false), readonly x=$(false) | continue | même raison |
x=$(false; echo bonjour) | continue, x=bonjour | la substitution ne hérite pas de errexit |
même chose après shopt -s inherit_errexit | arrêt | la substitution hérite de errexit |
x=$(false; echo suite) || echo rattrapé, avec inherit_errexit | continue, x=suite | la substitution est en contexte de condition |
cat <(false) | continue | le code d'une substitution de processus est ignoré |
( false; echo dans-sous-shell ) | arrêt, code 1 | le sous-shell s'arrête, puis le parent sur son code |
( false; echo dans-sous-shell ) || true | dans-sous-shell puis continue | contexte de condition contagieux |
i=0; (( i++ )) | arrêt, code 1 | (( )) vaut 1 quand l'expression vaut 0, et i++ vaut l'ancienne valeur |
i=0; let i++ | arrêt, code 1 | même raison |
i=0; (( ++i )) | continue | l'expression vaut 1 |
i=0; i=$(( i + 1 )) | continue | une affectation simple réussit |
g() { test -f absent && echo x; }; g | arrêt à l'appel de g | g renvoie le code de sa dernière commande, la liste, soit 1 |
x=$(( 08 - 1 )); echo "x=[$x]" | message d'erreur, puis continue à la ligne suivante | erreur d'expansion arithmétique : Bash abandonne la commande en cours, sans errexit ni trap ERR |
f() { n=$(( 09 )); echo dans-f; }; f | message, dans-f n'apparaît pas, puis continue | tout l'appel de f est abandonné, et le script poursuit après lui |
(( 08 > 1 )) | arrêt, code 1 | la commande arithmétique échoue normalement, errexit s'applique |
readonly r=1; if r=2; then :; fi | arrêt, code 1 | une affectation refusée arrête le script, même dans un if ou à gauche de || |
Deux lignes méritent une explication immédiate.
La substitution de commande. Une substitution de commande (command substitution, $(...)) s'exécute dans un sous-shell, un processus enfant qui est une copie du shell. Par défaut, en dehors du mode POSIX, Bash désactive errexit dans ce sous-shell. L'option shopt -s inherit_errexit, apparue avec Bash 4.4, change ce comportement : d'après le manuel, la substitution de commande hérite alors de la valeur de errexit au lieu de la désactiver. En mode POSIX (set -o posix), elle est activée d'office.
Les erreurs d'expansion arithmétique. Ce sont les seules lignes du tableau où le script ne s'arrête pas alors que Bash a signalé une erreur. Un nombre écrit avec un zéro en tête est lu en octal (08 et 09 sont donc invalides, leçon 3), une division par zéro, une expression mal formée : dans une expansion $(( )), Bash affiche le message, abandonne la commande de premier niveau en cours (la ligne, ou tout l'appel de fonction qui la contient), positionne $? à 1 et passe à la commande suivante. Ni set -e ni le trap ERR ne réagissent, puisque aucune commande ne s'est terminée en échec : elle n'a pas eu lieu. La variable affectée reste vide ou garde son ancienne valeur. Seul le mode POSIX (bash --posix, ou sh pointant vers Bash) fait de ces erreurs d'expansion des erreurs fatales, d'après la description de l'option compat43 dans la page de manuel. La parade est de ne jamais confier à l'arithmétique une valeur non validée : [[ $n =~ ^[0-9]+$ ]], puis 10#$n pour neutraliser le zéro en tête. La commande (( )) et les comparaisons -gt de [[ ]], elles, échouent normalement avec le code 1 et déclenchent errexit.
Les compteurs. La commande arithmétique (( expression )) renvoie 0 si l'expression est non nulle et 1 si elle est nulle. (( i++ )) évalue i avant de l'incrémenter : la première fois, l'expression vaut 0, donc le code est 1, donc set -e arrête le script. Les réponses aux exercices de BashFAQ/105 notent que ce comportement a changé avec Bash 4.1 : jusqu'à la 4.0, (( )) n'était pas concerné par set -e, et un script qui fonctionnait a cessé de fonctionner après une mise à jour de Bash. Écrivez (( ++i )) ou i=$(( i + 1 )).
set -u : les variables non définies
Avec -u (nom long nounset), le développement d'une variable non définie est une erreur : Bash écrit un message sur la sortie d'erreur et, dans un shell non interactif, s'arrête. Les paramètres spéciaux @ et * font exception. Le manuel le précise, et Bash 5.2 traite aussi sans erreur un tableau vide développé par "${t[@]}" (voir la leçon 7).
Cette option attrape les fautes de frappe ($destnation au lieu de $destination) et les paramètres absents ($1 quand le script est lancé sans argument). Elle n'a pas d'exception de contexte : l'erreur arrête le script même dans un if ou à gauche d'un ||. La forme ${variable:-défaut} de la leçon 3 permet de lire une variable facultative sans déclencher l'erreur.
pipefail : les échecs au milieu d'un tube
Par défaut, le code d'un tube est celui de sa dernière commande ; set -e ne voit donc pas l'échec de tail dans tail -n +2 fichier | wc -l. Avec set -o pipefail, le code du tube devient celui de la commande la plus à droite qui a échoué, ou 0 si toutes ont réussi. L'option, longtemps propre à Bash et à quelques shells, est entrée dans la norme POSIX en 2024 ; dash 0.5.12 (Debian 13) ne l'offre pas encore, autre raison de ne pas lancer un script Bash par sh (leçon 1).
Son revers tient au signal SIGPIPE (signal 13). Quand le lecteur d'un tube se termine avant l'écrivain, l'écrivain reçoit SIGPIPE à sa prochaine écriture et meurt, avec le code 141 (128 + 13). Sans pipefail, personne ne le remarque : c'est la manière normale dont head interrompt une commande bavarde. Avec pipefail, ce tube échoue, et avec set -e, le script s'arrête. Pire : cela dépend de la quantité de données. Si l'écrivain a fini d'écrire avant que le lecteur ne parte (tout tenait dans le tampon du tube, 64 Kio par défaut sous Linux), il n'y a pas de SIGPIPE. Le script passe les tests avec un petit fichier et échoue en production avec un gros.
trap ERR : savoir où et pourquoi
set -e arrête le script, mais n'explique rien : on voit au mieux le message de l'outil qui a échoué, sans savoir de quelle ligne du script il venait. La commande interne trap associe une commande à un événement. Le pseudo-signal ERR est déclenché, d'après le manuel, chaque fois qu'un tube, une liste ou une commande composée renvoie un code non nul, avec exactement les mêmes exceptions que errexit. Le gestionnaire s'exécute juste avant l'arrêt dû à set -e ; il peut aussi être utilisé sans set -e, pour journaliser chaque échec non rattrapé.
Dans le gestionnaire, plusieurs variables de Bash décrivent l'échec :
| Variable | Contenu |
|---|---|
$? | le code de la commande qui a échoué (à lire en tout premier) |
LINENO | la ligne en cours ; dans le texte du trap, celle de la commande qui a échoué |
BASH_COMMAND | le texte de la commande en cours d'exécution au moment du trap, avant développement des variables |
FUNCNAME | tableau des fonctions en cours d'appel ; l'indice 0 est la fonction courante, le dernier vaut main |
BASH_LINENO | tableau : BASH_LINENO[i] est la ligne d'où FUNCNAME[i] a été appelée |
BASH_SOURCE | tableau : le fichier où chaque fonction de FUNCNAME est définie |
BASH_SUBSHELL | 0 dans le shell principal, augmenté de 1 dans chaque sous-shell |
Une précision du manuel, essentielle : le trap ERR n'est pas hérité par les fonctions, les substitutions de commande et les sous-shells, sauf si l'option -E (nom long errtrace) est active. Sans -E et sans -e, un échec à l'intérieur d'une fonction n'est vu par le trap que si la fonction elle-même finit par renvoyer un code non nul, et l'on perd la ligne exacte. Sans -E mais avec -e, c'est pire : set -e termine le shell dans la fonction, où le trap n'existe pas, et le gestionnaire ne s'exécute jamais.
L'en-tête « mode strict »
On rencontre souvent, sous le nom de « mode strict », l'en-tête suivant, que ce cours adopte pour tous les scripts de signalements-outils :
set -Eeuo pipefail
shopt -s inherit_errexit-E: le trapERRest hérité par les fonctions et les sous-shells ;-e: arrêt sur échec non rattrapé ;-u: arrêt sur variable non définie ;-o pipefail: un échec au milieu d'un tube compte ;inherit_errexit: les substitutions de commande s'arrêtent aussi sur échec.
Ce n'est pas une garantie : c'est un filet, troué aux endroits du tableau ci-dessus. BashFAQ/105 résume l'objection : pour s'en servir correctement, il faut trouver tous les faux positifs (les échecs normaux, comme grep qui ne trouve rien) et les marquer comme autorisés, et connaître tous les faux négatifs. C'est précisément le travail de la suite.
En pratique
Les essais se font dans un répertoire jetable, ~/essais-erreurs, avec Bash 5.2 et LC_ALL=C pour obtenir les messages en anglais tels qu'ils apparaissent dans les journaux. Les extraits de publier-export se placent dans le dépôt signalements-outils.
Reproduire l'incident de Camille
Sans CSV, la redirection crée quand même l'archive :
$ mkdir -p ~/essais-erreurs/exports && cd ~/essais-erreurs/exports
$ gzip -c -- signalements-2026-02-03.csv > signalements-2026-02-03.csv.gz
gzip: signalements-2026-02-03.csv: No such file or directory
$ echo $?
1
$ stat -c %s signalements-2026-02-03.csv.gz
0
$ sha256sum signalements-2026-02-03.csv.gz
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 signalements-2026-02-03.csv.gz
L'empreinte e3b0c442...b855 est celle de la chaîne vide : retenez-la, elle signale un fichier vide partout où l'on rencontre du SHA-256. Le script de Camille, lancé dans ce répertoire avec une doublure de aws qui se contente de réussir, affiche :
$ bash camille.sh; echo "code $?"
gzip: signalements-2026-10-08.csv: No such file or directory
export publié
code 0
Le même script lancé par bash -e camille.sh s'arrête juste après gzip, avec le code 1, sans déposer. C'est déjà mieux, mais une archive vide reste sur le disque, et rien ne dit à quelle ligne le script s'est arrêté.
set -e, et le piège de la fonction dans un if
Écrivons une fonction de préparation, et appelons-la de deux façons :
#!/usr/bin/env bash
set -e
preparer() {
cp -- "$1" "$1.bak"
echo "copie faite"
}
if preparer absent.csv; then
echo "préparation réussie"
fi
preparer absent.csv
echo "jamais affiché"$ bash conditions.sh; echo "code $?"
cp: cannot stat 'absent.csv': No such file or directory
copie faite
préparation réussie
cp: cannot stat 'absent.csv': No such file or directory
code 1
Appelée dans le if, la fonction a ignoré l'échec de cp, affiché « copie faite » et renvoyé 0 : le if a conclu au succès. Appelée seule, elle a arrêté le script au cp. Le même code se comporte différemment selon l'endroit d'où on l'appelle. Ajouter set -e à l'intérieur de la fonction n'y change rien, le manuel le précise : le réglage ne prend effet qu'à la fin de la commande qui contient l'appel.
La parade est de rendre la fonction correcte par elle-même, sans compter sur set -e, dès lors qu'elle est susceptible d'être appelée dans une condition :
preparer() {
cp -- "$1" "$1.bak" || return
echo "copie faite"
}return sans argument renvoie le code de la dernière commande exécutée, ici celui de cp. Une fonction dont chaque étape critique se termine par || return se comporte de la même façon dans tous les contextes. C'est la règle que suivra publier-export : toute fonction qui a plus d'une étape et peut servir de condition vérifie ses étapes elle-même.
La même contagion touche les substitutions de commande à gauche d'un ||. Avec inherit_errexit :
$ bash -c 'set -e; shopt -s inherit_errexit
f() { false; echo "suite de f"; }
x=$(f) || echo "rattrapé"
echo "x=[$x]"'
x=[suite de f]
Le || echo "rattrapé" semblait protéger l'affectation ; en réalité, il a placé toute la substitution en contexte de condition, donc f a ignoré l'échec de false, et « rattrapé » ne s'affiche même pas. La forme x=$(f) || mourir ... n'est sûre que si f vérifie elle-même ses étapes.
local qui avale l'échec
Comptons les lignes de données d'un CSV, d'abord de la façon la plus naturelle :
#!/usr/bin/env bash
set -e
compter() {
local n=$(tail -n +2 -- "$1" | wc -l)
echo "$n lignes"
}
compter absent.csv
set -o pipefail
compter absent.csv
compter2() {
local n
n=$(tail -n +2 -- "$1" | wc -l)
echo "$n lignes"
}
compter2 absent.csv
echo "jamais affiché"$ bash local.sh; echo "code $?"
tail: cannot open 'absent.csv' for reading: No such file or directory
0 lignes
tail: cannot open 'absent.csv' for reading: No such file or directory
0 lignes
tail: cannot open 'absent.csv' for reading: No such file or directory
code 1
Trois appels, trois enseignements :
- Sans
pipefail, le tube vaut le code dewc, 0 :0 lignes, aucun arrêt. - Avec
pipefail, le tube vaut 1... maislocal n=$(...)est une commandelocal, dont le code est celui delocallui-même, qui a parfaitement réussi à créer la variable. Le code de la substitution est perdu. - En séparant déclaration et affectation, l'affectation simple
n=$(...)prend le code de la substitution, etset -earrête le script.
ShellCheck signale la forme fautive sous le code SC2155, dont l'intitulé est sans ambiguïté : Declare and assign separately to avoid masking return values. La même règle vaut pour export, declare et readonly : readonly REPERTOIRE=$(...) s'écrit en deux lignes, REPERTOIRE=$(...) puis readonly REPERTOIRE.
Les compteurs qui tuent le script
rapport-journaux compte les erreurs 500 (leçon 7). Avec set -e, la première incrémentation l'arrête :
$ bash -c 'set -e; erreurs=0; (( erreurs++ )); echo "erreurs=$erreurs"'; echo "code $?"
code 1
Aucun message : (( )) n'écrit rien quand il « échoue ». C'est l'un des arrêts les plus déroutants à diagnostiquer sans trap ERR. Corrections équivalentes :
(( ++erreurs )) # l'expression vaut la nouvelle valeur, au moins 1
erreurs=$(( erreurs + 1 )) # une affectation, code 0
(( erreurs += 1 )) || true # si l'on tient à la forme, en l'assumantpipefail et le faux échec de SIGPIPE
Dans publier-export, on veut vérifier que l'archive produite commence bien par l'en-tête attendu. Avec un CSV de 60 000 lignes (2,5 Mo, 150 Ko compressés) :
#!/usr/bin/env bash
set -eo pipefail
entete=$(gzip -dc -- gros.csv.gz | head -n 1)
echo "entete=$entete"$ bash sigpipe.sh; echo "code $?"
code 141
Le script meurt sans aucun message avec le code 141. head a lu une ligne et s'est terminé ; gzip, qui avait encore 2,5 Mo à écrire, a reçu SIGPIPE ; pipefail a fait du tube un échec ; inherit_errexit n'est même pas en jeu, c'est l'affectation qui porte le code. Avec une archive de quelques centaines d'octets, le même script fonctionne, parce que gzip a fini d'écrire avant que head ne se termine. C'est exactement le scénario du test qui passe et de la production qui casse.
On le voit dans PIPESTATUS, lu dans la branche || (le tableau n'a pas encore été écrasé quand ses arguments sont développés) :
$ gzip -dc -- gros.csv.gz | head -n 1 > /dev/null || echo "PIPESTATUS=${PIPESTATUS[*]}"
PIPESTATUS=141 0
Trois façons de s'en sortir, de la plus simple à la plus explicite :
# 1. Ne pas faire de tube : read lit une ligne depuis une substitution de processus,
# dont le code est ignoré (gzip peut donc mourir de SIGPIPE sans conséquence).
IFS= read -r entete < <(gzip -dc -- "$archive")
# 2. Accepter explicitement le code 141 pour l'écrivain, et lui seul.
entete=$( { gzip -dc -- "$archive" || (( $? == 141 )); } | head -n 1 )
# 3. Ne pas interrompre l'écrivain : lire tout le flux (coûteux sur un gros fichier).
entete=$(gzip -dc -- "$archive" | sed -n 1p)La première forme a une contrepartie : une archive corrompue ne serait pas détectée par cette lecture, puisque le code de gzip est ignoré. On la combine donc avec un contrôle d'intégrité séparé, gzip -t, qui décompresse sans rien écrire et échoue sur une archive tronquée. Le même phénomène touche grep -q derrière une commande bavarde : grep -q s'arrête à la première correspondance.
$ bash -c 'set -o pipefail; seq 1 100000 | grep -q 5'; echo "code $?"
141
grep a trouvé 5 et s'est arrêté ; seq est mort de SIGPIPE. Ici, préférez grep -q 5 < fichier quand les données sont dans un fichier, ou testez la présence dans une variable.
set -u
$ printf 'set -u\necho "Publication du $jour"\necho après\n' > u.sh
$ bash u.sh; echo "code $?"
u.sh: line 2: jour: unbound variable
code 1
Le message donne le fichier, la ligne et le nom : c'est l'erreur la plus facile à diagnostiquer de toute la leçon. Pour les paramètres facultatifs, la valeur par défaut explicite :
date_export=${1:-$(date +%F)} # premier argument, ou la date du jour
destination=${PUBLIER_EXPORT_DESTINATION:-$destination}Attention : un set -u déclenché dans une substitution de commande n'arrête que le sous-shell. Sans set -e, le parent continue avec une valeur vide :
$ printf 'set -u\nx=$(echo "$absent")\necho "après x=[$x]"\n' > u2.sh
$ bash u2.sh; echo "code $?"
u2.sh: line 2: absent: unbound variable
après x=[]
code 0
C'est une raison de plus pour activer -e et -u ensemble.
Un trap ERR qui dit où
Voici un gestionnaire complet, sur un script réduit à deux fonctions :
#!/usr/bin/env bash
set -Eeuo pipefail
sur_erreur() {
local code=$1 ligne=$2 commande=$3
printf '%s : échec (code %d) ligne %d : %s\n' \
"${0##*/}" "$code" "$ligne" "$commande" >&2
local i
for (( i = 1; i < ${#FUNCNAME[@]} - 1; i++ )); do
printf ' dans %s(), appelée ligne %d\n' "${FUNCNAME[i]}" "${BASH_LINENO[i]}" >&2
done
exit 1
}
trap 'sur_erreur "$?" "$LINENO" "$BASH_COMMAND"' ERR
compresser() {
gzip -c -- "$1" > "$1.gz"
}
publier() {
compresser "$1"
echo "publié : $1.gz"
}
publier exports/signalements-2026-02-03.csv$ bash pile.sh; echo "code $?"
gzip: exports/signalements-2026-02-03.csv: No such file or directory
pile.sh : échec (code 1) ligne 17 : gzip -c -- "$1" > "$1.gz"
dans compresser(), appelée ligne 21
dans publier(), appelée ligne 25
code 1
Lisons ce qui s'est passé :
- La commande du trap est écrite entre apostrophes :
$?,$LINENOet$BASH_COMMANDsont développés au moment où le trap se déclenche, pas au moment où on le définit. Avec des guillemets doubles, ils seraient figés à la ligne 14. $?est passé en premier argument : toute commande exécutée dans le gestionnaire l'écraserait.- Dans
sur_erreur,FUNCNAME[0]estsur_erreurlui-même, et le dernier élément estmain: la boucle parcourt les indices intermédiaires.BASH_LINENO[i]donne la ligne d'oùFUNCNAME[i]a été appelée. BASH_COMMANDmontre le texte non développé :"$1"et non le chemin. C'est souvent plus lisible, et cela évite d'écrire dans le journal un secret contenu dans une variable.- Sans
-E, le trap ne serait pas hérité parcompresser:set -ey terminerait le shell sur l'échec degzipsans quesur_erreurne s'exécute. On ne verrait que le message degzipet le code 1.
Note
Le gestionnaire de démonstration termine par exit 1 (exit "$EX_ERREUR" dans publier-export), et non exit "$code" : un échec inattendu donne le code 1 (« erreur générale ») de la convention fixée en leçon 8. Laisser filer le code de l'outil (255 pour aws, 141 pour SIGPIPE) mélangerait les codes de publier-export avec ceux de ses dépendances, et la supervision ne saurait plus les interpréter.
Le trap et les sous-shells
Avec -E, le trap est aussi hérité par les substitutions de commande et les sous-shells. Quand une commande échoue dans $(...), le gestionnaire s'exécute dans le sous-shell, qui se termine ; puis l'affectation échoue dans le shell parent, et le gestionnaire s'exécute une seconde fois. Pour un tube à l'intérieur d'une substitution, on peut même obtenir trois messages. Le plus simple est de ne rien afficher dans un sous-shell et de laisser le parent parler :
sur_erreur() {
local code=$1 ligne=$2 commande=$3
# Dans un sous-shell, sortir sans rien dire : le shell parent signalera l'échec.
if (( BASH_SUBSHELL > 0 )); then
exit "$code"
fi
...
}On perd la ligne exacte de l'échec à l'intérieur de la substitution, mais le message de l'outil (tail: cannot open ...) la précède, et la ligne de l'affectation suffit à retrouver la fonction.
Autre conséquence, plus sournoise : mourir appelé dans une substitution de commande ne termine que le sous-shell. Son code (3, par exemple) devient le code de l'affectation dans le parent, qui déclenche le trap... et le trap sort avec 1. Le code de sortie documenté est perdu :
$ cat sub.sh
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'echo "ERR code=$? ligne=$LINENO : $BASH_COMMAND" >&2; exit 1' ERR
mourir() { local code=1; [[ $1 == -c ]] && { code=$2; shift 2; }; echo "erreur : $*" >&2; exit "$code"; }
lire_jour() { [[ $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || mourir -c 2 "date invalide : $1"; echo "$1"; }
jour=$(lire_jour "7 octobre")
echo "jour=$jour"
$ bash sub.sh; echo "code $?"
erreur : date invalide : 7 octobre
ERR code=2 ligne=6 : jour=$(lire_jour "7 octobre")
code 1
La règle qui en découle : une fonction dont on capture la sortie ne meurt pas, elle échoue, avec return, et c'est l'appelant, dans le shell principal, qui décide :
lire_jour() {
[[ $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || return 1
printf '%s\n' "$1"
}
date_export=$(lire_jour "$argument") || mourir -c "$EX_USAGE" "date invalide : $argument"Les outils qui ne disent pas qu'ils ont échoué
set -e et le trap ne voient que des codes de sortie. Plusieurs outils courants renvoient 0 alors que le travail n'a pas été fait, ou un code non nul alors que tout va bien. Il faut connaître ceux que l'on appelle :
| Outil | Comportement par défaut | Ce qu'il faut faire |
|---|---|---|
psql -f script.sql | continue après une erreur SQL et renvoie 0 | psql -X -v ON_ERROR_STOP=1 : arrêt à la première erreur, code 3 |
curl URL | une réponse HTTP 404 ou 500 est un transfert réussi, code 0 | curl --fail (code 22) ou --fail-with-body |
grep | 1 si rien n'est trouvé, 2 en cas d'erreur | distinguer 1 (réponse) de 2 (panne) |
diff, cmp | 1 si les fichiers diffèrent | idem : 1 est une réponse, 2 une panne |
aws s3 cp, aws s3 sync | 1 si un transfert a échoué ; 2 pour une ligne de commande impossible à analyser, mais aussi, pour les commandes s3, si des fichiers ont été ignorés (absents, illisibles) alors que les autres sont passés | traiter tout code non nul comme un échec pour une publication |
rsync | 24 si des fichiers source ont disparu pendant la copie | souvent toléré pour des journaux, jamais pour un export |
xargs | 123 si une des commandes lancées a renvoyé 1 à 125 | ne pas confondre avec une erreur de xargs lui-même |
Pour psql, la documentation de PostgreSQL est explicite : par défaut, le traitement des commandes continue après une erreur ; avec ON_ERROR_STOP activé, psql s'arrête et renvoie le code 3, pour distinguer ce cas d'une erreur fatale (code 1) ou d'une connexion perdue (code 2). Sans cette variable, un script de purge dont la deuxième requête échoue renvoie 0. L'option -X empêche la lecture d'un ~/.psqlrc qui pourrait changer le format de sortie. Avec -1 (--single-transaction) et ON_ERROR_STOP, une erreur annule toute la transaction.
Pour curl, la page de manuel précise que --fail fait échouer sur les codes HTTP 400 et plus, avec le code de sortie 22, sans écrire le corps de la réponse ; --fail-with-body garde le corps, utile pour journaliser le message d'erreur de l'API. verifier-sante (leçon 4) s'appuie dessus.
Pour aws, la documentation des codes de retour précise que le sens de 2 dépend de la commande (erreur d'analyse pour toutes, fichiers ignorés pour s3), et distingue en outre 252 (syntaxe invalide), 253 (configuration ou identifiants manquants), 254 (le service a renvoyé une erreur) et 255 (erreur générale, à ne pas interpréter finement). Pour le script, tous signifient la même chose : le dépôt n'est pas fait, code 4.
Valider le résultat, pas seulement l'absence d'erreur
Même quand chaque commande réussit, le résultat peut être faux. Les contrôles qui coûtent peu et attrapent beaucoup :
- l'entrée : le fichier existe, n'est pas vide, a le bon format (leçon 4) ;
- les comptes : au moins une ligne de données, et un ordre de grandeur plausible ;
- les fichiers produits :
gzip -tpour une archive,sha256sum -cpour une empreinte,jq emptypour un JSON ; - l'effet distant : après un dépôt, relire la taille de l'objet avec
aws s3api head-objectet la comparer à la taille locale.
publier-export, après cette leçon
Voici les parties de publier-export modifiées par cette leçon, qui le fait passer en version 1.3.0. L'analyse des options et le fichier de configuration (leçon 8) ne changent pas : ils fixent toujours date_export, destination, simulation et csv, et la fonction executer affiche au lieu d'exécuter en simulation. La bibliothèque lib/commun.sh (leçon 6) ne change pas non plus : mourir -c CODE MESSAGE écrit publier-export : erreur : MESSAGE sur la sortie d'erreur et sort avec le code donné, avertir écrit publier-export : attention : ....
#!/usr/bin/env bash
# publier-export : dépose l'export quotidien de Signalements pour la mairie.
set -Eeuo pipefail
shopt -s inherit_errexit
REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh"
readonly VERSION=1.3.0
readonly EX_OK=0 EX_ERREUR=1 EX_USAGE=2 EX_EXPORT=3 EX_DEPOT=4 EX_VERROU=5
readonly REPERTOIRE_EXPORTS=/srv/donnees/exports
readonly POINT_ACCES=https://s3.fr-par.scw.cloud
readonly EN_TETE='id,type,commune,date'
aws_s3=(aws s3 cp --only-show-errors --endpoint-url "$POINT_ACCES")
sur_erreur() {
local code=$1 ligne=$2 commande=$3
# Dans un sous-shell, sortir sans rien dire : le shell parent signalera l'échec.
if (( BASH_SUBSHELL > 0 )); then
exit "$code"
fi
avertir "échec inattendu (code $code) ligne $ligne : $commande"
local i
for (( i = 1; i < ${#FUNCNAME[@]} - 1; i++ )); do
avertir " dans ${FUNCNAME[i]}(), appelée ligne ${BASH_LINENO[i]}"
done
exit "$EX_ERREUR"
}
trap 'sur_erreur "$?" "$LINENO" "$BASH_COMMAND"' ERR
# ... usage, erreur_usage, lire_configuration, executer et analyse des options :
# inchangés depuis la leçon 8 ; ils fixent date_export, destination, simulation, csv.
compter_lignes() {
local n
n=$(tail -n +2 -- "$1" | wc -l)
printf '%d\n' "$n"
}
verifier_export() {
local premiere
[[ -f $csv ]] || mourir -c "$EX_EXPORT" "export introuvable : $csv"
[[ -s $csv ]] || mourir -c "$EX_EXPORT" "export vide : $csv"
# read renvoie 1 sur une ligne sans saut de ligne final, mais la remplit quand même.
IFS= read -r premiere < "$csv" || true
[[ ${premiere%$'\r'} == "$EN_TETE" ]] ||
mourir -c "$EX_EXPORT" "en-tête inattendu dans $csv : $premiere"
}
deposer() {
local fichier=$1 cible=$2 code taille_locale taille_distante
local seau=${cible#s3://}
local cle=${seau#*/}
seau=${seau%%/*}
executer "${aws_s3[@]}" "$fichier" "$cible" || {
code=$?
mourir -c "$EX_DEPOT" "échec du dépôt de ${fichier##*/} (aws, code $code)"
}
(( ! simulation )) || return 0
taille_locale=$(stat -c %s -- "$fichier")
taille_distante=$(aws s3api head-object --endpoint-url "$POINT_ACCES" \
--bucket "$seau" --key "$cle" --query ContentLength --output text) || {
code=$?
mourir -c "$EX_DEPOT" "dépôt de ${fichier##*/} invérifiable (aws, code $code)"
}
[[ $taille_distante == "$taille_locale" ]] ||
mourir -c "$EX_DEPOT" "taille distante $taille_distante pour ${fichier##*/}, $taille_locale attendus"
}
publier() {
local archive=$csv.gz nb
local annee=${date_export:0:4} mois=${date_export:5:2}
local prefixe=$destination/$annee/$mois
verifier_export
nb=$(compter_lignes "$csv")
(( nb > 0 )) || mourir -c "$EX_EXPORT" "aucune ligne de données dans $csv"
gzip -c -- "$csv" > "$archive"
gzip -t -- "$archive"
( cd -- "${csv%/*}" && sha256sum -- "${archive##*/}" ) > "$archive.sha256"
deposer "$archive" "$prefixe/${archive##*/}"
deposer "$archive.sha256" "$prefixe/${archive##*/}.sha256"
if (( simulation )); then
journaliser "simulation : rien n'a été déposé"
else
journaliser "export du $date_export publié, lignes de données : $nb"
fi
}Ce qui a changé, ligne à ligne :
- L'en-tête active le mode strict et le trap ; la variable
REP_OUTILSest affectée seule, sansreadonlysur la même ligne (SC2155 l'aurait relevé). sur_erreursort avecEX_ERREUR(1) : un échec que le script n'a pas prévu est une « erreur générale », jamais un code d'outil qui se ferait passer pour un code documenté.verifier_exportet le compte de lignes traduisent l'absence, le vide et l'absence de données en code 3 (EX_EXPORT), avec un message qui dit quoi. Ces fonctions appellentmourirsans risque : elles ne sont jamais appelées dans un$(...).compter_lignes, qui est capturée par$(...), ne meurt pas : sitailéchoue,pipefailetinherit_errexitfont échouer l'affectationn=$(...), puis l'appel, et le trap rapporte la ligne denb=$(compter_lignes "$csv").gzip -tcontrôle l'archive. Si le disque se remplit pendant la compression,gzip -céchoue de lui-même (No space left on device) et le trap l'attrape ;gzip -tcouvre en plus une archive écrite mais illisible.- L'empreinte est calculée dans un sous-shell qui se place dans le répertoire de l'export, pour que le fichier
.sha256contienne un nom relatif, vérifiable par la mairie avecsha256sum -c. Le&&protègesha256sumd'uncdraté, et le sous-shell évite de changer le répertoire du script. deposerpasse parexecuter(la simulation de la leçon 8 reste valable), convertit tout échec deawsen code 4 (EX_DEPOT) en gardant le code d'origine dans le message, puis, hors simulation, vérifie la taille de l'objet déposé.--only-show-errorsappartient àaws s3 cpseulement : passé àaws s3api, il provoquerait une erreur de syntaxe (code 252), c'est pourquoi la vérification n'utilise pas le tableauaws_s3.
Sur une copie de travail, avec HORODATER=0 (convention adoptée depuis la leçon 6 pour alléger les sorties) et une doublure de aws qui écrit dans un fichier au lieu d'appeler Scaleway, les scénarios donnent :
$ publier-export --date 2026-10-07; echo "code $?"
publier-export : export du 2026-10-07 publié, lignes de données : 2
code 0
$ FAUX_AWS_ECHEC=1 publier-export --date 2026-10-07; echo "code $?"
upload failed: simulated
publier-export : erreur : échec du dépôt de signalements-2026-10-07.csv.gz (aws, code 1)
code 4
$ publier-export --date 2026-02-03; echo "code $?"
publier-export : erreur : export introuvable : /srv/donnees/exports/signalements-2026-02-03.csv
code 3
Le 3 février rejoué donne désormais le code 3, rien n'est déposé, le service systemd passe en failed et OnFailure= prévient l'astreinte (voir En production). Il reste un défaut : quand le script s'arrête après gzip, l'archive et l'empreinte restent sur le disque, et une archive à moitié écrite pourrait être ramassée par une autre tâche. Le nettoyage à la sortie, les fichiers temporaires et l'écriture atomique sont l'objet de la leçon 10.
Sous le capot
Un drapeau sur l'arbre des commandes
Bash ne décide pas d'ignorer errexit au moment de l'échec : il le décide avant d'exécuter, en marquant les commandes. Après l'analyse syntaxique, une ligne de script devient un arbre de structures COMMAND ; chacune porte un champ flags. Le fichier execute_cmd.c des sources de Bash 5.2 montre le mécanisme : lorsqu'il exécute un if, la fonction execute_if_command pose le drapeau CMD_IGNORE_RETURN sur le test avant de l'exécuter (if_command->test->flags |= CMD_IGNORE_RETURN;). execute_while_or_until fait de même pour la condition d'une boucle. Pour une liste && ou ||, execute_connection pose le drapeau sur le premier membre ; le second ne le reçoit que si la liste entière l'avait déjà.
Après l'exécution d'une commande simple, d'une commande composée ou d'un tube, le même fichier teste une condition de cette forme :
if (ignore_return == 0 && invert == 0 && exit_immediately_on_error && exec_result != EXECUTION_SUCCESS)ignore_return vient du drapeau, invert du !, exit_immediately_on_error de set -e. Si tout est réuni, Bash exécute le trap ERR (run_error_trap), puis abandonne l'exécution par jump_to_top_level (ERREXIT), qui remonte jusqu'à la boucle principale du shell et le termine.
Pourquoi la contagion
Le drapeau se propage vers le bas. Quand une liste ; porte CMD_IGNORE_RETURN, execute_connection le recopie sur ses deux membres ; une boucle le recopie sur son corps ; un if sur ses branches. Et pour une fonction, execute_function exécute une copie du corps de la fonction et lui transmet le drapeau de l'appel :
tc = (COMMAND *)copy_command (function_cell (var));
if (tc && (flags & CMD_IGNORE_RETURN))
tc->flags |= CMD_IGNORE_RETURN;Toutes les commandes du corps héritent alors du drapeau, et aucune ne déclenchera l'arrêt. C'est la traduction exacte de la phrase du manuel sur les fonctions appelées « dans un contexte où -e est ignoré ». Ce n'est pas un bogue : POSIX décrit le même comportement, et les autres shells l'implémentent aussi. Mais cela explique pourquoi rien, à l'intérieur de la fonction, ne peut le contourner.
Les sous-shells et inherit_errexit
Une substitution de commande crée un processus enfant par fork. Dans l'enfant, Bash remet errexit à zéro, sauf si inherit_errexit est actif ou si le shell est en mode POSIX. L'option ne fait rien de plus : elle ne retire pas le drapeau CMD_IGNORE_RETURN que l'enfant hérite de la commande qui le contient. D'où le dernier cas du tableau : x=$(f) || ... reste en contexte de condition même avec inherit_errexit.
L'enfant peut aussi mourir d'un set -u ou d'un exit : le parent ne reçoit qu'un code de sortie, par waitpid. Il ne sait pas pourquoi l'enfant est mort. C'est ce qui fait perdre le code de mourir dans une substitution : le sous-shell est un autre processus, et son exit ne termine que lui.
Le trap ERR
Le trap ERR n'est pas un signal du noyau : c'est un pseudo-signal, interne à Bash, comme EXIT, DEBUG et RETURN. Il est déclenché par les mêmes tests que errexit (le code appelle run_error_trap juste avant jump_to_top_level), d'où les mêmes exceptions. Quand Bash entre dans une fonction ou un sous-shell, il réinitialise ce trap, sauf si errtrace (-E) est actif : c'est l'héritage contrôlé par -E.
Pièges courants
local x=$(commande), export, readonly, declare. Le code de la substitution est remplacé par celui de la commande interne. Déclarer, puis affecter. ShellCheck : SC2155.
Une fonction appelée dans un if, un while, ou à gauche de && et ||. Elle s'exécute entièrement sans errexit. Faire vérifier ses étapes par la fonction elle-même (|| return).
cd sans vérification. cd /srv/donnees/exports qui échoue, puis rm -f -- *.gz dans le répertoire courant. Avec set -e, le cd raté arrête le script ; sans, c'est une catastrophe. Écrivez toujours cd -- "$rep" || mourir ... : ShellCheck le réclame sous le code SC2164 (Use cd ... || exit in case cd fails), et la vérification survit aux contextes de condition.
(( n++ )) ou let n++ à partir de zéro. Arrêt silencieux. (( ++n )) ou n=$(( n + 1 )).
Le dernier && d'une fonction. [[ ${VERBEUX:-0} == 1 ]] && journaliser "..." en dernière ligne d'une fonction : quand VERBEUX ne vaut pas 1, la fonction renvoie 1, et l'appelant s'arrête. Utiliser un if, ou terminer par return 0. C'est pour cela que deboguer, dans lib/commun.sh (leçon 6), est écrite [[ ${VERBEUX:-0} == 1 ]] || return 0 : elle renvoie toujours 0.
grep qui ne trouve rien. Avec set -e, nb=$(grep -c ' 500 ' "$journal") arrête le script quand il n'y a aucune erreur 500, ce qui est une bonne nouvelle. Écrire nb=$(grep -c ' 500 ' "$journal") || (( $? == 1 )) (le code 1 est une réponse, 2 reste une panne), ou utiliser awk, qui renvoie 0 dans ce cas.
SIGPIPE et pipefail. | head, | grep -q, | sed q derrière une commande qui écrit beaucoup : code 141 aléatoire selon la taille. read < <(...), ou accepter 141 explicitement pour l'écrivain.
|| true partout. Il désactive l'arrêt pour tous les échecs de la commande, y compris ceux que l'on n'avait pas prévus. Préférez tester le code attendu : || (( $? == 1 )).
Le trap défini entre guillemets doubles. trap "sur_erreur $? $LINENO" ERR fige les valeurs au moment de la définition. Apostrophes.
$? lu après une autre commande dans le gestionnaire. Le passer en premier argument au gestionnaire, ou le lire en première instruction.
La substitution de processus. while read ...; done < <(commande) : l'échec de commande est ignoré. Si cela compte, vérifier après la boucle avec wait $! (Bash renvoie le code de la dernière substitution de processus si son PID est celui de $!), ou passer par un fichier temporaire.
set -e dans un script appelé par source. Le set modifie le shell appelant. Une bibliothèque comme lib/commun.sh ne doit pas activer ni désactiver d'options : c'est le script principal qui les fixe.
bash -x script ou bash script qui « perd » le -e de la ligne #!. Les options du shebang ne s'appliquent que si le noyau exécute le fichier. Mettez set -Eeuo pipefail dans le corps du script.
Sécurité
- Échouer fermé. Un script qui continue après une erreur ne produit pas seulement un résultat faux : il peut agir sur ce résultat.
purger-pieces-jointesqui calcule une liste de fichiers à garder à partir d'une requête qui a échoué, et considère donc que rien n'est à garder ; unrm -rf -- "$racine/$sous_dossier"où$sous_dossierest vide parce que sa substitution a échoué.set -uprotège du second (la variable non définie arrête le script), mais pas d'une variable définie et vide : vérifiez[[ -n $sous_dossier ]]avant toute suppression construite à partir d'une variable. - Les messages d'erreur sont des journaux. Tout ce qu'écrit le gestionnaire part dans le journal de systemd, souvent centralisé (leçon 12 du cours d'administration).
BASH_COMMANDaffiche le texte non développé, ce qui est une bonne chose ; en revanche, un message construit avec des variables (mourir -c "$EX_DEPOT" "échec de $url") peut y écrire une URL présignée ou un jeton. Ne journalisez que des identifiants et des noms, jamais une valeur secrète. - Les codes de l'outil ne sont pas les vôtres. Un appelant (minuteur, pipeline, autre script) qui décide d'une action selon le code de
publier-exportdoit pouvoir lui faire confiance. Renvoyer tel quel le code 2 deaws s3(« fichiers ignorés ») le ferait confondre avec « mauvaise utilisation » ; traduisez. - Ne jamais masquer pour faire taire.
2>/dev/null || truesur une commande qui échoue « de temps en temps » transforme une alerte en silence. Si l'échec est réellement acceptable, écrivez pourquoi en commentaire et testez le code précis. - Intégrité des données transmises. L'export contient des données personnelles. Une archive vide ou tronquée déposée chez un tiers est un incident de qualité, et peut devenir un incident de conformité si la mairie s'en sert pour une décision. L'empreinte SHA-256 déposée avec l'archive permet au destinataire de vérifier ce qu'il reçoit.
En production
- Le code de sortie est l'interface avec systemd. Un service
Type=oneshotqui sort avec un code non nul passe enfailed;OnFailure=déclenche alors la notification décrite dans la leçon 10 du cours d'administration. Tout ce que fait cette leçon sert à ce que ce code soit vrai. Si certains codes doivent être tolérés par systemd (par exemple 3 quand l'export Python est volontairement suspendu), c'est le rôle deSuccessExitStatus=dans l'unité, pas d'unexit 0dans le script. - Un échec doit se lire en une ligne dans le journal.
journalctl -u signalements-publication.servicedoit montrer le message de l'outil, puis celui du script avec la ligne et la pile. C'est ce qu'apporte le trapERR, et c'est ce qui fait gagner l'heure de diagnostic de la nuit. - Les échecs silencieux restent possibles. Un script qui ne tourne pas du tout ne signale rien. Le signal de vie (heartbeat) envoyé après le succès, décrit dans la même leçon d'administration, couvre ce cas : c'est le seul contrôle qui ne dépend pas du script.
- En CI, GitHub Actions lance les étapes
shell: bashavecbash --noprofile --norc -eo pipefail {0}, mais une étape sansshell:explicite avecbash -e {0}, sanspipefail(leçon 1 du cours GitHub Actions). Un script versionné dans le dépôt porte ses propres options, ce qui le rend indépendant de la manière dont on l'appelle. - Mesurer. Quand un script devient critique, publiez son dernier succès et ses codes de sortie comme métriques ; une suite d'échecs avec le code 4 (dépôt) et une suite avec le code 3 (export absent) n'appellent pas la même équipe.
- Activer les contrôles de ShellCheck. SC2155 et SC2164 sont actifs par défaut. Le contrôle optionnel
check-extra-masked-returns(code SC2312) signale en plus les substitutions dont le code est perdu, commecd "$(calculer_repertoire)/etc". Il est bruyant, mais utile sur les scripts les plus sensibles ; la leçon 12 montre comment l'activer dans.shellcheckrc.
Exercices
1. Arrêt ou pas (niveau 100). Avec set -e actif, dites pour chaque ligne si le script s'arrête, puis vérifiez dans un shell bash -c 'set -e; ...; echo survécu' : (a) grep -q absent /etc/hostname ; (b) grep -q absent /etc/hostname || true ; (c) if grep -q absent /etc/hostname; then echo trouvé; fi ; (d) n=0; (( n++ )) ; (e) ls /absent | wc -l.
Solution
(a) Arrêt, code 1 : grep n'a rien trouvé, et la commande n'est dans aucun contexte de condition. (b) Continue : grep est le premier membre d'une liste ||. (c) Continue : test d'un if. (d) Arrêt, code 1 : l'expression n++ vaut l'ancienne valeur, 0, donc (( )) renvoie 1. (e) Continue en affichant 0 : le code du tube est celui de wc, sauf avec set -o pipefail, où il deviendrait 2 (celui de ls) et arrêterait le script.
2. Le piège du compteur et du local (niveau 200). Cette fonction de rapport-journaux doit compter les lignes en erreur 500 d'un journal. En mode strict, le script s'arrête sans aucun message, avec le code 1, sur tous les journaux, même ceux qui contiennent des erreurs 500. Un collègue remplace (( n++ )) par (( ++n )) : le script ne s'arrête plus, mais il annonce une erreur 500 pour un journal qui n'en contient aucune, et une aussi pour un journal qui n'existe pas. Expliquez ces trois comportements et corrigez.
compter_500() {
local journal=$1 n=0
local lignes=$(grep ' 500 ' "$journal")
while IFS= read -r ligne; do
(( n++ ))
done <<< "$lignes"
echo "$n"
}Solution
Premier défaut : la boucle s'exécute au moins une fois (une here-string fournit toujours au moins une ligne, vide si $lignes l'est), et la première incrémentation (( n++ )) évalue l'ancienne valeur, 0, donc renvoie 1 : set -e arrête le script sans message, quel que soit le journal. Avec (( ++n )), deux autres défauts apparaissent. Journal absent : grep renvoie 2, mais local lignes=$(...) prend le code de local, 0 (SC2155) ; l'erreur est masquée. Journal sans erreur : grep renvoie 1, masqué de la même façon. Dans les deux cas, $lignes est vide, la here-string fournit une ligne vide, et la boucle la compte : résultat 1. Correction, en distinguant « rien trouvé » de « panne » :
compter_500() {
local journal=$1 n
n=$(grep -c ' 500 ' -- "$journal") || (( $? == 1 )) || return
printf '%d\n' "$n"
}grep -c affiche 0 et renvoie 1 quand il ne trouve rien : (( $? == 1 )) accepte ce cas ; le code 2 fait échouer la fonction par || return, y compris si elle est appelée dans une condition. Plus de boucle, plus de compteur.
3. Un trap qui dit tout (niveau 200). Écrivez un script essai-trap.sh en mode strict avec trois fonctions imbriquées a, b, c, où c exécute ls /absent. Le gestionnaire doit afficher le code, la ligne, la commande et la pile d'appels, sur la sortie d'erreur, puis sortir avec le code 1. Que se passe-t-il si vous retirez -E ? Et si vous définissez le trap entre guillemets doubles ?
Solution
Reprenez le gestionnaire sur_erreur de la leçon et trap 'sur_erreur "$?" "$LINENO" "$BASH_COMMAND"' ERR. La sortie montre ls: cannot access '/absent'..., puis échec (code 2) ligne N : ls /absent, puis dans c(), dans b(), dans a() avec les lignes d'appel. Sans -E, le trap n'est pas hérité par les fonctions, et set -e termine le shell dans c, sur l'échec de ls : le gestionnaire ne s'exécute pas du tout. On ne voit que le message de ls et le code de sortie 2, celui de ls, et non 1. Avec des guillemets doubles, $?, $LINENO et $BASH_COMMAND sont développés au moment où la commande trap est lue : le gestionnaire reçoit toujours le code 0, le numéro de la ligne du trap, et un début de la commande trap elle-même en guise de commande fautive.
4. La purge qui n'échouait jamais (niveau 200). Une version de purger-pieces-jointes récupère la durée de conservation depuis la base avant de supprimer les fichiers plus anciens :
jours=$(psql -X -At -c "SELECT valeur FROM parametres WHERE cle = 'retention_jours'")
find /srv/donnees/pieces-jointes -type f -mtime +"$jours" -deleteLa table parametres a été renommée lors d'une migration. Que se passe-t-il, avec et sans set -e ? Corrigez pour que le script échoue avec un message clair et ne supprime rien.
Solution
Une erreur SQL dans une requête passée par -c fait déjà renvoyer 1 à psql (le code source de psql, startup.c, traduit l'échec de chaque -c en EXIT_FAILURE, avec ou sans ON_ERROR_STOP ; le code 3 est réservé aux scripts lus par -f) ; avec set -e, l'affectation échoue et le script s'arrête. Sans set -e, jours est vide, et find ... -mtime +"" échoue sur un argument invalide : rien n'est supprimé, par chance, et le script renvoie quand même le code de find. Mais le jour où la requête passe par un fichier de plusieurs commandes (-f), psql continuerait après l'erreur et renverrait 0 sans ON_ERROR_STOP. Et si la table existe mais la ligne a disparu, la requête réussit et jours est vide. Version robuste :
jours=$(psql -X -At -v ON_ERROR_STOP=1 \
-c "SELECT valeur FROM parametres WHERE cle = 'retention_jours'") ||
mourir "lecture de la durée de conservation impossible"
[[ $jours =~ ^[0-9]+$ ]] && (( 10#$jours >= 30 )) ||
mourir "durée de conservation invalide : '${jours}'"
find /srv/donnees/pieces-jointes -type f -mtime +"$jours" -deleteOn vérifie l'appel et la valeur : un nombre, et un plancher qui interdit de tout supprimer par erreur. Pour une suppression, c'est le contrôle du résultat qui protège vraiment.
5. SIGPIPE en production (niveau 200). La fonction suivante vérifie l'en-tête d'une archive. Elle passe les tests unitaires (archives de quelques lignes) et échoue une nuit sur deux en production, avec le code 141 et sans message. Expliquez, puis proposez deux corrections qui gardent la détection d'une archive corrompue.
verifier_archive() {
local entete
entete=$(gzip -dc -- "$1" | head -n 1)
[[ $entete == "$EN_TETE" ]]
}Solution
head se termine après une ligne ; si gzip a encore des données à écrire (archive plus grosse que le tampon du tube, 64 Kio), il reçoit SIGPIPE et sort avec 141 ; pipefail en fait l'échec du tube, l'affectation échoue, set -e (avec inherit_errexit) arrête le script. Selon la taille de l'export du jour, cela arrive ou non. Corrections :
# 1. Intégrité d'abord, puis lecture sans tube.
verifier_archive() {
local entete
gzip -t -- "$1" || return
IFS= read -r entete < <(gzip -dc -- "$1") || return
[[ $entete == "$EN_TETE" ]]
}
# 2. Tolérer SIGPIPE pour l'écrivain, et lui seul.
verifier_archive() {
local entete
gzip -t -- "$1" || return
entete=$( { gzip -dc -- "$1" || (( $? == 141 )); } | head -n 1 ) || return
[[ $entete == "$EN_TETE" ]]
}Dans les deux cas, gzip -t détecte la corruption, puisque la lecture de la première ligne ne le peut plus. Et un test avec une archive de plusieurs mégaoctets rejoint la suite Bats de la leçon 12.
Récapitulatif
- Un script sans gestion d'erreur renvoie le code de sa dernière commande : tout ce qui précède peut avoir échoué. C'est ainsi qu'une archive vide est partie chez la mairie avec un service « réussi ».
set -earrête le script sur un échec, sauf en contexte de condition : test d'if, dewhile, membres non finaux de&&et||, éléments non finaux d'un tube,!. Ce contexte est contagieux : une fonction appelée dans unifou à gauche d'un||s'exécute entièrement sanserrexit.- Masquages à connaître :
local,export,readonly,declareavec une substitution (SC2155) ; substitution dans un argument (echo "$(cmd)") ; substitution de processus ;(( n++ ))à partir de 0. - Une erreur d'expansion arithmétique (
$(( 08 )), division par zéro) n'arrête pas le script : Bash abandonne la commande en cours et continue, sanserrexitni trapERR. Validez toute valeur avant de calculer avec. inherit_errexitfait héritererrexitaux substitutions de commande.set -uarrête sur variable non définie, sans exception de contexte.pipefailfait compter les échecs au milieu d'un tube, au prix du code 141 de SIGPIPE derrièreheadougrep -q.trap '...' ERRavecset -E: passer$?,$LINENO,$BASH_COMMANDau gestionnaire, parcourirFUNCNAMEetBASH_LINENOpour la pile, sortir avec le code d'erreur générale du script. Ne rien afficher dans un sous-shell (BASH_SUBSHELL).- Une fonction capturée par
$(...)ne meurt pas, elle échoue :return, et l'appelant décide avec|| mourir -c CODE. - Connaître les outils qui mentent :
psqlsansON_ERROR_STOP=1,curlsans--fail,grepetdiff(1 est une réponse),aws s3(2 = fichiers ignorés). - Valider le résultat : comptes,
gzip -t, taille de l'objet déposé. Le mode strict est un filet ; les vérifications explicites sont la structure.
Pour aller plus loin
- La description de
set -edans The Set Builtin du manuel de Bash, à relire phrase par phrase après cette leçon : chaque proposition correspond à une ligne du tableau. - BashFAQ/105 sur le wiki de Greg Wooledge, avec ses exercices et leurs réponses : le meilleur plaidoyer contre
set -e, à lire pour savoir exactement ce que l'on accepte en l'utilisant. - Le fichier
execute_cmd.cdes sources de Bash, en cherchantCMD_IGNORE_RETURN: une heure de lecture qui rend le comportement prévisible. - La section Exit Status de la documentation de
psqlet la page Return codes de l'AWS CLI, pour les deux outils qu'appellent le plus les scripts de Signalements. - La leçon suivante, Signaux, nettoyage et verrous : le trap
EXIT, les fichiers temporaires et l'écriture atomique, pour qu'un script qui s'arrête ne laisse rien derrière lui.
Sources
- GNU Bash Reference Manual, The Set Builtin (-e, -u, -E, pipefail)
- GNU Bash Reference Manual, Bourne Shell Builtins (trap, ERR) et The Shopt Builtin (inherit_errexit)
- GNU Bash Reference Manual, Bash Variables (BASH_COMMAND, BASH_LINENO, FUNCNAME, BASH_SUBSHELL)
- Bash 5.2, code source : execute_cmd.c (drapeau CMD_IGNORE_RETURN, execute_connection, execute_function)
- POSIX.1-2024, Shell Command Language, set (errexit, pipefail)
- Greg's Wiki, BashFAQ/105 : Why doesn't set -e do what I expected?
- Greg's Wiki, BashFAQ/105, réponses aux exercices
- ShellCheck, SC2155 : Declare and assign separately to avoid masking return values
- ShellCheck, SC2164 : Use cd ... || exit in case cd fails
- ShellCheck, SC2312 : Consider invoking this command separately to avoid masking its return value
- PostgreSQL 18, documentation de psql (ON_ERROR_STOP, Exit Status)
- PostgreSQL, code source de psql : src/bin/psql/startup.c (code de sortie des options -c et -f)
- Bash, page de manuel bash(1), options shopt compat43 (erreurs d'expansion)
- curl, page de manuel (--fail, --fail-with-body, codes de sortie 22 et 28)
- AWS CLI, Return codes
- GNU findutils, page de manuel xargs(1), EXIT STATUS