Private/Invoke-LMRestMethod.ps1

<#
.SYNOPSIS
Centralized wrapper for LogicMonitor REST API calls with built-in retry logic.

.DESCRIPTION
The Invoke-LMRestMethod function provides a robust wrapper around Invoke-RestMethod
that handles rate limiting, transient errors, and implements intelligent retry logic
with exponential backoff for improved reliability.

.PARAMETER Uri
The URI for the REST API endpoint.

.PARAMETER Method
The HTTP method to use (GET, POST, PATCH, PUT, DELETE).

.PARAMETER Headers
Hashtable containing HTTP headers for the request.

.PARAMETER WebSession
The web session object to use for the request.

.PARAMETER Body
The request body (typically JSON for LogicMonitor API).

.PARAMETER OutFile
The path to a file to save the response content to.

.PARAMETER MaxRetries
Maximum number of retry attempts. Default is 3.

.PARAMETER NoRetry
Switch to disable retry logic and fail immediately on any error.

.PARAMETER EnableDebugLogging
Switch to enable detailed debug logging for troubleshooting.

.EXAMPLE
$Response = Invoke-LMRestMethod -Uri $Uri -Method "GET" -Headers $Headers -WebSession $Session

.EXAMPLE
$Response = Invoke-LMRestMethod -Uri $Uri -Method "POST" -Headers $Headers -Body $JsonData -MaxRetries 5

.NOTES
This function automatically handles:
- Rate limiting (429 errors) with proper wait times
- Transient server errors (502, 503, 504) with exponential backoff
- Network timeouts and connectivity issues
- Authentication errors (401) without retry
- Client errors (4xx) without retry

.OUTPUTS
Returns the response object from the API call, or throws an exception for non-retryable errors.
#>


# Custom exception class for cleaner error output
class LMException : System.Exception {
    LMException([string]$message) : base($message) { }
    LMException([string]$message, [System.Exception]$innerException) : base($message, $innerException) { }
}

