Manejo de errores terminantes en PowerShell

Encuentra el código de la lección:

Abstract

Al terminar esta lección podrás controlar el flujo con errores terminantes en PowerShell: promover fallos con -ErrorAction Stop , capturarlos con try / catch / finally y devolver salida estructurada en lugar de texto suelto. Nos centraremos en errores terminantes; los errores no terminantes aparecerán solo como contraste y los estudiaremos en detalle más adelante, cuando entremos en el pipeline.

También aprenderás un patrón seguro para ejecutar comandos externos: resolver el binario real, ejecutarlo de forma inequívoca, capturar su resultado y elevar fallos con contexto cuando corresponda, para evitar scripts frágiles o dependientes del entorno. El ejercicio final consolida estas ideas implementando un fallback simple entre herramientas, priorizando capacidades reales por sobre suposiciones.

Errores terminantes

En un shell orientado a automatización y pipelines como PowerShell, no todos los fallos deben detener la ejecución. A menudo procesas muchos elementos (archivos, filas, recursos remotos) y necesitas registrar errores por ítem sin abortar el lote completo: estos son errores no terminantes.

En cambio, cuando una operación es crítica (por ejemplo, escribir o borrar datos), conviene elevar el error a terminante para detener el flujo y manejarlo explícitamente con try / catch . PowerShell modela esta distinción de forma explícita y proporciona mecanismos como -ErrorAction y $ErrorActionPreference para promover errores no terminantes a terminantes cuando el script lo requiere.

Ojo: try / catch solo captura errores terminantes. Esto no es un detalle accidental: los errores no terminantes están pensados para reportar problemas sin romper el flujo, mientras que try / catch está diseñado para el control explícito del flujo ante fallos críticos. En última instancia, PowerShell nos permite decidir si los errores informan o definen el comportamiento del programa.

Error no terminante

Un error no terminante registra la falla pero continúa la ejecución. Es útil cuando procesas un conjunto de elementos y quieres un reporte por elemento de qué funcionó y qué falló, sin detener todo ante el primer error. Solo lo mencionamos aquí como contexto; lo veremos con más detalle cuando abordemos el pipeline, donde resulta especialmente relevante.

Error terminante

Un error terminante interrumpe inmediatamente el flujo. Úsalo cuando la operación es crítica (crear/escribir/borrar datos relevantes, cambios de estado, CI/CD), promoviendo fallos con -ErrorAction Stop y manejándolos con try / catch / finally .

En CI/CD suele ser deseable fallar rápido para evitar despliegues inconsistentes o estados intermedios.

Regla práctica

Si un fallo invalida el resultado del script, conviértelo en terminante; si solo invalida un elemento, suele ser mejor tratarlo como no terminante (registrar y continuar).

Este ejemplo implementa una copia segura y predecible con ShouldProcess para confirmación, y elevación de errores a terminantes para capturarlos con catch . El bloque finally garantiza una salida estructurada con el estado de la operación, incluso ante fallos o cancelaciones.

Copia segura con ShouldProcess y salida estructurada
scripts/operations/Copy-ItemStrict.ps1
#Requires -Version 7.5 
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrWhiteSpace()]
    [ValidateScript({ Test-Path -LiteralPath $_ })]
    [string] $Source,

    [Parameter(Mandatory)]
    [ValidateNotNullOrWhiteSpace()]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
    [string] $Destination,

    [switch] $Recurse
)

Set-StrictMode -Version 3.0

$result = @{
    Source      = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Source)
    Destination = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Destination)
}
$result.Target = Join-Path $result.Destination (Split-Path -Leaf $result.Source)

