Handling the Unexpected When Problems Happen in Code
Writing PowerShell scripts can be a fulfilling task. After all, you write something to assist with a task or procedure so you can focus on the result, not the task itself. But what if your script tries to run an action and is unsuccessful, for example, when a user the script attempts to manipulate is invalid or the signed in account has insufficient permissions to run a cmdlet? And do not forget the peculiarities of the online world, such as a network connection dropping or an authentication token expiring.
This is where one of the often-undervalued aspects of writing resilient and “a less optimistic version” of scripts comes into the picture: exception handling.
Exception handling means adding code to deal with unexpected or expected situations. This is why the often coined term error handling might mislead, as sometimes the “error” can be a result determined by the command. “User not found” is not really an error, is it, except when it happens unexpectedly? A script might expect not to find a user account or other object and in that case, “user not found” is exactly what you expect to happen.
When the default behavior of commands is undesirable or may even create problems when not dealt with properly, code should anticipate issues. A simple example is when you create an Entra ID user account and want to set some of its mail-related properties. The manipulation is only possible if the creation was successful.
Handling Exceptions
How you want exceptions to be managed depends on the situation, for example:
- Retry operations when the encountered issue is likely a temporary one. For example, when accessing Entra ID accounts, you must wait for changes to propagate to Exchange Online before configuring mail-related properties.
- Continuing with further processing. For example, when changing properties for a collection of users, should the failure of a single user cause a complete failure and prevent performing the task for the remainder of users? Proper logging of the issue might be more appropriate here so the operator can remediate the situation and re-run the script for the failed subset.
- Managing exceptions gracefully. Not only does this allow you to deal with the exception in a more informative way, but it also allows you to do some needed clean-up, preventing the premature exit of your script from leaving the environment it interacted with in an undetermined state.
ErrorAction and $Error
Before diving into how to anticipate exceptions, I want to discuss ErrorAction and $Error.
ErrorAction, or EA for short, is one of the common parameters supported by all PowerShell cmdlets. The most used settings are:
- Continue outputs the error message to the output stream (console) and continues executing code. This is the default behavior.
- Stop outputs the error and terminates execution.
- SilentlyContinue suppresses error messages and continues execution. Any error messages are sent to the error stream, which you can query using the automatic variable $Error or the variable specified using the common parameter $ErrorVariable.
Other options are Inquire, Ignore, Suspend, and Break. More information about these options is available online. When ErrorAction is not specified in a script, the global variable $ErrorActionPreference defines how a cmdlet should respond to non-terminating errors.
When you specify -ErrorAction Stop for a cmdlet, the cmdlet turns a non-terminating error into a terminating exception, e.g., the following command will continue retrieving mailbox information using a CSV file, even if a single entry cannot be found:
Import-Csv -Path .\Users.csv | ForEach-Object { Get-Mailbox -Identity $_.EmailAddress }
When a non-terminating error is encountered, for example, a mailbox cannot be located, the ErrorActionPreference default setting of Continue allows continued processing of the remainder of the objects sent through the pipeline.
However, if we update the script to make the Get-Mailbox cmdlet use ErrorAction Stop, the script stops if it cannot locate a mailbox:
Import-Csv -Path .\Users.csv | ForEach-Object { Get-Mailbox -Identity $_.EmailAddress -ErrorAction Stop }
Tip: Similar to the output streams used by Write-Output, Write-Host, and Write-Verbose, you can use Write-Error to write non-terminating error messages to the error output stream. Write-Error also has the option to specify additional properties, such as error numbers (similar to category. More on that can be found here.
The automatic variable $Error is an array storing all errors encountered in the current PowerShell session. This can be the most recent error ($Error[0]) or history. Every item in the array contains the error and its message and some informative properties such as CategoryInfo (error category), invocationInfo (what was executed when the error occurred), ScriptStackTrace (what was called when the error occurred), and Exception information.
Try/Catch/Finally
Next, let’s discuss how to catch and anticipate terminating errors. To accomplish this, we need to add the Try/Catch construct to our script:
Try { <code> } Catch [[error type[, errortype]*] { <code to handle exception> } Finally { <code to execute, whether try code successfully or not> }
A brief explanation of this construct:
- Try is followed by a script block containing the code you want to execute. If any terminating error is encountered while running this code, the script will pass the error to the catch block if present.
- Catch is optional and specifies the code you want to run when errors occur while running the Try block. The error is passed from the try block as the automatic variable $_. In its most simple form, the catch will catch all errors. When you want to anticipate multiple error types, you can specify a catch block for every type of error. For this, you need to specify its error type. You can determine the error type quickly by creating the issue and inspecting the exception while fetching the full name of its error type, e.g. $error[0].exception.getType().Fullname will return System.Exception. While the error type sounds generic in this situation, the context of what you were trying to accomplish should also point to what might be wrong.
For how to incorporate an error type catch such as System.Exception, have a look at the Catch [System.Exception] block in the example below. Note that when specifying multiple catch blocks, you can use one without error type specification as the default catch block. - Finally, code found here always runs, whether the try block is successful or not. Use it for housekeeping purposes. The Finally block also runs when you run a stop cmdlet such as Exit from one of the catch blocks.
The following script excerpt fetches mailbox objects for a given set of users and handles generic exceptions as well as system exceptions, as mentioned earlier:
ForEach( $User in $Users){ Write-Host -Message ( 'Fetching mailbox {0}' -f $User.Identity) Try { $Mailbox= Get-Mailbox -Identity $User.Identity -ErrorAction Stop } Catch [System.Exception] { Write-Error( '{0} System Exception: {1}' -f $User.Identity, $_.Exception.Message) } Catch { Write-Error -Message ('{0} Default Catch: {1}' -f $User.Identity, $_.Exception.Message) } Finally { # Finally } }
As you can see, in the example, we set ErrorAction to Stop with Get-Mailbox because fetching a non-existent mailbox is not a terminating error. Also, something to be aware of is that if you put cmdlets such as Get-Mailbox in a try block with a catch block present, you will not see any error output because the error gets passed to the catch block. That is, unless you perform your own fancy display of the error message or perhaps some other way of logging.
Some other recommendations:
- Try to avoid putting multiple commands that are prone to failure in a single try block. But when you do, be aware that the catch block will be triggered by the first exception, and your catch is unaware of which command caused the error.
- Having a single operation in a try block limits the potential scope of failure. This makes it easier to deal with the error and continue the rest of the process.
- Try avoiding state variables to control the flow of logic down the road. For example, I sometimes see the following pattern:
Try { <Something> $DoThis= $true } Catch { $DoThis= $false } If( $DoThis) { <Try was successful, perform further processing> }
For improved readability, include processing in the try block.
Try { <Something> <Perform further processing> } Catch { <..> }
- When inspecting the last error $Error[0] variable in the catch block, copy it to another variable first. Commands in your catch block might return new error results, moving the error you are interested in down the array.
- You can nest try/catch/finally, but it is not recommended, as it adds complexity. Also, nesting might be confusing as the error report will be from the highest level, e.g., when we take the script excerpt from earlier and we call it from another small script, what will happen is that the error and line report will be from the caller, not the called script:
[PS]> try { .\Sample1.ps1 -CsvFile .\Users.csv } catch { Write-Error -Message ('The whole thing failed') } Fetching mailbox henk@contoso.com Write-Error: Line | 2 | .\Sample1.ps1 -CsvFile .\Users.csv | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | System Exception called for henk@contoso.com: Ex6F9304|Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException|The operation couldn't be performed because object 'henk@contoso.com' couldn't be found on 'AM6PR05A02DC002.EURPR05A002.prod.outlook.com'.
The output shows an error on line 2: the line from the calling script block, not the line from the actual code.
Throw & Trap
Finally, let’s discuss Throw and Trap. Throw is a keyword that you use to generate terminating errors. It will stop the code execution from the current command, function, or script. You can pass any expression when throwing an error, which should at least be an informative message. For example:
Try { <Something> } Catch { Throw( 'Error occurred, exiting.') } Finally { <Finally> }
You can also throw ErrorRecord objects, allowing you to provide more details about the error returned. More on Throw here.
Earlier we discussed the function of Catch in the Try/Catch construct. When terminating errors occur, default error handling takes place. If you do not want default error handling to occur, Trap allows you to create a catch-all for terminating errors when you have not specified a Try/Catch construct. You can optionally specify the error type only to catch exceptions for that type. An example often says more than words. Here’s a sample script excerpt:
trap [System.Exception] { Write-Error -Message 'An error trapped' } ThisCommandDoesNotExit
When we run the script, the trap catches the exception generated by calling a non-existent command (which happens to be System.Exception error type):
The script executes the code in the trap and displays the custom error message. However, the trap’s default behavior is to continue with execution after the failing code piece, which in this case would mean displaying the error message. We can manage this using Stop cmdlets, such as using Break to report the error and terminate execution, or Continue to suppress the error and continue execution. You can find more on Trap here.
Feel free to reach out in the comments if you have questions or comments. If not, until the following article, where I will talk about logging and reporting, a favorite topic of many administrators.