Tout ce que vous avez toujours voulu savoir sur les exceptions

La gestion des erreurs fait partie intégrante du travail dès lors qu’il s’agit d’écrire du code. Le comportement attendu est couramment contrôlé par la vérification et la validation de conditions. En cas de problème inattendu, nous nous tournons vers la gestion des exceptions. Vous pouvez facilement gérer les exceptions générées par le code d’autres personnes, ou bien générer vos propres exceptions pour que d’autres les gèrent.

Notes

La version originale de cet article est parue sur le blog écrit par @KevinMarquette. L’équipe PowerShell remercie Kevin d’avoir partagé ce contenu. Consultez son blog à l’adresse PowerShellExplained.com.

Terminologie de base

Examinons quelques termes de base avant de commencer.

Exception

Une exception ressemble à un événement créé lorsque la gestion normale des erreurs ne parvient pas à traiter le problème. Par exemple, une division par zéro ou le manque de mémoire provoquent une exception. Parfois, l’auteur du code que vous utilisez crée des exceptions pour certains problèmes lorsqu’ils se produisent.

Throw et catch

Quand une exception se produit, on dit qu’elle est levée (throw). Pour gérer une exception levée, vous devez l’intercepter ou la capturer (catch). Si une exception est levée mais n’est pas capturée, le script cesse de s’exécuter.

Pile des appels

La pile des appels est la liste des fonctions qui se sont appelées. Chaque fonction appelée est ajoutée à la pile ou en haut de la liste. Lorsqu’elle se termine (exit ou return), elle est supprimée de la pile.

Quand une exception est levée, la pile des appels est vérifiée pour qu’un gestionnaire d’exceptions puisse l’intercepter.

Erreurs bloquantes et non bloquantes

Une exception constitue généralement une erreur bloquante. Une fois levée, soit elle est interceptée, soit elle met fin à l’exécution en cours. Par défaut, une erreur non bloquante est générée par Write-Error et s’ajoute au flux de sortie sans lever d’exception.

C’est un point à savoir, car Write-Error et les autres erreurs non bloquantes ne déclenchent pas catch.

Dissimulation d’exceptions

Il s’agit de capturer une erreur pour se contenter de la supprimer. Utilisez ce procédé avec précaution, car il peut compliquer considérablement la résolution des problèmes.

Syntaxe des commandes de base

Voici un tour d’horizon rapide de la syntaxe de gestion des exceptions de base utilisée dans PowerShell.

Throw

Pour créer notre propre événement d’exception, nous levons une exception avec le mot clé throw,

function Start-Something
{
    throw "Bad thing happened"
}

qui crée une exception à l’exécution. Il s’agit d’une erreur bloquante. Elle est gérée par un catch dans une fonction d’appel ou quitte le script avec un message comme celui-ci.

PS> Start-Something

Bad thing happened
At line:1 char:1
+ throw "Bad thing happened"
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (Bad thing happened:String) [], RuntimeException
    + FullyQualifiedErrorId : Bad thing happened

Write-Error -ErrorAction Stop

Comme nous l’avons vu, Write-Error ne génère pas d’erreur bloquante par défaut. Si vous spécifiez -ErrorAction Stop en revanche, Write-Error déclenche une erreur bloquante qui peut être gérée avec un catch.

Write-Error -Message "Houston, we have a problem." -ErrorAction Stop

Merci à Lee Dailey d’avoir rappelé cette utilisation de -ErrorAction Stop.

Cmdlet -ErrorAction Stop

Spécifié sur une fonction ou une cmdlet avancée, -ErrorAction Stop convertit toutes les instructions Write-Error en erreurs bloquantes qui interrompent l’exécution ou qui peuvent être gérées par un catch.

Start-Something -ErrorAction Stop

Pour plus d’informations sur le paramètre ErrorAction, consultez about_CommonParameters. Pour plus d’informations sur la variable $ErrorActionPreference, consultez about_Preference_Variables.

Try/Catch

La gestion des exceptions fonctionne ainsi dans PowerShell (et dans beaucoup d’autres langages) : on essaye (try) d’abord une section de code ; s’il génère une erreur, on peut l’intercepter (catch). Voici un exemple rapide.

try
{
    Start-Something
}
catch
{
    Write-Output "Something threw an exception"
    Write-Output $_
}

try
{
    Start-Something -ErrorAction Stop
}
catch
{
    Write-Output "Something threw an exception or used Write-Error"
    Write-Output $_
}

