Private/Invoke-GovUKNotifyApi.ps1
|
function Invoke-GovUKNotifyApi { <# .SYNOPSIS Sends an authenticated request to the GOV.UK Notify REST API. .DESCRIPTION Internal request engine used by every public cmdlet. It resolves the API key and base URL, generates a fresh JWT for each attempt, serialises the request body to JSON, and parses the response. Transient failures (HTTP 429, 500, 502, 503, 504) are retried with exponential backoff, honouring a Retry-After header when present. GOV.UK Notify error responses are parsed and surfaced as a single, descriptive terminating error containing the HTTP status code and the Notify error type. .PARAMETER Method The HTTP method, 'GET' or 'POST'. .PARAMETER Path The request path appended to the base URL, for example '/v2/notifications/email'. .PARAMETER Body An optional hashtable serialised to a JSON request body for POST requests. .PARAMETER ApiKey An explicit API key. If omitted, the connected session context is used. .PARAMETER BaseUrl An explicit base URL. If omitted, the connected session context or the production default is used. .PARAMETER Raw Return the raw response bytes instead of parsed JSON. Used for the letter PDF endpoint. .PARAMETER MaxRetry Maximum number of retries for transient failures. Defaults to 3. .PARAMETER RetryDelaySeconds Base delay, in seconds, used for exponential backoff between retries. Defaults to 1. .OUTPUTS The parsed JSON response object, or System.Byte[] when -Raw is specified. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateSet('GET', 'POST')] [string]$Method, [Parameter(Mandatory = $true)] [string]$Path, [hashtable]$Body, [string]$ApiKey, [string]$BaseUrl, [switch]$Raw, [int]$MaxRetry = 3, [int]$RetryDelaySeconds = 1 ) $Context = Resolve-GovUKNotifyContext -ApiKey $ApiKey -BaseUrl $BaseUrl $Uri = '{0}{1}' -f $Context.BaseUrl, $Path $JsonBody = $null if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $JsonBody = $Body | ConvertTo-Json -Depth 10 } $RetryableStatus = @(429, 500, 502, 503, 504) $Attempt = 0 while ($true) { $Attempt++ # -- A fresh token per attempt: GOV.UK Notify tokens expire ~30 seconds after issue. $Token = New-GovUKNotifyJwt -ApiKey $Context.ApiKey $Headers = @{ Authorization = "Bearer $Token" } try { if ($Raw) { $Response = Invoke-WebRequest -Uri $Uri -Method $Method -Headers $Headers -UseBasicParsing -ErrorAction Stop # -- Return the byte array as a single object so the pipeline does not unroll it. return , $Response.RawContentStream.ToArray() } $RequestParams = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = 'Stop' } if ($null -ne $JsonBody) { $RequestParams.Body = $JsonBody $RequestParams.ContentType = 'application/json' } return Invoke-RestMethod @RequestParams } catch { $ErrorRecordItem = $_ $StatusCode = $null $ResponseBody = $null # -- Extract the HTTP status code (the StatusCode enum type is shared across PS editions). if ($ErrorRecordItem.Exception.Response) { try { $StatusCode = [int]$ErrorRecordItem.Exception.Response.StatusCode } catch { $StatusCode = $null } } # -- Extract the response body. PowerShell 7 populates ErrorDetails.Message; Windows # PowerShell 5.1 requires reading the response stream. if ($ErrorRecordItem.ErrorDetails -and $ErrorRecordItem.ErrorDetails.Message) { $ResponseBody = $ErrorRecordItem.ErrorDetails.Message } elseif ($ErrorRecordItem.Exception.Response -and ($ErrorRecordItem.Exception.Response | Get-Member -Name GetResponseStream -ErrorAction SilentlyContinue)) { try { $Stream = $ErrorRecordItem.Exception.Response.GetResponseStream() $Reader = [System.IO.StreamReader]::new($Stream) try { $ResponseBody = $Reader.ReadToEnd() } finally { $Reader.Dispose() } } catch { $ResponseBody = $null } } # -- Parse the Notify error payload (status_code + errors[]) for a friendly message. $ErrorSummary = $null if (-not [string]::IsNullOrWhiteSpace($ResponseBody)) { try { $Parsed = $ResponseBody | ConvertFrom-Json -ErrorAction Stop if ($Parsed.status_code) { $StatusCode = [int]$Parsed.status_code } if ($Parsed.errors) { $ErrorSummary = ($Parsed.errors | ForEach-Object { "$($_.error): $($_.message)" }) -join '; ' } } catch { $ErrorSummary = $ResponseBody } } if ([string]::IsNullOrWhiteSpace($ErrorSummary)) { $ErrorSummary = $ErrorRecordItem.Exception.Message } # -- Retry transient failures with exponential backoff. if ($StatusCode -in $RetryableStatus -and $Attempt -le $MaxRetry) { $Delay = [Math]::Min([Math]::Pow(2, $Attempt - 1) * $RetryDelaySeconds, 30) # -- Honour Retry-After when the server provides it. try { $ResponseHeaders = $ErrorRecordItem.Exception.Response.Headers if ($ResponseHeaders) { if ($ResponseHeaders['Retry-After']) { $Delay = [int]$ResponseHeaders['Retry-After'] } elseif ($ResponseHeaders.RetryAfter -and $ResponseHeaders.RetryAfter.Delta) { $Delay = [int]$ResponseHeaders.RetryAfter.Delta.TotalSeconds } } } catch { Write-Verbose 'Unable to read the Retry-After header; using the computed backoff delay.' } Write-Verbose ("GOV.UK Notify request failed (HTTP {0}). Retry {1}/{2} in {3}s." -f $StatusCode, $Attempt, $MaxRetry, $Delay) Start-Sleep -Seconds $Delay continue } # -- Non-retryable, or retries exhausted: throw a single descriptive terminating error. $StatusText = if ($StatusCode) { " (HTTP $StatusCode)" } else { '' } $Message = "GOV.UK Notify API request '$Method $Path' failed${StatusText}: $ErrorSummary" $Exception = [System.Exception]::new($Message, $ErrorRecordItem.Exception) $NewError = [System.Management.Automation.ErrorRecord]::new( $Exception, 'GovUKNotifyApiError', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Path ) throw $NewError } } } |