try {
    $shouldProcess = $PSCmdlet.ShouldProcess($result.Source, 'Copy to {0}' -f $result.Destination)
    if ($shouldProcess) {
        if (Test-Path -LiteralPath $result.Target) {
            $result.Status = 'Skipped'
            $result.Reason = ('Target already exists: {0}' -f $result.Target)
        }
        else {
            $copyParams = @{
                LiteralPath = $result.Source
                Destination = $result.Destination
                Recurse     = $Recurse
                ErrorAction = 'Stop'
            }

            Copy-Item @copyParams
            $result.Status = 'Copied'
        }
    }
    else {
        # Make cancellation explicit so callers don't need to infer it from missing Status
        $result.Status = 'Cancelled'
        $result.Reason = 'ShouldProcess returned False (confirmation declined or -WhatIf)'
    }
}
catch {
    $result.Status = 'Failed'
    $result.Error = [PSCustomObject]@{
        Kind    = $_.Exception.GetType().Name
        Message = $_.Exception.Message
    }
}
finally {
    [PSCustomObject]$result
}

Detalles clave

El objetivo del diseño no es solo copiar archivos: es comunicar explícitamente qué ocurrió, sin obligar al consumidor a inferirlo desde excepciones o salida textual.

  • Rutas normalizadas y destino final: $PSCmdlet.GetUnresolvedProviderPathFromPSPath() resuelve rutas absolutas; Join-Path ... (Split-Path -Leaf ...) compone el Target.
  • Validación temprana: [ValidateScript({ Test-Path ... })] asegura que el origen existe y que el destino es un contenedor.
  • -ErrorAction Stop : promueve fallos de Copy-Item a terminantes para entrar en catch .
  • Salida estructurada: en finally devolvemos $result con Source/Destination/Target, Status y, cuando aplica, Reason o Error.

Cancelled se modela de forma explícita para evitar ambigüedad: el script no falló, simplemente no ejecutó la acción (decisión del usuario o -WhatIf ).

Estados posibles del resultado

  • Copied: la copia se realizó correctamente.
  • Skipped: el destino ya existía.
  • Cancelled: ShouldProcess devolvió false.
  • Failed: ocurrió un error terminante durante la operación.

Uso

Ejecuta el script desde la terminal pasando un archivo y una carpeta destino, para centrarnos en el contrato del script antes de introducir pipelines:

Usar y leer el resultado (breve, sin pipeline)
# Preparación mínima
$dest = 'backup'
if (!(Test-Path -LiteralPath $dest)) {
    New-Item -ItemType Directory -Path $dest
}

# Ensayo con -WhatIf -> 'Cancelled'
$w = ./Copy-ItemStrict.ps1 -Source './Copy-ItemStrict.ps1' -Destination $dest -WhatIf
$w.Status
$w.Reason

# Copia real -> 'Copied' (primera vez) o 'Skipped'
$r = ./Copy-ItemStrict.ps1 -Source './Copy-ItemStrict.ps1' -Destination $dest
$r.Status
$r.Target

Más adelante veremos cómo esta salida estructurada permite encadenar decisiones en pipelines, sin depender de try / catch externos.

Piensa rápido

  • Repite la copia para observar Skipped y el Reason.
  • Intenta copiar una carpeta sin -Recurse para ver Failed y leer Error.Kind/Error.Message.

Esquema de salida y estabilidad

Este ejemplo define un contrato explícito: siempre devuelve Source/Destination/Target y añade Status para que el consumidor no tenga que inferir qué pasó leyendo excepciones o texto. Nombrar Cancelled de forma directa evita el antipatrón de asumir que «no se hizo nada» solo porque falta información, algo frágil en automatización.

Hay un compromiso
Un esquema estable/tabular (mismas propiedades siempre, ideal para filtrar o agrupar por Status en un pipeline) versus uno flexible (propiedades opcionales según el caso). Aquí todavía no usamos el pipeline, así que privilegiamos claridad por encima de rigidez total.
Regla práctica
Si otros scripts consumirán tu salida, prefiere un esquema estable; si es consumo humano puntual, puedes permitir más flexibilidad.
Nota
Al construir con @{} (hashtable) puedes perder el orden de propiedades. Si necesitas un orden fijo (por ejemplo, para una tabla), crea el [PSCustomObject] desde el inicio y agrega propiedades con Add-Member .