Le script catch s’exécute seulement en cas d’erreur bloquante. Si le bloc try s’exécute correctement, le bloc catch est ignoré. Vous pouvez accéder aux informations sur les exceptions dans le bloc catch à l’aide de la variable $_.

Try/Finally

Il peut arriver qu’il ne soit pas nécessaire de gérer une erreur, mais qu’il faille quand même exécuter du code si une exception se produit. C’est le rôle d’un script finally.

Regardez cet exemple :

$command = [System.Data.SqlClient.SqlCommand]::New(queryString, connection)
$command.Connection.Open()
$command.ExecuteNonQuery()
$command.Connection.Close()

À chaque fois que l’on ouvre une ressource ou que l’on s’y connecte, il faut la fermer. Si ExecuteNonQuery() lève une exception, la connexion n’est pas arrêtée. Voici le même code à l’intérieur d’un bloc try/finally.

$command = [System.Data.SqlClient.SqlCommand]::New(queryString, connection)
try
{
    $command.Connection.Open()
    $command.ExecuteNonQuery()
}
finally
{
    $command.Connection.Close()
}

Dans cet exemple, la connexion est fermée qu’il se produise une erreur ou non. Le script finally s’exécute à chaque fois.

Comme l’exception n’est pas interceptée, elle est quand même propagée en haut de la pile des appels.

Try/Catch/Finally

Il est tout à fait possible d’utiliser catch et finally ensemble dans certains scénarios, même si dans la plupart des cas ils sont employés séparément.

$PSItem

Maintenant que nous avons vu les principes de base, nous pouvons aller un peu plus loin.

Dans le bloc catch se trouve une variable automatique ($PSItem ou $_) de type ErrorRecord qui contient les détails de l’exception. Voici un tour d’horizon rapide de quelques propriétés de la clé.

Dans ces exemples, l’exception était générée en utilisant un chemin d’accès non valide dans ReadAllText.

[System.IO.File]::ReadAllText( '\\test\no\filefound.log')

PSItem.ToString()

Cette fonction donne le message le plus propre possible pour la journalisation et la sortie générale. ToString() est appelé automatiquement si $PSItem est placé à l’intérieur d’une chaîne.

catch
{
    Write-Output "Ran into an issue: $($PSItem.ToString())"
}

catch
{
    Write-Output "Ran into an issue: $PSItem"
}

$PSItem.InvocationInfo

Cette propriété contient des informations supplémentaires collectées par PowerShell sur la fonction ou le script où l’exception a été levée. Voici la propriété InvocationInfo provenant de l’exemple d’exception créé précédemment.

PS> $PSItem.InvocationInfo | Format-List *

MyCommand             : Get-Resource
BoundParameters       : {}
UnboundArguments      : {}
ScriptLineNumber      : 5
OffsetInLine          : 5
ScriptName            : C:\blog\throwerror.ps1
Line                  :     Get-Resource
PositionMessage       : At C:\blog\throwerror.ps1:5 char:5
                        +     Get-Resource
                        +     ~~~~~~~~~~~~
PSScriptRoot          : C:\blog
PSCommandPath         : C:\blog\throwerror.ps1
InvocationName        : Get-Resource

Les informations importantes sont le nom du script (ScriptName), la ligne (Line) de code et le numéro de ligne (ScriptLineNumber) où l’appel a commencé.

$PSItem.ScriptStackTrace

Cette propriété montre l’ordre des appels de fonction qui conduisent dans le code où l’exception a été générée.

PS> $PSItem.ScriptStackTrace
at Get-Resource, C:\blog\throwerror.ps1: line 13
at Start-Something, C:\blog\throwerror.ps1: line 5
at <ScriptBlock>, C:\blog\throwerror.ps1: line 18

Il n’y a ici que des appels à des fonctions dans le même script, mais elle suivrait les appels si plusieurs scripts étaient impliqués.

$PSItem.Exception

Il s’agit de l’exception réelle qui a été levée.

$PSItem.Exception.Message

Il s’agit du message général qui décrit l’exception, un bon point de départ pour la résolution des problèmes. La plupart des exceptions comportent un message par défaut, mais elles peuvent également être définies sur un message personnalisé lorsqu’elles sont levées.

PS> $PSItem.Exception.Message

Exception calling "ReadAllText" with "1" argument(s): "The network path was not found."

Il s’agit également du message retourné lorsque $PSItem.ToString() est appelé, si aucun n’a été défini sur ErrorRecord.

$PSItem.Exception.InnerException

Les exceptions peuvent contenir des exceptions internes. C’est souvent le cas lorsque le code appelé intercepte une exception et en lève une autre. L’exception d’origine est placée à l’intérieur de la nouvelle.

