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 verás un patrón para ejecutar comandos externos de forma segura: resolver el binario real con Get-Command , invocarlo con & , capturar salida y errores ( 2>&1 ), validar $LASTEXITCODE y elevar con contexto si falla (por ejemplo, mediante un wrapper como Invoke-Tool.ps1 ). El ejercicio final te invita a practicar estos conceptos implementando un fallback simple entre dos comandos de descarga.

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 (errores no terminantes). En cambio, cuando una operación es crítica (p. ej., escribir/borrar datos relevantes), conviene elevar el error a terminante para detener el flujo y manejarlo con try / catch . PowerShell modela explícitamente esta distinción y te da controles como -ErrorAction y $ErrorActionPreference para escalar fallos no terminantes a terminantes cuando lo requiera el guion, mientras que try / catch solo captura errores terminantes. Esto permite equilibrar robustez en lote y seguridad en operaciones críticas dentro del mismo lenguaje.

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 obtener un reporte 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 .

Este ejemplo implementa una copia segura con ShouldProcess y elevación de errores a terminantes para capturarlos con catch . El bloque finally siempre devuelve un objeto con estado de la operación.

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
}

¿Qué acabamos de hacer?

  • 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.

Uso

Ejecuta el script desde la terminal pasando un archivo y una carpeta destino:

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

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

Cuando se omite, copia o falla, el ejemplo adjunta Status y, según el caso, Reason o Error. Si ShouldProcess es rechazado (confirmación o -WhatIf ), aquí marcamos Cancelled de forma explícita. Una alternativa más simple es devolver solo el objeto base (Source/Destination/Target) sin Status, pero eso varía el esquema entre ramas.

Decide qué prefieres para tus consumidores: un esquema estable/tabular (mismas propiedades siempre, ideal para filtrar/seleccionar en pipeline) o uno más flexible/JSON (propiedades opcionales según el caso). En estas notas todavía no trabajamos con el pipeline, así que priorizamos claridad y registro por sobre la rigidez del esquema.

Nota: al construir con @ (hashtable) se puede perder el orden de propiedades; si necesitas orden estable (p. ej., para salida tabular), puedes crear un [PSCustomObject] desde el comienzo y agregar propiedades con Add-Member . Aquí elegimos la opción más simple con fines ilustrativos.

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] , cuyas categorías o tipos varían entre proveedores. Esto dificulta predecir el tipo exacto, por lo que un bloque genérico suele ser una red de seguridad útil. Aun así, cuando anticipes escenarios típicos (permisos, E/S), añade catch tipados para mensajes más claros.

En este apunte usaremos principalmente catch genéricos para simplificar los ejemplos, aunque en contextos reales conviene combinar ambos tipos según el grado de control y legibilidad que busques.

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)
}

¿Qué acabamos de hacer?

  • 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) usando la heurística simple de quedarnos con el primer resultado. Con -ErrorAction Stop convertimos «no encontrado» en error terminante. El objeto devuelto es [System.Management.Automation.CommandInfo] .
  • [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 :
    • call operator & ejecuta el binario.
    • Splatting @Rest pasa los argumentos tal cual (array → args).
    • 2>&1 : combina los flujos de salida estándar (stdout) y error estándar (stderr) en uno solo. En PowerShell (y en sistemas heredados de Unix), los números representan los streams:
      • 1 → salida estándar (stdout), lo que normalmente imprime el programa.
      • 2 → salida de error (stderr), donde van los mensajes de error.
      • > redirige la salida hacia otro destino, y & indica que ese destino es otro flujo.
      Por lo tanto, 2>&1 significa literalmente «redirige el flujo 2 (errores) al mismo lugar que el flujo 1 (salida estándar)» , lo que permite capturar todo (mensajes y errores) en la misma variable $out .
  • $LASTEXITCODE : código de salida del proceso nativo. 0 = éxito; cualquier otro se trata como error y se eleva con un mensaje detallado.
  • 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. 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.

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.

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. 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.
  • Escenario propuesto (puedes elegir otro): descargar un archivo probando primero wget y luego curl.
  • El script debe:
    • Recibir -Uri (obligatorio) y -OutFile (obligatorio).
    • Probar wget dentro de try / catch con -ErrorAction Stop .
    • Si wget falla, capturar el error y probar curl en el bloque catch (anidando otro try ).
    • Si ambos fallan, throw con un mensaje consolidado.
    • Devuelve un objeto estructurado con el estado de la operación.

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 la lección es controlar el flujo a través de los errores. 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. En esta lección vimos tres ideas clave: elevar fallos no terminantes con -ErrorAction Stop para capturarlos con try / catch cuando la integridad está en juego; envolver comandos externos (por ejemplo con Invoke-Tool.ps1) para resolver el ejecutable real, validar $LASTEXITCODE y devolver salida estructurada; y diseñar scripts alrededor de capacidades (qué herramientas hay disponibles) en lugar de suposiciones frágiles del entorno. 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

  • 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.