Структурированная обработка исключений превращает скрипт из хрупкой последовательности команд в надёжный инструмент, который продолжает работу после сбоя, фиксирует контекст проблемы и возвращает системе сборки понятный результат. Подход try/catch/finally задаёт единый язык реакции на ошибку: операция объявляется рискованной, обработчик принимает только известные классы исключений, а блок уборки гарантированно освобождает ресурсы независимо от исхода. Ниже разбирается разница между исключениями и кодами возврата, особенности модели PowerShell с её терминирующими и нетерминирующими ошибками, приёмы повторных попыток с растущей задержкой, журналирование с техническим контекстом и соглашения о пустой нагрузке.

Исключение против кода возврата

Классическая модель кодов возврата обязывает вызывающую сторону проверять результат после каждого вызова. В оболочке это переменная errorlevel у команды cmd или $LASTEXITCODE в PowerShell. Модель рабочая, но она переносит ответственность на всех: один пропущенный if, и ошибка проходит молча, а дальнейшие команды выполняются уже над повреждённым состоянием.

Исключение меняет направление ответственности. Ошибка сама прерывает поток выполнения и поднимается по стеку, пока её не перехватят. Вызывающая сторона не обязана помнить о проверке, потому что нормальный путь и аварийный путь разведены синтаксисом. Код внутри try исполняет успешный сценарий, а весь давящий шум проверок сосредоточен в catch.

У исключения есть и содержательное преимущество: оно несёт объект с типом, сообщением, стеком вызовов и вложенной причиной. Код возврата в этом смысле беден: число 53 говорит меньше, чем тип FileNotFoundException с полным путём в свойстве. Поэтому зрелые сценарии конвертируют коды выхода внешних утилит в исключения, чтобы дальше работать с ними единообразно.

Стоит понимать и обратную сторону. Исключение дороже кода возврата на горячих путях, и оно неуместно для ожидаемых ветвлений логики. Если отсутствие файла является нормальным исходом поиска, корректнее вернуть пустой список, а не бросать исключение. Граница проста: исключение означает нарушение ожиданий, а пустая нагрузка означает законное отсутствие данных. Договорённость об этом в интерфейсе каждой функции снимает добрую половину споров о том, где нужен try.

Структура блока и роль finally

Конструкция try/catch/finally делит работу на три зоны. В try помещаются только те операторы, которые реально могут выбросить исключение: чтение файла, сетевой запрос, обращение к реестру. Раздувание try на весь сценарий затрудняет чтение и маскирует место возникновения ошибки.

Блок finally исполняется всегда, при успехе, при перехваченной ошибке и при нерешённой ошибке, уходящей выше. Именно здесь освобождаются ресурсы: закрывается дескриптор файла, завершается соединение, возвращается прежнее значение переменной окружения или позиции каталога. Классическая ошибка новичка состоит в том, что очистку пишут в конец try. Первая же ошибка опускает эти строки, и дескриптор утекает, а следующий запуск скрипта упирается в заблокированный файл.

Полезный приём состоит в явном сохранении контекста до изменения среды. Если тело try переключает расположение или отключает проверку сертификата для одного вызова, то finally возвращает прежнее состояние. Такой скрипт можно вызывать из чужого кода, не опасаясь побочных эффектов.

  1. Сохранить исходный контекст перед входом в try.
  2. Выполнить рискованные операции в минимальном теле try.
  3. Перехватить конкретные классы исключений в отдельных catch.
  4. Завершить нерешённую ситуацию пробросом или перевыбросом.
  5. Освободить ресурсы и вернуть контекст в finally.

Порядок блоков catch имеет значение: сначала идут специализированные типы, затем более общие. Родительский класс, поставленный первым, съест всё, включая потомков, и диагностика окажется обеднённой.

Конкретная ловля вместо глотания всего

Пустой обработчик catch { } считается главным антипримером обработки ошибок. Он скрывает как предсказуемые сбои, так и дефекты самого сценария: опечатка в имени переменной, деление на ноль и ошибка доступа приобретают одинаковый вид, а именно полное молчание. Поддержка такого кода превращается в угадывание.