PS> $PSItem.Exception.InnerExceptionMessage
The network path was not found.

Nous reviendrons sur ce sujet plus tard lorsque nous parlerons de lever à nouveau des exceptions.

$PSItem.Exception.StackTrace

Il s’agit de l’arborescence des appels de procédure (StackTrace) de l’exception. Contrairement à la ScriptStackTrace ci-dessus, celle-ci concerne les appels au code managé.

at System.IO.FileStream.Init(String path, FileMode mode, FileAccess access, Int32 rights, Boolean
 useRights, FileShare share, Int32 bufferSize, FileOptions options, SECURITY_ATTRIBUTES secAttrs,
 String msgPath, Boolean bFromProxy, Boolean useLongPath, Boolean checkHost)
at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share, Int32
 bufferSize, FileOptions options, String msgPath, Boolean bFromProxy, Boolean useLongPath, Boolean
 checkHost)
at System.IO.StreamReader..ctor(String path, Encoding encoding, Boolean detectEncodingFromByteOrderMarks,
 Int32 bufferSize, Boolean checkHost)
at System.IO.File.InternalReadAllText(String path, Encoding encoding, Boolean checkHost)
at CallSite.Target(Closure , CallSite , Type , String )

Cette arborescence des appels de procédure n’est accessible que si l’événement est levé à partir de code managé. Ici, une fonction .NET Framework est appelée directement ; c’est tout ce que l’on peut voir dans cet exemple. En général, lorsque l’on examine une arborescence des appels de procédure, on recherche l’endroit où s’arrête le code et où commencent les appels système.

Utilisation des exceptions

La question des exceptions dépasse la syntaxe de base et les propriétés des exceptions.

Interception des exceptions typées

Il est possible de choisir les exceptions à intercepter. Elles possèdent en effet un type,

try
{
    Start-Something -Path $path
}
catch [System.IO.FileNotFoundException]
{
    Write-Output "Could not find $path"
}
catch [System.IO.IOException]
{
        Write-Output "IO error with the file: $path"
}

qui est vérifié pour chaque bloc catch jusqu’à ce qu’il en soit trouvé un qui corresponde à l’exception. Il est important de comprendre que les exceptions peuvent hériter d’autres exceptions. Dans l'exemple ci-dessus, FileNotFoundException hérite de IOException. Par conséquent, si IOException arrivait en premier, ce serait elle qui serait appelée. Un seul bloc catch est appelé, même s’il existe plusieurs correspondances.

Si nous avions un System.IO.PathTooLongException, le IOException correspondrait, mais si nous avions un InsufficientMemoryException, rien ne l’attraperait et il se propagerait dans la pile.

Interception de plusieurs types à la fois

Il est possible d’intercepter plusieurs types d’exceptions avec la même instruction catch.

try
{
    Start-Something -Path $path -ErrorAction Stop
}
catch [System.IO.DirectoryNotFoundException],[System.IO.FileNotFoundException]
{
    Write-Output "The path or file was not found: [$path]"
}
catch [System.IO.IOException]
{
    Write-Output "IO error with the file: [$path]"
}

Merci à /u/Sheppard_Ra d’avoir suggéré cet ajout.

Déclenchement des exceptions typées

Il est possible de lever des exceptions typées dans PowerShell. Au lieu d’appeler throw avec une chaîne :

throw "Could not find: $path"

Utilisez un accélérateur d’exception :

throw [System.IO.FileNotFoundException] "Could not find: $path"

Toutefois, il est alors nécessaire de spécifier un message.

Vous pouvez également créer une nouvelle instance d’une exception à lever. Dans ce cas, le message est facultatif, car le système possède des messages par défaut pour toutes les exceptions intégrées.

throw [System.IO.FileNotFoundException]::new()
throw [System.IO.FileNotFoundException]::new("Could not find path: $path")

Si vous n’utilisez pas la version 5.0 ou une version ultérieure de PowerShell, vous devez utiliser l’ancienne approche New-Object.

throw (New-Object -TypeName System.IO.FileNotFoundException )
throw (New-Object -TypeName System.IO.FileNotFoundException -ArgumentList "Could not find path: $path")

Une exception typée peut être interceptée par son type, comme nous l’avons vu dans la section précédente.

Write-Error -Exception

Ces exceptions typées peuvent être ajoutées à Write-Error, sachant qu’il restera possible d’intercepter (catch) les erreurs par type d’exception. Utilisez Write-Error comme dans les exemples suivants :

