Private/HttpResponse.ps1
|
<# .SYNOPSIS Internal helpers that read HTTP details from the errors thrown by web cmdlets. .DESCRIPTION Windows PowerShell 5.1 throws System.Net.WebException, whose Response is an HttpWebResponse (StatusCode, StatusDescription, WebHeaderCollection headers and a body stream). PowerShell 7 throws Microsoft.PowerShell.Commands.HttpResponseException, whose Response is an HttpResponseMessage (StatusCode, ReasonPhrase and typed headers with a RetryAfter property). In both, Invoke-RestMethod and Invoke-WebRequest put the response body in ErrorRecord.ErrorDetails.Message. .NET's HttpRequestException has a StatusCode property on .NET 5 and later. .NOTES Private helpers for the tcs.core module. Duck-typed so that wrapped exceptions and test doubles work on both editions. #> function ConvertTo-RetryAfterDelay { <# .SYNOPSIS Converts a Retry-After header value (delay in seconds or an HTTP date) to seconds from now. #> [CmdletBinding()] [OutputType([double])] param( [AllowNull()] [object]$Value ) if ($null -eq $Value) { return $null } # System.Net.Http.Headers.RetryConditionHeaderValue (PowerShell 7 / HttpClient) if ($Value.PSObject.Properties['Delta'] -or $Value.PSObject.Properties['Date']) { if ($null -ne $Value.Delta) { return [Math]::Max(0, $Value.Delta.TotalSeconds) } if ($null -ne $Value.Date) { return [Math]::Max(0, ($Value.Date.UtcDateTime - [datetime]::UtcNow).TotalSeconds) } return $null } $text = ([string]@($Value)[0]).Trim() if ([string]::IsNullOrEmpty($text)) { return $null } $seconds = [double]0 if ([double]::TryParse($text, [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$seconds)) { return [Math]::Max(0, $seconds) } $date = [System.DateTimeOffset]::MinValue $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AllowWhiteSpaces if ([System.DateTimeOffset]::TryParse($text, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$date)) { return [Math]::Max(0, ($date.UtcDateTime - [datetime]::UtcNow).TotalSeconds) } return $null } function Get-HttpResponseInfo { <# .SYNOPSIS Returns StatusCode, StatusDescription, Body, RetryAfterSeconds, Uri and Response for an error that carries an HTTP response, or $null when it has none. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [AllowNull()] [object]$InputObject ) if ($null -eq $InputObject) { return $null } $errorRecord = $null $exception = $null if ($InputObject -is [System.Management.Automation.ErrorRecord]) { $errorRecord = $InputObject $exception = $InputObject.Exception } elseif ($InputObject -is [System.Exception]) { $exception = $InputObject } elseif ($InputObject.PSObject.Properties['Exception']) { $exception = $InputObject.Exception } else { $exception = $InputObject } # Find the first exception in the chain that has a response or a status code $response = $null $statusCode = $null $current = $exception $depth = 0 while ($null -ne $current -and $depth -lt 10) { if ($current.PSObject.Properties['Response'] -and $null -ne $current.Response) { $response = $current.Response break } if ($current.PSObject.Properties['StatusCode'] -and $null -ne $current.StatusCode) { $statusCode = [int]$current.StatusCode break } if ($current.PSObject.Properties['InnerException']) { $current = $current.InnerException } else { $current = $null } $depth++ } if ($null -eq $response -and $null -eq $statusCode) { return $null } $statusDescription = $null $retryAfter = $null $uri = $null if ($null -ne $response) { if ($response.PSObject.Properties['StatusCode'] -and $null -ne $response.StatusCode) { $statusCode = [int]$response.StatusCode } if ($response.PSObject.Properties['StatusDescription']) { $statusDescription = [string]$response.StatusDescription } elseif ($response.PSObject.Properties['ReasonPhrase']) { $statusDescription = [string]$response.ReasonPhrase } if ($response.PSObject.Properties['ResponseUri'] -and $response.ResponseUri) { $uri = [string]$response.ResponseUri } elseif ($response.PSObject.Properties['RequestMessage'] -and $response.RequestMessage -and $response.RequestMessage.RequestUri) { $uri = [string]$response.RequestMessage.RequestUri } $headers = $null if ($response.PSObject.Properties['Headers']) { $headers = $response.Headers } if ($null -ne $headers) { if ($headers -is [System.Collections.IDictionary] -or $headers -is [System.Collections.Specialized.NameValueCollection]) { # WebHeaderCollection (Windows PowerShell) and plain dictionaries $retryAfter = ConvertTo-RetryAfterDelay -Value $headers['Retry-After'] } elseif ($headers.PSObject.Properties['RetryAfter']) { # HttpResponseHeaders (PowerShell 7) $retryAfter = ConvertTo-RetryAfterDelay -Value $headers.RetryAfter } } } $body = $null if ($errorRecord -and $errorRecord.ErrorDetails -and -not [string]::IsNullOrEmpty($errorRecord.ErrorDetails.Message)) { $body = $errorRecord.ErrorDetails.Message } elseif ($null -ne $response) { try { if ($response.PSObject.Methods['GetResponseStream']) { $stream = $response.GetResponseStream() if ($stream) { if ($stream.CanSeek) { $stream.Position = 0 } $reader = New-Object System.IO.StreamReader -ArgumentList $stream try { $body = $reader.ReadToEnd() } finally { $reader.Dispose() } } } elseif ($response.PSObject.Properties['Content'] -and $null -ne $response.Content) { if ($response.Content -is [string]) { $body = $response.Content } elseif ($response.Content.PSObject.Methods['ReadAsStringAsync']) { $body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() } } } catch { Write-Verbose "Could not read the HTTP response body: $($_.Exception.Message)" } } return [PSCustomObject]@{ PSTypeName = 'Tcs.HttpErrorDetail' StatusCode = $statusCode StatusDescription = $statusDescription Body = $body RetryAfterSeconds = $retryAfter Uri = $uri Response = $response } } |