Правильная стратегия предписывает перехватывать только те классы, которые сценарий реально умеет обработать. Сетевой таймаут компенсируется повтором, отсутствующий файл создаётся заново, занятый дескриптор приводит к короткой паузе. Всё неизвестное должно продолжить движение вверх, чтобы вызывающий уровень увидел правду.

В PowerShell конкретика указывается в квадратных скобках перед блоком:

try {
    $data = Invoke-WebRequest -Uri $url -TimeoutSec 10 -ErrorAction Stop
}
catch [System.Net.WebException] {
    Write-Warning ("Сетевой сбой при запросе " + $url + ": " + $_.Exception.Message)
}
catch [System.TimeoutException] {
    Write-Warning "Истёк таймаут, запрос будет повторён уровнем выше"
    throw
}

Обработка только известного класса дисциплинирует ещё одним способом: она заставляет автора заранее перечислить, какие именно сбои ожидает операция. Этот перечень становится частью документации функции и основой для тестирования.

Проброс и повторный выброс ошибки

Оператор throw без аргумента внутри catch перевыбрасывает текущее исключение с сохранением исходного стека и типа. Форма throw $_.Exception создаёт новый стек и срезает историю, поэтому её избегают, когда важна диагностика.

Проброс вверх применяется, когда текущий уровень может добавить контекст, но не может принять решение. Типичная схема выглядит так: нижний уровень перехватывает исключение, дополняет его данными о параметрах вызова и перевыбрасывает, средний уровень решает, повторять операцию или сворачиваться, верхний уровень формирует итоговый код выхода.

Есть и противоположный приём, когда внутреннее исключение упаковывается во внешнее с понятным прикладным смыслом. Операция загрузки отчёта может обернуть разные транспортные ошибки в единый тип ReportLoadException, записав исходное исключение в поле вложенной причины. Вызывающий код видит одну семантику, а диагност сохраняет доступ к корню.

Модель ошибок PowerShell

PowerShell разделяет ошибки на терминирующие и нетерминирующие. Терминирующая ошибка прерывает конвейер и ловится в try/catch. Нетерминальная ошибка записывается в поток ошибок и в переменную $Error, а выполнение продолжается. Поведение командлета переключается параметром ErrorAction: значение Stop превращает ошибку в терминирующую, Continue возвращает молчаливое продолжение, а SilentlyContinue прячет даже запись.

Именно поэтому конструкция try/catch вокруг командлета без ErrorAction Stop часто не срабатывает. Командлет вежливо записал ошибку и пошёл дальше, а обработчик остался пустым. Правило простое: любой командлет, сбой которого должен вести в catch, вызывается с ErrorAction Stop либо в области действует $ErrorActionPreference = Stop.

Отдельной сущностью служит оператор trap. В отличие от try, привязанного к ограниченному блоку, trap действует в своей области видимости и перехватывает ошибки от любого оператора внутри неё, включая вложенные вызовы. После срабатывания trap либо продолжает выполнение в той же области при ключевом слове continue, либо передаёт ошибку выше через break. Современные скрипты предпочитают try/catch из-за предсказуемости, а trap оставляют как предохранитель верхнего уровня.

Транзакции и откат по исключению

Многошаговые операции требуют атомарности: либо все шаги завершились, либо ни один. Периметр атомарности совпадает с периметром try, а код отката размещается в catch или finally.

$moved = @()
try {
    foreach ($file in $batch) {
        Move-Item $file.FullName $targetDir -ErrorAction Stop
        $moved += $file.FullName
    }
    Update-Index $targetDir -ErrorAction Stop
}
catch {
    foreach ($path in $moved) {
        Move-Item (Join-Path $targetDir (Split-Path $path -Leaf)) $path
    }
    Write-Error ("Пакет откачен после ошибки: " + $_.Exception.Message)
    throw
}
finally {
    Remove-Item $tempLock -Force -ErrorAction SilentlyContinue
}

