Private/Translation/Invoke-TranslationApiRequest.ps1
|
function Get-TranslationApiErrorDetail { <# .SYNOPSIS Extracts the provider's real error body and HTTP status code from a failed Invoke-RestMethod call, on both Windows PowerShell 5.1 and PowerShell 7. .DESCRIPTION Windows PowerShell 5.1 (Desktop, HttpWebRequest-based) surfaces a System.Net.WebException whose response body must be read from the response stream if Invoke-RestMethod did not already populate ErrorDetails.Message. PowerShell 7 (Core, HttpClient-based) surfaces a Microsoft.PowerShell.Commands.HttpResponseException instead. Both expose a duck-typed ".Response" with a ".StatusCode" property, so this reads that generically rather than testing for either concrete type. .OUTPUTS PSCustomObject: Message, StatusCode (StatusCode is $null when the failure carried no HTTP response at all - e.g. DNS failure, connection timeout). #> [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord ) $statusCode = $null $bodyText = $null # Invoke-RestMethod/-WebRequest populate ErrorDetails.Message from the response # body (when present) on both Desktop and Core editions - prefer it, since it is # the real provider JSON error rather than a generic exception message. if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { $bodyText = $ErrorRecord.ErrorDetails.Message } $exception = $ErrorRecord.Exception if ($exception -and $exception.Response) { try { $statusCode = [int]$exception.Response.StatusCode } catch { $statusCode = $null } # Fallback body extraction for the PS 5.1 WebException shape, in case # ErrorDetails.Message was not populated for some reason. if (-not $bodyText -and ($exception.Response -is [System.Net.HttpWebResponse])) { try { $stream = $exception.Response.GetResponseStream() if ($stream) { $reader = [System.IO.StreamReader]::new($stream) $bodyText = $reader.ReadToEnd() $reader.Dispose() } } catch { # Response stream unavailable or already consumed - degrade gracefully # to the generic exception message below. Write-Verbose "Could not read error response stream: $_" } } } $message = if ($bodyText) { $bodyText } else { $exception.Message } return [PSCustomObject]@{ Message = $message StatusCode = $statusCode } } function Invoke-TranslationApiRequest { <# .SYNOPSIS Shared HTTP mechanics for translation provider API calls. .DESCRIPTION Centralizes what used to be copy-pasted per-adapter: exponential-backoff retry, UTF-8-safe JSON body encoding, and real-error-body extraction. Retry classification: only HTTP 429, HTTP 5xx, and network/timeout errors with no HTTP status at all are retried. Any other 4xx (400 bad request / invalid model, 401 bad key, 403 no access, 404, ...) fails fast, since retrying a permanent failure only wastes time. Content-Type is always sent via the dedicated -ContentType parameter of Invoke-RestMethod, never via -Headers - on Windows PowerShell 5.1 Desktop (HttpWebRequest-based), Content-Type is a restricted header and setting it through -Headers throws. A Content-Type entry in -Headers is rejected here so that bug cannot silently reappear in a caller. The JSON body is serialized and converted to UTF-8 bytes explicitly, because Invoke-RestMethod -Body <string> does not reliably send UTF-8 on Desktop edition - this module regularly ships non-ASCII subtitle text (Persian, Arabic, CJK, ...). .PARAMETER Uri The full request URI. .PARAMETER Method HTTP method. Defaults to Post. .PARAMETER Body Request body as a hashtable, serialized to JSON. Optional - a GET request (e.g. listing models) has none. .PARAMETER Headers Request headers. Must NOT contain a Content-Type entry - use the body/ -ContentType mechanism instead. .PARAMETER ProviderLabel Human-readable provider name used in retry warning messages. .PARAMETER MaxRetries Maximum number of retries after the initial attempt (default 3, so up to 4 attempts total with 2/4/8 second backoff). .PARAMETER JsonDepth -Depth passed to ConvertTo-Json when serializing -Body. .OUTPUTS PSCustomObject: Success, Response, ErrorMessage, StatusCode, RetryCount #> [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string] $Uri, [string] $Method = 'Post', [hashtable] $Body, [hashtable] $Headers = @{}, [string] $ProviderLabel = 'API', [int] $MaxRetries = 3, [int] $JsonDepth = 8 ) if ($Headers.ContainsKey('Content-Type')) { throw "Invoke-TranslationApiRequest: '$ProviderLabel' passed a Content-Type header. Content-Type must be sent via -ContentType (set automatically from -Body here), never via -Headers - on Windows PowerShell 5.1 Desktop it is a restricted header and setting it through -Headers throws." } $invokeParams = @{ Uri = $Uri Method = $Method Headers = $Headers ErrorAction = 'Stop' } if ($Body) { $json = $Body | ConvertTo-Json -Depth $JsonDepth $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($json) $invokeParams.Body = $bodyBytes $invokeParams.ContentType = 'application/json; charset=utf-8' } $attempt = 0 $retryCount = 0 while ($true) { $attempt++ try { $response = Invoke-RestMethod @invokeParams return [PSCustomObject]@{ Success = $true Response = $response ErrorMessage = $null StatusCode = 200 RetryCount = $retryCount } } catch { $detail = Get-TranslationApiErrorDetail -ErrorRecord $_ $isRetryable = ($null -eq $detail.StatusCode) -or ($detail.StatusCode -eq 429) -or ($detail.StatusCode -ge 500) if ((-not $isRetryable) -or ($retryCount -ge $MaxRetries)) { return [PSCustomObject]@{ Success = $false Response = $null ErrorMessage = $detail.Message StatusCode = $detail.StatusCode RetryCount = $retryCount } } $retryCount++ $delay = [Math]::Pow(2, $retryCount) Write-Warning "$ProviderLabel API call failed (attempt $attempt, status=$($detail.StatusCode)). Retrying in ${delay}s... ($retryCount/$MaxRetries retries)" Start-Sleep -Seconds $delay } } } |