Private/Invoke-PatApi.ps1

function Invoke-PatApi {
    <#
    .SYNOPSIS
        Invokes the Plex API.
 
    .DESCRIPTION
        Internal function that sends HTTP requests to the Plex API and returns the response.
        Includes automatic retry with exponential backoff for transient errors such as
        DNS failures, connection timeouts, and rate limiting (503/429).
 
    .PARAMETER Uri
        The complete URI to call
 
    .PARAMETER Method
        The HTTP method to use (default: Get)
 
    .PARAMETER Headers
        Optional headers to include in the request (default: Accept = application/json)
 
    .PARAMETER MaxRetries
        Maximum number of retry attempts for transient errors (default: 3)
 
    .PARAMETER BaseDelaySeconds
        Base delay in seconds for exponential backoff (default: 1)
        Actual delays will be: 1s, 2s, 4s for the default value
 
    .OUTPUTS
        PSCustomObject
        Returns the MediaContainer object from the Plex API response if present, otherwise returns the full response
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Uri,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Method = 'Get',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [hashtable]
        $Headers = @{
            Accept = 'application/json'
        },

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 10)]
        [int]
        $MaxRetries = 3,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 60)]
        [int]
        $BaseDelaySeconds = 1
    )

    # Warn if using HTTP with authentication token (only once per session to avoid spam)
    if ($Uri -match '^http://' -and $Headers.ContainsKey('X-Plex-Token')) {
        if (-not $script:HttpWarningShown) {
            $script:HttpWarningShown = $true
            Write-Warning "Sending authentication token over unencrypted HTTP connection. Consider using HTTPS."
        }
    }

    $apiQueryParameters = @{
        Method      = $Method
        Uri         = $Uri
        Headers     = $Headers
        ErrorAction = 'Stop'
    }
    Write-Debug 'Invoking Plex API with the following parameters:'
    $apiQueryParameters | Out-String | Write-Debug

    # DNS failures. The "no data of the requested type" wording is the
    # WSANO_DATA (11004) socket error that Windows raises when a hostname
    # resolves but has no record of the queried type (commonly: only AAAA
    # is present and the resolver asked for A). It is often transient while
    # DNS records propagate or caches recover. Defined once so the retry
    # classification and the post-exhaustion guidance branch cannot drift.
    $dnsErrorPattern = 'No such host|DNS|name.+not.+resolve|no data of the requested type'

    # Helper function to determine if an error is transient and should be retried
    function Test-TransientError {
        param([System.Management.Automation.ErrorRecord]$ErrorRecord)

        $message = $ErrorRecord.Exception.Message

        if ($message -match $dnsErrorPattern) {
            return $true
        }

        # Connection/timeout issues
        if ($message -match 'timed out|timeout|connection.+refused|connection.+reset|unable to connect') {
            return $true
        }

        # Server-side transient errors (rate limiting, service unavailable)
        if ($message -match '503|429|temporarily unavailable|service unavailable|too many requests') {
            return $true
        }

        return $false
    }

    $lastError = $null

    for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
        try {
            $response = Invoke-RestMethod @apiQueryParameters

            # Handle case where response is returned as JSON string (some servers/content-types)
            # Check for both JSON objects ({) and arrays ([)
            $trimmedResponse = if ($response -is [string]) { $response.TrimStart() } else { $null }
            if ($trimmedResponse -and ($trimmedResponse.StartsWith('{') -or $trimmedResponse.StartsWith('['))) {
                Write-Debug "Response is JSON string, parsing with -AsHashtable..."
                # Use -AsHashtable to handle Plex API's case-sensitive keys (e.g., "guid" and "Guid")
                # Then convert back to PSCustomObject for consistent property access patterns
                $hashtable = $response | ConvertFrom-Json -AsHashtable -Depth 100
                $response = ConvertTo-PsCustomObjectFromHashtable -Hashtable $hashtable
            }

            if ($response.PSObject.Properties['MediaContainer']) {
                return $response.MediaContainer
            }
            return $response
        }
        catch {
            $lastError = $_

            # Check if this is a transient error that should be retried
            $isTransient = Test-TransientError -ErrorRecord $_

            if (-not $isTransient -or $attempt -eq $MaxRetries) {
                # Non-transient error or final attempt - throw immediately
                $errorMessage = $_.Exception.Message

                # 401 indicates a missing, expired, or invalid token. Surface
                # actionable guidance instead of the raw HTTP error so callers
                # know which cmdlets to run to recover. Match \b401\b only —
                # bare "Unauthorized" is too broad (UnauthorizedAccessException,
                # 403 bodies mentioning the word, etc. would misfire).
                if ($errorMessage -match '\b401\b') {
                    throw ("Plex API returned 401 Unauthorized. The authentication token is missing, expired, or invalid. " +
                        "To resolve: refresh the token with 'Update-PatServerToken' (use -Name to target a non-default server), " +
                        "list configured servers with 'Get-PatStoredServer', " +
                        "or pass an explicit -Token parameter to the cmdlet you are calling. " +
                        "Original error: $errorMessage")
                }

                # If retries were exhausted on a DNS resolution failure,
                # surface a single actionable message rather than the raw
                # socket error wrapped through three layers of cmdlets.
                # Timeouts and connection-refused errors are also transient
                # but have a different recovery story (server health, network,
                # firewall) so they fall through to the generic message.
                if ($isTransient -and $attempt -eq $MaxRetries -and $errorMessage -match $dnsErrorPattern) {
                    throw ("Plex API request failed after $MaxRetries attempts: DNS could not resolve the server hostname. " +
                        "To resolve: verify the hostname with 'Resolve-DnsName <host>' (try -Type A and -Type AAAA), " +
                        "confirm reachability with 'Test-NetConnection <host> -Port <port>', " +
                        "check the stored URI with 'Get-PatStoredServer', " +
                        "and re-add the server with 'Add-PatServer -Force' if the address has changed. " +
                        "Original error: $errorMessage")
                }

                throw "Error invoking Plex API: $errorMessage"
            }

            # Calculate exponential backoff delay
            $delay = $BaseDelaySeconds * [Math]::Pow(2, $attempt - 1)
            Write-Verbose "Transient error on attempt $attempt of $MaxRetries. Retrying in ${delay}s. Error: $($_.Exception.Message)"
            Start-Sleep -Seconds $delay
        }
    }

    # Should not reach here, but just in case
    if ($lastError) {
        throw "Error invoking Plex API: $($lastError.Exception.Message)"
    }
}