catch tipado

Puedes definir varios bloques catch para distintos tipos de excepción y cerrar con uno genérico. Ordénalos de los más específicos a los más generales.

Orden de catch y uso práctico
try {
    Copy-Item @copyParams
}
catch [System.UnauthorizedAccessException] {
    Write-Warning ('Permisos insuficientes: {0}' -f $_.Exception.Message)
}
catch [System.IO.IOException] {
    Write-Warning ('Problema de E/S: {0}' -f $_.Exception.Message)
}
catch {
    Write-Warning ('Error {0}: {1}' -f $_.Exception.GetType().Name, $_.Exception.Message)
}

En PowerShell es común incluir un catch genérico porque muchos cmdlets encapsulan las excepciones .NET dentro de objetos [ErrorRecord] . En la práctica esto implica que el tipo de excepción original no siempre es el que llega al catch , por lo que tipar puede fallar. El catch genérico actúa como última línea de defensa: garantiza que el error no se pierda ni quede sin manejar.

Regla práctica
Tipa catch solo cuando esperas un fallo concreto y recurrente; mantén siempre un catch genérico como respaldo. En ambos casos, el objetivo es convertir el error en información útil (por ejemplo en Error) para quien consuma el resultado.

Verificar y ejecutar comandos externos con seguridad

Patrón simple: resolver el binario real con Get-Command , ejecutarlo con el call operator & , y validar $LASTEXITCODE . Devuelve un objeto con datos útiles para logs o para controlar el flujo.

Wrapper seguro para comandos externos
scripts/tools/Invoke-Tool.ps1
#Requires -Version 7.5
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrWhiteSpace()]
    [string] $Name,

    [Parameter(ValueFromRemainingArguments)]
    [ValidateNotNull()]
    [string[]] $Rest = @()
)

Set-StrictMode -Version 3.0

$cmds = @(Get-Command -Name $Name -CommandType Application -ErrorAction Stop)
if ($cmds.Count -gt 1) {
    Write-Warning ("Multiple executables named '{0}' were found. Using: {1}" -f 
        $Name, $cmds[0].Source)
}
$path = $cmds[0].Source

$originalEncoding = [Console]::OutputEncoding
try {
    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
    $out = & $path @Rest 2>&1
}
finally {
    [Console]::OutputEncoding = $originalEncoding
}

if ($LASTEXITCODE -eq 0) {
    [PSCustomObject]@{
        ToolPath = $path
        ExitCode = $LASTEXITCODE
        Output   = $out
    }
}
else {
    $nl = [Environment]::NewLine
    $msg = ('{0} {1} returned exit code {2}.{3}Output:{3}{4}' -f $Name, ($Rest -join ' '), 
        $LASTEXITCODE, $nl, ($out -join $nl))
    throw [System.Exception]::new($msg)
}

Detalles clave

  • ValueFromRemainingArguments : todo lo que quede después de -Name se captura en $Rest sin intentar interpretarlo como parámetros del script. Así puedes pasar flags arbitrarios al binario: Invoke-Tool -Name git -C . status --porcelain .
  • Get-Command ... -ErrorAction Stop : resuelve el ejecutable real (solo de tipo Application) a partir de todos los candidatos que encuentra (devuelve una colección). Aquí usamos una heurística simple (primer candidato), suficiente para scripts controlados; si el entorno es más complejo, puedes filtrar por ruta/versión. Con -ErrorAction Stop convertimos «no encontrado» en error terminante.
  • Seguridad (PATH hijacking): resolver el binario con Get-Command y ejecutar su ruta ( $path ) reduce el riesgo de invocar otro ejecutable con el mismo nombre por manipulación del PATH.
  • Forzar arreglo con @() : envolver la llamada a Get-Command en @(...) garantiza que el resultado sea siempre un arreglo (0, 1 o más elementos). De este modo .Count y la indexación ( $cmds[0] ) funcionan de forma consistente sin errores sorpresa cuando solo hay un candidato o ninguno.
  • [CommandInfo].Source : ruta absoluta al binario resuelto (p. ej., C:\Program Files\Git\cmd\git.exe). Se usa para invocarlo de forma inequívoca.
  • [Console]::OutputEncoding : establece UTF-8 temporalmente para capturar stdout/stderr con caracteres especiales. Se restaura en finally para no afectar la sesión.
  • Línea clave: $out = & $path @Rest 2>&1 : ejecuta el binario ( & ), pasa argumentos sin reinterpretarlos ( @Rest ) y captura stdout + stderr juntos ( 2>&1 ).
  • $LASTEXITCODE : código de salida del proceso nativo. 0 = éxito; cualquier otro se trata como error y se eleva con un mensaje detallado. Nota: $LASTEXITCODE solo aplica a procesos nativos; los cmdlets de PowerShell reportan fallos vía errores, no por exit codes.
  • throw [System.Exception]::new($msg) : promueve el fallo a error terminante con contexto (comando, argumentos, código y salida), ideal para CI/CD y manejo en niveles superiores con try / catch .