# with normal message
Write-Error -Message "Could not find path: $path" -Exception ([System.IO.FileNotFoundException]::new()) -ErrorAction Stop

# With message inside new exception
Write-Error -Exception ([System.IO.FileNotFoundException]::new("Could not find path: $path")) -ErrorAction Stop

# Pre PS 5.0
Write-Error -Exception ([System.IO.FileNotFoundException]"Could not find path: $path") -ErrorAction Stop

Write-Error -Message "Could not find path: $path" -Exception (New-Object -TypeName System.IO.FileNotFoundException) -ErrorAction Stop

Nous pouvons alors l’intercepter ainsi :

catch [System.IO.FileNotFoundException]
{
    Write-Log $PSItem.ToString()
}

La grande liste des exceptions .NET

Avec l’aide de la communauté Reddit/r/PowerShell, j’ai compilé une liste principale contenant des centaines d’exceptions .NET pour compléter ce billet.

L’idée est de rechercher dans cette liste des exceptions qui semblent adaptées à la situation. Essayez d’utiliser des exceptions dans l’espace de noms System de base.

Les exceptions sont des objets

Si vous commencez à utiliser de nombreuses exceptions typées, n’oubliez pas qu’il s’agit d’objets. Des exceptions différentes possèdent différents constructeurs et différentes propriétés. Dans la documentation FileNotFoundException de System.IO.FileNotFoundException, il apparaît que l’on peut transmettre un message et un chemin de fichier.

[System.IO.FileNotFoundException]::new("Could not find file", $path)

Elle possède également une propriété FileName qui expose ce chemin du fichier.

catch [System.IO.FileNotFoundException]
{
    Write-Output $PSItem.Exception.FileName
}

Pour découvrir d’autres constructeurs et propriétés d’objets, consultez la documentation .NET.

Exception levée plusieurs fois

Si le bloc catch ne sert qu’à lever (throw) la même exception, ne l’interceptez (catch) pas. Il ne faut intercepter (catch) une exception que pour la gérer ou effectuer une action quand elle se produit.

Il peut arriver que l’on souhaite exécuter une action sur une exception, mais lever à nouveau l’exception pour qu’elle puisse être traitée en aval : par exemple, écrire un message ou consigner le problème au moment de la détection, pour le gérer plus haut dans la pile.

catch
{
    Write-Log $PSItem.ToString()
    throw $PSItem
}

Point intéressant, si l’on appelle throw à partir du catch, il lève à nouveau l’exception actuelle.

catch
{
    Write-Log $PSItem.ToString()
    throw
}

Lever à nouveau l’exception permet de conserver les informations d’exécution d’origine, comme le script source et le numéro de ligne. Si l’on déclenche une nouvelle exception, cela a pour effet de masquer l’endroit où l’exception a commencé.

Nouvelle exception levée plusieurs fois

Si vous interceptez une exception mais que vous souhaitez en lever une autre, imbriquez l’exception d’origine à l’intérieur de la nouvelle. Ainsi, elle sera accessible plus bas dans la pile en tant que $PSItem.Exception.InnerException.

catch
{
    throw [System.MissingFieldException]::new('Could not access field',$PSItem.Exception)
}

$PSCmdlet.ThrowTerminatingError()

Le seul inconvénient de throw pour les exceptions brutes est que le message d’erreur pointe vers l’instruction throw et indique la ligne où se trouve le problème.

Unable to find the specified file.
At line:31 char:9
+         throw [System.IO.FileNotFoundException]::new()
+         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (:) [], FileNotFoundException
    + FullyQualifiedErrorId : Unable to find the specified file.

Le fait qu’il précise que l’exécution du script s’est arrêtée parce que throw a été appelé à la ligne 31 n’est pas pertinent pour les utilisateurs du script, car non instructif.

Dexter Dhami a souligné que l’on peut utiliser ThrowTerminatingError() pour y remédier.

$PSCmdlet.ThrowTerminatingError(
    [System.Management.Automation.ErrorRecord]::new(
        ([System.IO.FileNotFoundException]"Could not find $Path"),
        'My.ID',
        [System.Management.Automation.ErrorCategory]::OpenError,
        $MyObject
    )
)

Si l’on suppose que ThrowTerminatingError() a été appelé à l’intérieur d’une fonction nommée Get-Resource, c’est cette erreur que l’on verrait.

Get-Resource : Could not find C:\Program Files (x86)\Reference
Assemblies\Microsoft\Framework\.NETPortable\v4.6\System.IO.xml
At line:6 char:5
+     Get-Resource -Path $Path
+     ~~~~~~~~~~~~
    + CategoryInfo          : OpenError: (:) [Get-Resource], FileNotFoundException
    + FullyQualifiedErrorId : My.ID,Get-Resource