function Invoke-LMRestMethod {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [String]$Uri,

        [Parameter(Mandatory)]
        [ValidateSet("GET", "POST", "PATCH", "PUT", "DELETE")]
        [String]$Method,

        [Parameter(Mandatory)]
        [Hashtable]$Headers,

        [Microsoft.PowerShell.Commands.WebRequestSession]$WebSession,

        [String]$Body,

        [String]$OutFile,

        [ValidateRange(1, 10)]
        [Int]$MaxRetries = 3,

        [Switch]$NoRetry,

        [Switch]$EnableDebugLogging,

        [System.Management.Automation.PSCmdlet]$CallerPSCmdlet
    )

    $retryCount = 0
    $success = $false

    while (-not $success -and $retryCount -le $MaxRetries) {
        try {
            # Build parameters for Invoke-RestMethod
            $params = @{
                Uri         = $Uri
                Method      = $Method
                Headers     = $Headers
                ErrorAction = 'Stop'  # We handle errors ourselves
            }

            if ($WebSession) {
                $params.WebSession = $WebSession
            }
            if ($Body) {
                $params.Body = $Body
            }
            if ($OutFile) {
                $params.OutFile = $OutFile
            }

            # Log debug information if enabled
            if ($EnableDebugLogging) {
                Write-Debug "Attempt $($retryCount + 1): $Method $Uri"
                if ($Body -and $Body.Length -lt 1000) {
                    Write-Debug "Request Body: $Body"
                }
            }

            # Make the API call
            $Response = Invoke-RestMethod @params
            $success = $true

            if ($EnableDebugLogging) {
                Write-Debug "Request successful on attempt $($retryCount + 1)"
            }

            return $Response
        }
        catch {
            # Get detailed error information from Resolve-LMException
            $resolveParams = @{
                LMException        = $PSItem
                EnableDebugLogging = $EnableDebugLogging
            }
            $errorResult = Resolve-LMException @resolveParams

            # Special handling for 404 errors on GET operations
            if ($errorResult.ErrorType -eq 'NotFound' -and $Method -eq 'GET') {
                # For GET operations, 404 might be expected behavior
                $errorActionPreference = if ($CallerPSCmdlet) { 
                    $CallerPSCmdlet.SessionState.PSVariable.GetValue('ErrorActionPreference') 
                }
                else { 
                    $ErrorActionPreference 
                }
                
                if ($errorActionPreference -eq 'SilentlyContinue') {
                    # Return null silently for GET + 404 + SilentlyContinue
                    return $null
                }
            }

            # Check if we should retry
            $shouldRetry = $errorResult.ShouldRetry -and -not $NoRetry -and $retryCount -lt $MaxRetries

            if ($shouldRetry) {
                $retryCount++

                # Log retry attempt
                if ($EnableDebugLogging -or $VerbosePreference -ne 'SilentlyContinue') {
                    $waitTime = if ($errorResult.ErrorType -eq 'RateLimit') {
                        "already waited $($errorResult.WaitSeconds)s"
                    }
                    else {
                        $backoffSeconds = [Math]::Min(60, [Math]::Pow(2, $retryCount - 1))
                        "${backoffSeconds}s"
                    }
                    Write-Verbose "Request failed ($($errorResult.ErrorType)), retrying (attempt $retryCount of $MaxRetries) - wait time: $waitTime"
                }

                # For non-rate-limit errors, apply exponential backoff
                if ($errorResult.ErrorType -ne 'RateLimit') {
                    $backoffSeconds = [Math]::Min(60, [Math]::Pow(2, $retryCount - 1))
                    Start-Sleep -Seconds $backoffSeconds
                }

                # Continue to next iteration of the while loop
                continue
            }
            else {
                # Either no retry requested, non-retryable error, max retries exceeded, or NoRetry switch set
                if ($retryCount -eq $MaxRetries) {
                    $errorMessage = "Maximum retry attempts ($MaxRetries) exceeded. Last error: $($errorResult.Message)"
                    if ($EnableDebugLogging) {
                        Write-Debug $errorMessage
                    }
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                        [LMException]::new($errorMessage),
                        "LMAPIError.MaxRetriesExceeded",
                        [System.Management.Automation.ErrorCategory]::ResourceUnavailable,
                        $Uri
                    )
                    
                    if ($CallerPSCmdlet) {
                        $CallerPSCmdlet.WriteError($errorRecord)
                        if ($CallerPSCmdlet.SessionState.PSVariable.GetValue('ErrorActionPreference') -eq 'Stop') {
                            $CallerPSCmdlet.ThrowTerminatingError($errorRecord)
                        }
                    }
                    else {
                        Write-Error -ErrorRecord $errorRecord
                    }
                    return $null
                }
                elseif ($NoRetry) {
                    if ($EnableDebugLogging) {
                        Write-Debug "Retry disabled, failing immediately: $($errorResult.Message)"
                    }
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                        [LMException]::new($errorResult.Message),
                        "LMAPIError.NoRetry",
                        [System.Management.Automation.ErrorCategory]::InvalidOperation,
                        $Uri
                    )
                    
                    if ($CallerPSCmdlet) {
                        $CallerPSCmdlet.WriteError($errorRecord)
                        if ($CallerPSCmdlet.SessionState.PSVariable.GetValue('ErrorActionPreference') -eq 'Stop') {
                            $CallerPSCmdlet.ThrowTerminatingError($errorRecord)
                        }
                    }
                    else {
                        Write-Error -ErrorRecord $errorRecord
                    }
                    return $null
                }
                else {
                    # Non-retryable error
                    if ($EnableDebugLogging) {
                        Write-Debug "Non-retryable error ($($errorResult.ErrorType)): $($errorResult.Message)"
                    }
                    $errorCategory = switch ($errorResult.ErrorType) {
                        'AuthenticationError' { [System.Management.Automation.ErrorCategory]::AuthenticationError }
                        'AuthorizationError' { [System.Management.Automation.ErrorCategory]::PermissionDenied }
                        'NotFound' { [System.Management.Automation.ErrorCategory]::ObjectNotFound }
                        'ClientError' { [System.Management.Automation.ErrorCategory]::InvalidArgument }
                        'ServerError' { [System.Management.Automation.ErrorCategory]::ResourceUnavailable }
                        default { [System.Management.Automation.ErrorCategory]::InvalidOperation }
                    }
                    
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                        [LMException]::new($errorResult.Message),
                        "LMAPIError.$($errorResult.ErrorType)",
                        $errorCategory,
                        $Uri
                    )
                    
                    if ($CallerPSCmdlet) {
                        $CallerPSCmdlet.WriteError($errorRecord)
                        if ($CallerPSCmdlet.SessionState.PSVariable.GetValue('ErrorActionPreference') -eq 'Stop') {
                            $CallerPSCmdlet.ThrowTerminatingError($errorRecord)
                        }
                    }
                    else {
                        Write-Error -ErrorRecord $errorRecord
                    }
                    return $null
                }
            }
        }
    }

    # This should never be reached, but just in case
    $errorRecord = [System.Management.Automation.ErrorRecord]::new(
        [LMException]::new("Unexpected error: retry loop completed without success or failure"),
        "LMAPIError.UnexpectedError",
        [System.Management.Automation.ErrorCategory]::InvalidResult,
        $Uri
    )
    
    if ($CallerPSCmdlet) {
        $CallerPSCmdlet.WriteError($errorRecord)
        if ($CallerPSCmdlet.SessionState.PSVariable.GetValue('ErrorActionPreference') -eq 'Stop') {
            $CallerPSCmdlet.ThrowTerminatingError($errorRecord)
        }
    }
    else {
        Write-Error -ErrorRecord $errorRecord
    }
}