Public/Retry/TransientErrorStrategies/New-TransientPowerShellModuleInstallRetryStrategy.ps1

<#
.NOTES
    Dot-sourced by Infrastructure.Common.psm1. The public surface is
    New-TransientPowerShellModuleInstallRetryStrategy; Test-TransientPowerShellModuleInstallException
    is a file-private helper kept alongside the factory so the
    classification policy lives next to its sole consumer.
#>


# ---------------------------------------------------------------------------
# Test-TransientPowerShellModuleInstallException (private)
# Returns $true when an Install-Module / Install-Package failure looks
# like a transient PSGallery-specific issue (source resolution).
# Generic transient network failures (DNS, timeout, 5xx) are out of
# scope here - they are handled by New-TransientNetworkRetryStrategy's
# message-based fallback path, OR-composed by callers that face the
# wrapped-exception problem (see Invoke-ModuleInstall).
#
# Classification is by message pattern because PowerShellGet wraps
# its underlying failures in generic exception types. The pattern
# list will need occasional refresh as PowerShellGet wording shifts -
# the cost of a missed pattern is a real config error masked as
# transient and retried for the full attempt budget (~5 min) before
# failing, which is the wrong direction to err in. So keep the list
# narrow and aimed at observed flake modes.
#
# Why not match the broad "No match was found" string: it is also what
# Install-Module emits for a genuine typo. Treating it as transient
# would make every misspelt module name wait 5 min before failing.
# Invoke-ModuleInstall's call site promotes the *warning-stream* source-
# resolution message into the error text so the truly-transient case
# still matches one of the patterns below even when the terminating
# error itself says only "No match was found".
# ---------------------------------------------------------------------------

# Single source of truth for PSGallery source-resolution patterns.
# Two consumers feed strings into this helper:
# 1. Test-TransientPowerShellModuleInstallException below, against the
# combined ErrorRecord message text.
# 2. Invoke-ModuleInstall, against each captured warning's .Message
# so it can decide whether to promote the warning into the error
# message before rethrowing.
# Keeping the pattern list in one place prevents the two consumers from
# drifting out of sync as PowerShellGet wording shifts.
#
# Why two consumers - one signal arrives via two channels:
# Today, PowerShellGet emits the source-resolution text only in the
# warning stream; the terminating error itself is the ambiguous
# "No match was found". So in practice consumer #2 (warning check in
# Invoke-ModuleInstall) is what catches the actual flake, and #1 (the
# strategy's check against the error text) only matches because #2
# first promoted the warning text into the error.
#
# The strategy's re-check is therefore *defensive belt-and-braces*: if
# a future PowerShellGet version starts surfacing source-resolution
# text directly in the terminating error (skipping the warning stream),
# the strategy still classifies it correctly without any change to
# Invoke-ModuleInstall. Cost is one extra regex evaluation per failed
# attempt - negligible compared to the retry sleep budget.
function Test-PSGallerySourceResolutionMessage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Message
    )

    $transientPatterns = @(
        'Unable to resolve package source',
        'package source.*(unavailable|not\s+available|not\s+found)'
    )

    foreach ($pattern in $transientPatterns) {
        if ($Message -match $pattern) { return $true }
    }
    return $false
}

function Test-TransientPowerShellModuleInstallException {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord] $ErrorRecord
    )

    # Both Exception.Message and ErrorDetails.Message are checked because
    # PowerShellGet sometimes populates only the latter (the operator-
    # facing text) while leaving Exception.Message as a generic wrapper.
    $messages = @()
    if ($ErrorRecord.Exception)    { $messages += $ErrorRecord.Exception.Message }
    if ($ErrorRecord.ErrorDetails) { $messages += $ErrorRecord.ErrorDetails.Message }
    $combined = ($messages -join ' ')

    return (Test-PSGallerySourceResolutionMessage -Message $combined)
}

function New-TransientPowerShellModuleInstallRetryStrategy {
    <#
    .SYNOPSIS
        Builds a retry strategy hashtable that matches transient
        PSGallery-specific failures (source resolution) emitted by
        Install-Module / Install-Package. Generic network failures
        (DNS, timeout, 5xx) are handled by New-TransientNetworkRetryStrategy
        and should be OR-composed alongside this strategy.
 
    .DESCRIPTION
        Returned shape is the standard retry-strategy contract consumed by
        Invoke-WithRetry:
 
            @{
                Name = 'TransientPowerShellModuleInstall'
                ShouldRetry = { param($err) <bool> }
            }
 
        Scope is intentionally PSGallery-only (see file header for the
        rationale). Permanent failures (typos, publisher-signature
        mismatches, auth) propagate immediately.
 
    .EXAMPLE
        Invoke-WithRetry `
            -ScriptBlock { Install-Module Foo -ErrorAction Stop } `
            -RetryStrategy @(
                (New-TransientPowerShellModuleInstallRetryStrategy),
                (New-TransientNetworkRetryStrategy)
            ) `
            -MaxAttempts 6
    #>

    [CmdletBinding()]
    param()

    return @{
        Name        = 'TransientPowerShellModuleInstall'
        ShouldRetry = {
            param([System.Management.Automation.ErrorRecord] $ErrorRecord)
            Test-TransientPowerShellModuleInstallException -ErrorRecord $ErrorRecord
        }
    }
}