Arreglos en PowerShell

En PowerShell, un arreglo ( [T[]] ) es una colección ordenada de elementos. Es, además, la forma “natural” de representar cero, uno o muchos resultados: muchos comandos devuelven un arreglo cuando hay múltiples valores. Para normalizar, envolver una expresión en @() garantiza que el resultado sea siempre un arreglo (aunque tenga 0 o 1 elemento). Declaras un arreglo vacío con @() o simplemente listando valores separados por comas:

Sintaxis básica de arreglos
# Arreglo vacío
$empty = @()

# Arreglo con valores
$tools = @('git', 'curl', 'wget')
# O simplemente:
$tools = 'git', 'curl', 'wget'

# Acceso por índice (base cero)
$tools[0]  # 'git'
$tools[1]  # 'curl'

# Contar elementos
$tools.Count  # 3

En Invoke-Tool.ps1 , el parámetro $Rest es un arreglo de strings ( [string[]] ) que captura todos los argumentos restantes gracias a ValueFromRemainingArguments . Luego, @Rest (splatting) expande ese arreglo como argumentos individuales al comando nativo. Esto evita un error común: pasar $Rest sin splatting enviaría el arreglo como un solo argumento.

Importante

Los arreglos son de tamaño fijo; operaciones como += crean un nuevo arreglo.

Streams de salida en PowerShell

PowerShell no se limita a imprimir texto: maneja múltiples streams de salida, lo que permite separar mensajes de éxito, errores, advertencias, depuración, etc. Esto hace posible redirigirlos o tratarlos de forma independiente en un mismo script.

La idea clave para esta etapa es simple: distinguir entre datos y mensajes. En la práctica, devolver objetos (por ejemplo un [PSCustomObject] ) por el pipeline es equivalente a usar Write-Output .

De momento solo nos interesa la idea general: el stream 1 se usa para la salida normal, y el resto (2–6) sirven para errores, advertencias, mensajes verbosos, depuración, etc. El stream 6 (Information) es útil para mensajes informativos que el consumidor puede mostrar, suprimir o redirigir según su configuración. Más adelante veremos con detalle cómo usar Write-Error para propagar errores no terminantes en el pipeline; por ahora nos apoyaremos sobre todo en salida estructurada y dejaremos la responsabilidad de imprimir en pantalla a quien consuma estos scripts (otros cmdlets, pipelines o herramientas de automatización), que decidirán qué mostrar, cuándo y cómo.

Streams de PowerShell
Stream Descripción Write Cmdlet
1 Éxito (Output) Write-Output
2 Error Write-Error
3 Warning Write-Warning
4 Verbose Write-Verbose
5 Debug Write-Debug
6 Information Write-Information
n/a Progress Write-Progress

Ejercicio: Fallback por comando