Откат сам может ошибаться, поэтому его пишут максимально простым и идемпотентным: повторный запуск отката не должен ухудшать состояние. Журнал фиксирует, какие шаги завершились, чтобы при деградации системы оператор восстановил картину вручную.

Журналирование с контекстом и повторные попытки

Ошибка без контекста почти бесполезна. Строка журнала должна содержать имя операции, ключевые параметры, тип исключения, сообщение и время. Чувствительные данные, токены и пароли, в журнал не попадают.

function Write-ErrorLog {
    param($Operation, $Context, $ErrorRecord)
    $entry = [ordered]@{
        time      = (Get-Date).ToString("s")
        operation = $Operation
        context   = $Context
        type      = $ErrorRecord.Exception.GetType().FullName
        message   = $ErrorRecord.Exception.Message
        target    = $ErrorRecord.TargetObject
    }
    ($entry | ConvertTo-Json -Compress) | Add-Content $logPath -Encoding UTF8
}

Сетевые сбои часто временны, поэтому стандартным контрприёмом служит повтор с экспоненциальной задержкой: пауза удваивается после каждой неудачи, а сверху добавляется небольшой случайный разброс, чтобы параллельные клиенты не синхронизировались в один момент. Число попыток ограничено, и после исчерпания лимита исключение уходит выше.

$delay = 2
for ($attempt = 1; $attempt -le 5; $attempt++) {
    try {
        return (Invoke-RestMethod -Uri $url -TimeoutSec 15 -ErrorAction Stop)
    }
    catch [System.Net.WebException] {
        if ($attempt -eq 5) { throw }
        Start-Sleep -Seconds ($delay + (Get-Random -Maximum 2))
        $delay *= 2
    }
}

Повторяют только те ошибки, которые имеют смысл повторять: таймаут, временная недоступность, кратковременный отказ. Ошибка авторизации или отсутствующий ресурс не лечатся ожиданием, и цикл повторов лишь откладывает диагноз.

Пустая нагрузка и код выхода

Функция поиска, не нашедшая совпадений, возвращает пустой массив, а не выбрасывает исключение. Пустой результат является законным ответом, с которым вызывающий код умеет работать без catch. Исключение же резервируется для ситуаций, когда сама операция не могла завершиться: ресурс недоступен, аргументы невалидны, нарушен инвариант. Это соглашение защищает поток вызывающего кода от лишних веток обработки.

Даже самый богатый исключениями скрипт обязан вернуть среде запуска ясный код выхода, потому что планировщики и системы сборки читают именно его. Итоговый якорь выглядит так: успех даёт exit 0, обработанные предупреждения дают ноль с пометкой в отчёте, нерешённое исключение превращается в устойчивый ненулевой код, например exit 1 для общего сбоя и exit 2 для ошибки конфигурации. Верхний уровень сценария перехватывает все исключения, записывает журнал и формирует код выхода, чтобы автоматика получила однозначный сигнал, а человек получил читаемую причину.

Почему ошибки ловят по имени, а не по молчанию. Наиболее поучительная ошибка скриптовой обработки - поглотитель всего подряд: непроизносимый catch ведёт к ситуации, где операция по записи важного файла не завершается, а дневник ошибок содержит тишину, не годную к аудиту. Зрелые скрипты той же задачи ловят конкретную ошибку, записывают её контекст (имя машины, путь, переданную команду) и завершаются с разумным внешним сигналом, а неопознанные ошибки всплывают в уже корректируемом виде. Механическая мантра этой практики - то, что подача жалобы сбою должна быть так же дословна, как и обработка самой задачи, и только тогда журнал становится звеном эксплуатации, а не статистическим стыдом.

Повторные попытки как единственный извинительный жест. Ретрай по шаблону экспоненциальной задержки превращает множество мимолётных простоев API или файловых операций в самоликвидирующиеся неудачи: ждать секунду, потом две, потом четыре; складывать интервалы с ограничением потолка; прекращать после заранее определённого числа попыток и сообщать об отказе - есть вечное качество признать упрямое поражение, а не глухой стеной доводить до таймаута администратора.