Private/Get-HDTFailureClass.ps1

function Get-HDTFailureClass {
    <#
        .SYNOPSIS
            Classifies a failure as Transient, Configuration or Environment.
 
        .DESCRIPTION
            "Engine code wraps each step in a single try/catch that classifies
            failures as Transient (retry per the step's retry policy),
            Configuration (bad authoring - fail fast, point at the file and
            line), or Environment (hardware/network - fail with diagnostics
            attached)."
 
            The classification decides whether the step is retried, so it is not
            decoration. The signals, in the order they are read:
 
              a timeout Environment
              FullyQualifiedErrorId starting HDTConfigurationError Configuration
              one of the named refusal ids Configuration
              System.IO.*, Win32Exception, TimeoutException Environment
              a result whose Data carries one of those ids Configuration
              anything else, including a Failed result with an
              exit code and no exception at all Transient
 
            THE RESULT-DATA LEG EXISTS BECAUSE A STEP NEVER THROWS A REFUSAL.
            The step contract (03-02) invokes every discovered type with an
            empty property bag and requires a result whose Status is in the
            closed set, so a step that let its refusal escape as a terminating
            error would turn that contract red. Every phase 04 step therefore
            catches its own refusal and returns
 
              New-HDTStepResult -Status Failed -Data @{ errorId = 'HDT...Error' }
 
            which means the id reaches this classifier through the RESULT rather
            than through an ErrorRecord. Without this leg, 04-02's "a refusal is
            never retried" would quietly stop being true the moment the refusal
            became a result, and a step declaring retry: 2 would refuse to wipe
            the same disk three times over.
 
            THE THROWN ERROR OUTRANKS THE RESULT DATA. An exception says what
            actually went wrong; the data says what the step believed.
 
            THE REFUSAL IDS ARE A NAMED LIST, NOT A WILDCARD. The
            refusal to guess which disk to wipe, and 9.2's refusal to guess
            which image index to apply, are bad authoring rather than bad luck -
            a refusal that got retried three times would spend a deployment's
            time proving the same point twice more. They carry their own ids
            rather than HDTConfigurationError so a log reader can tell a wipe
            refusal from a malformed YAML file. Matching 'HDT*Error' instead
            would silently swallow every id a later phase invents, including
            ones that really are transient.
 
            A CONFIGURATION FAILURE IS NEVER RETRIED by the caller. Retrying bad
            authoring spends a deployment's time three times over and buries the
            message that would have fixed it under two more copies of itself.
 
            A TIMEOUT OUTRANKS EVERY OTHER SIGNAL. A step that was still running
            when its bound expired did not tell us why, and "it took too long on
            this machine" is an environment fact whatever the step was doing.
 
            IT UNWRAPS TO THE INNERMOST EXCEPTION FIRST. Every real adapter is a
            ScriptMethod on a pscustomobject, and a ScriptMethod wraps whatever
            it threw in MethodInvocationException over RuntimeException
            (tests/helpers/README.md section 5). A classifier that read only the
            outer type would call every adapter failure Transient and retry a
            missing install.wim three times.
 
        .PARAMETER ErrorRecord
            The caught ErrorRecord, or a bare Exception, or nothing. Nothing is
            Transient: a step that returned a Failed result with an exit code
            reported a failure without an exception, and an exit code is the
            classic retryable case.
 
        .PARAMETER ResultData
            The Data of a Failed step result. An errorId in the configuration
            list classifies as Configuration. Anything that is not a dictionary
            or an object carrying an errorId is ignored.
 
        .PARAMETER TimedOut
            The step overran its timeoutMinutes. Environment, whatever else the
            record says.
 
        .OUTPUTS
            System.String - Transient, Configuration or Environment.
 
        .EXAMPLE
            try { ... } catch { $class = Get-HDTFailureClass -ErrorRecord $_ }
 
        .EXAMPLE
            Get-HDTFailureClass -TimedOut
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [object] $ErrorRecord,

        [Parameter()]
        [AllowNull()]
        [object] $ResultData,

        [Parameter()]
        [switch] $TimedOut
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if ($TimedOut) {
        return 'Environment'
    }

    # DESIGN 9.1 and 9.2's refusals. Named, never a wildcard.
    $configurationErrorId = @(
        'HDTConfigurationError',
        'HDTAmbiguousTargetError',
        'HDTUnsafeTargetError',
        'HDTNoTargetDiskError',
        'HDTAmbiguousImageError'
    )

    if ($null -eq $ErrorRecord) {
        # The refusal a step returned rather than threw.
        $resultErrorId = ''

        if ($null -ne $ResultData) {
            if ($ResultData -is [System.Collections.IDictionary]) {
                foreach ($key in @($ResultData.Keys)) {
                    if ([string] $key -eq 'errorId') { $resultErrorId = [string] $ResultData[$key] }
                }
            } elseif (-not ($ResultData -is [System.Collections.IList]) -and -not ($ResultData -is [string])) {
                $member = $ResultData.PSObject.Properties['errorId']
                if ($null -ne $member) { $resultErrorId = [string] $member.Value }
            }
        }

        if ($configurationErrorId -contains $resultErrorId) {
            return 'Configuration'
        }

        return 'Transient'
    }

    $exception = $ErrorRecord
    if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) {
        # The id is "<ErrorId>,<FunctionName>", so the comparison is against the
        # part before the first comma rather than the whole string.
        $errorId = ([string] $ErrorRecord.FullyQualifiedErrorId).Split(',')[0]

        if ($configurationErrorId -contains $errorId) {
            return 'Configuration'
        }

        $exception = $ErrorRecord.Exception
    }

    # Down to the original. Two layers for a real adapter, none for a fake.
    while (($exception -is [System.Exception]) -and ($null -ne $exception.InnerException)) {
        $exception = $exception.InnerException
    }

    if ($exception -is [System.IO.IOException] -or
        $exception -is [System.ComponentModel.Win32Exception] -or
        $exception -is [System.TimeoutException] -or
        $exception -is [System.UnauthorizedAccessException]) {

        return 'Environment'
    }

    return 'Transient'
}