Requisitos

  • Implementa Invoke-WebRequestByFallback.ps1 que intente ejecutar una tarea usando una cadena de alternativas de binarios. No interrogues el sistema operativo; en su lugar, resuelve comandos (con Invoke-Tool.ps1 ) y usa try / catch para avanzar al siguiente. La idea es que el comportamiento dependa de la disponibilidad real de herramientas, no del “entorno declarado” (plataforma/versión).
  • Escenario propuesto (puedes elegir otro): descargar un archivo probando primero wget y luego curl.
  • El script debe:
    • Interfaz: recibir -Uri y -OutFile (obligatorios).
    • Estrategia de fallback: intentar wget; si falla, intentar curl (en el catch ).
    • Fallos explícitos: si ambos fallan, throw con un mensaje consolidado.
    • Contrato de salida: devolver un objeto estructurado con el estado de la operación.
    • Objetivo: la descarga es un pretexto; lo importante es modelar una cadena de alternativas con fallos explícitos y un resultado verificable.

Notas

wget

Descargador común en Linux/BSD; puede instalarse en macOS/Windows. Usa -O para definir el archivo de salida.

Uso básico con wget
# Descargar a un archivo específico
wget -O "<OutFile>" "<Uri>"

# Ejemplo
wget -O "./download/file.txt" "https://example.com/file.txt"

curl

Cliente de transferencia que viene preinstalado en macOS y muchas distros Linux; en Windows 10+ suele estar disponible. Úsalo con -L para seguir redirecciones y -o para especificar archivo de salida.

Uso básico con curl
# Descargar siguiendo redirecciones
curl -L -o "<OutFile>" "<Uri>"

# Ejemplo
curl -L -o "./download/file.txt" "https://example.com/file.txt"

Uso esperado

Ejecución
$params = @{
    Uri     = 'https://www.toptal.com/developers/gitignore/api/powershell,windows,linux,macos,visualstudiocode'
    OutFile = '.gitignore'
    Verbose = $true
}
./Invoke-WebRequestByFallback.ps1 @params

Hints

  • Usa Join-Path ... -Resolve para obtener la ruta real de Invoke-Tool.ps1 y fallar temprano si no existe.
    Invocador seguro
    $invoker = Join-Path $PSScriptRoot '..' 'tools' 'Invoke-Tool.ps1' -Resolve
    # Ejemplo de uso con call operator (&) y argumentos:
    & $invoker wget -O './out.txt' 'https://example.com/file.txt'
    -Resolve valida la ruta en tiempo de inicio; evita fallos tardíos.
  • Prefiere tipar [uri] $Uri y validar el esquema en lugar de [string] :
    Validación de URL robusta
    [Parameter(Mandatory)]
    [ValidateScript({ $_.Scheme -in @('http','https') })]
    [uri] $Uri
    El tipo [uri] parsea la dirección; el [ValidateScript] asegura http/https.

Solución

#Requires -Version 7.5
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ $_.Scheme -in @('http','https') })]
    [uri] $Uri,

    [Parameter(Mandatory)]
    [ValidateNotNullOrWhiteSpace()]
    [string] $OutFile
)

Set-StrictMode -Version 3.0

$invoker = Join-Path $PSScriptRoot '..' 'tools' 'Invoke-Tool.ps1' -Resolve

if ($PSCmdlet.ShouldProcess("$Uri to $OutFile", 'Download file')) {
    try {
        & $invoker wget -O $OutFile $Uri
        [PSCustomObject]@{
            Uri    = $Uri
            OutFile = $OutFile
            Tool   = 'wget'
            Status = 'Success'
        }
    }
    catch {
        Write-Warning ('wget failed: {0}' -f $_.Exception.Message)
        try {
            & $invoker curl -L -o $OutFile $Uri
            [PSCustomObject]@{
                Uri    = $Uri
                OutFile = $OutFile
                Tool   = 'curl'
                Status = 'Success'
            }
        }
        catch {
            Write-Warning ('curl failed: {0}' -f $_.Exception.Message)
            throw [System.Exception]::new("Both wget and curl failed to download $Uri")
        }
    }
}