Comme on peut le constater, elle pointe vers la fonction Get-Resource comme source du problème, ce qui constitue une information utile pour l’utilisateur.

Étant donné que $PSItem est un ErrorRecord, il est également possible d’utiliser ThrowTerminatingError de cette façon pour un nouveau déclenchement.

catch
{
    $PSCmdlet.ThrowTerminatingError($PSItem)
}

La source de l’erreur devient la cmdlet et les éléments internes de la fonction sont cachés aux utilisateurs de la cmdlet.

Try peut créer des erreurs bloquantes

Kirk Munro signale que certaines exceptions ne constituent des erreurs bloquantes que si elles sont exécutées dans un bloc try/catch. Voici son exemple, qui génère une exception de division par zéro à l’exécution.

function Start-Something { 1/(1-1) }

Appelez-le ainsi : comme vous pouvez le constater, il génère l’erreur, mais donne quand même le message.

&{ Start-Something; Write-Output "We did it. Send Email" }

Toutefois, en plaçant ce même code à l’intérieur d’un bloc try/catch, le résultat est différent.

try
{
    &{ Start-Something; Write-Output "We did it. Send Email" }
}
catch
{
    Write-Output "Notify Admin to fix error and send email"
}

L’erreur devient bloquante et ne génère pas le premier message. Le problème est que ce code peut se trouver dans une fonction et agir différemment si quelqu’un utilise un bloc try/catch.

Je n’ai personnellement pas rencontré ce problème, mais il s’agit d’un cas particulier à connaître.

$PSCmdlet.ThrowTerminatingError() dans un bloc try/catch

L’une des nuances de $PSCmdlet.ThrowTerminatingError() est qu’elle crée une erreur bloquante dans la cmdlet, mais devient une erreur non bloquante une fois sortie de la cmdlet. C’est donc à la personne qui appelle la fonction de décider comment gérer l’erreur. Elle peut la retransformer en erreur bloquante en utilisant -ErrorAction Stop ou en l’appelant à partir d’un bloc try{...}catch{...}.

Modèles de fonctions publics

Par ailleurs, Kirk Munro place un bloc try{...}catch{...} autour de chaque bloc begin, process ou end dans toutes ses fonctions avancées. Dans ces blocs Catch génériques, il se sert d’une ligne unique utilisant $PSCmdlet.ThrowTerminatingError($PSItem) pour traiter toutes les exceptions qui quittent ses fonctions.

function Start-Something
{
    [CmdletBinding()]
    param()

    process
    {
        try
        {
            ...
        }
        catch
        {
            $PSCmdlet.ThrowTerminatingError($PSItem)
        }
    }
}

Étant donné que tout se trouve dans une instruction try dans les fonctions, l’ensemble fonctionne de manière cohérente. L’utilisateur final obtient également des erreurs propres, qui masquent le code interne.

Trap

Nous nous sommes concentrés sur l’aspect try/catch des exceptions. Cependant, il existe une fonctionnalité héritée qu’il faut mentionner avant de conclure.

Un bloc trap est placé dans un script ou une fonction pour intercepter toutes les exceptions qui se produisent dans cette portée. Quand une exception se produit, le code du bloc trap est exécuté, puis le code normal reprend. Si plusieurs exceptions se produisent, le bloc trap est appelé plusieurs fois.

trap
{
    Write-Log $PSItem.ToString()
}

throw [System.Exception]::new('first')
throw [System.Exception]::new('second')
throw [System.Exception]::new('third')

Je n’ai personnellement jamais adopté cette approche, mais elle peut être intéressante dans les scripts d’administrateur ou de contrôleur qui journalisent toutes les exceptions, puis continuent à s’exécuter.

Remarques finales

Ajouter une gestion des exceptions adaptée aux scripts les rend non seulement plus stables, mais permet également de résoudre plus facilement ces exceptions.

Nous avons longuement parlé de throw, car il s’agit d’un concept fondamental pour la gestion des exceptions. PowerShell propose également Write-Error, qui gère toutes les situations où throw peut être utilisé. Ne concluez pas que vous devez employer throw après avoir lu ce billet.

Maintenant que j’ai pris le temps d’écrire sur la gestion des exceptions dans le détail, je vais utiliser Write-Error -Stop pour générer des erreurs dans mon code. Je vais également suivre le conseil de Kirk et faire de ThrowTerminatingError mon gestionnaire d’exceptions de prédilection pour toutes les fonctions.