Conclusiones

El hilo conductor de esta lección es que, en PowerShell, el manejo de errores es una decisión de diseño y una herramienta explícita de control de flujo. Cuando la integridad importa, eleva fallos con -ErrorAction Stop y manéjalos con try / catch / finally ; cuando buscas reportar sin interrumpir, prefiere errores no terminantes. Al ejecutar binarios externos, resuélvelos primero ( Get-Command ), ejecútalos con & y valida con $LASTEXITCODE o un wrapper como Invoke-Tool.ps1 , devolviendo salida estructurada en lugar de texto plano.

Puntos clave

  • Promueve errores críticos con -ErrorAction Stop para activar catch donde puedas decidir cómo reaccionar.
  • En catch , $_ es el [ErrorRecord] (tipo y mensaje en $_.Exception ).
  • Resuelve binarios con Get-Command -ErrorAction Stop y llama al path real.
  • Ejecuta con & y valida con $LASTEXITCODE (o tu wrapper).
  • 2>&1 mezcla stderr en stdout para capturar todo en una variable cuando te convenga simplificar el flujo.

¿Qué nos llevamos?

El buen manejo de errores es diseño, no accidente. Elevar fallos a terminantes, envolver herramientas externas y devolver objetos no es “ser más estricto”: es ser más predecible. Si registras con intención, priorizas la salida estructurada sobre el texto suelto y «fail loudly» cuando corresponde, tus scripts serán más confiables, portables y fáciles de mantener.

¿Con ganas de más?

Referencias adicionales

  • “Errors and exceptions” (pp. 531–562) en Windows PowerShell in Action, Third Edition por Bruce Payette y Richard Siddaway
    Capítulo estructurado sobre el sistema de manejo de errores de PowerShell como herramienta de automatización crítica. Explica la distinción fundamental entre errores terminantes (que interrumpen el flujo) y no terminantes (que continúan pero registran fallos), alineados con el modelo de streaming de PowerShell. Cubre objetos ErrorRecord con sus propiedades ricas (Exception, TargetObject, CategoryInfo, InvocationInfo), cómo capturar errores usando redirección ( 2>&1 ), el parámetro -ErrorVariable , y variables de estado ( $? , $LASTEXITCODE ). Incluye patrones para diagnosticar y gestionar errores en scripts, especialmente útil para entender cuándo un fallo debe detener la ejecución y cuándo debe reportarse sin interrumpir un pipeline.
  • about_Redirection en la documentación oficial de PowerShell
    Breve guía de redirección en PowerShell: explica cómo enviar salida a archivos y combinar flujos (éxito, error, warning, etc.) usando Out-File , Tee-Object y operadores de redirección ( > , >> , n>&1 , *> ). Incluye tabla de flujos redireccionables, cambios en PowerShell 7.4 para redirigir stdout de comandos nativos (conservando bytes), ejemplos prácticos (fusionar 2>&1 , redirigir todos los flujos, suprimir Information/Host), efectos de Action Preferences, consideraciones de codificación y ancho de salida, y notas sobre confusiones comunes con > vs. comparadores ( -gt , -lt ).
  • about_Try_Catch_Finally en la documentación oficial de PowerShell
    Descripción formal de cómo usar try / catch / finally para manejar errores terminantes en PowerShell. Presenta la sintaxis general de los bloques, muestra cómo envolver código que puede fallar, cómo disponer de varios bloques catch (genéricos y tipados) y en qué orden se evalúan, y explica qué ocurre con las excepciones no capturadas. Detalla la relación con trap y con las preferencias de error, cómo acceder a la información de la excepción vía $_ / $PSItem y las propiedades de [ErrorRecord] (CategoryInfo, TargetObject, etc.), y cómo usar finally para liberar recursos o dejar el sistema en un estado consistente. Incluye ejemplos prácticos y notas sobre buenas prácticas para escribir bloques catch claros y seguros.