Skip to content

Commit 2608b44

Browse files
committed
Refactor HTTP requests to use centralized Invoke-HttpRequestWithRetry
- Add new Invoke-HttpRequestWithRetry cmdlet with built-in retry logic for transient HTTP errors (408, 429, 500, 502, 503, 504) - Refactor Invoke-GitHubApiRequest to delegate retry logic to the new shared cmdlet - Update Test-NetworkConnectivity to use Invoke-HttpRequestWithRetry instead of Invoke-WebRequest - Update Get-TerraformTool to use Invoke-HttpRequestWithRetry for HashiCorp API calls and downloads - Update unit tests to mock the new cmdlet
1 parent fffb47e commit 2608b44

5 files changed

Lines changed: 212 additions & 47 deletions

File tree

src/ALZ/Private/Shared/Invoke-GitHubApiRequest.ps1

Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
####################################
1+
####################################
22
# Invoke-GitHubApiRequest.ps1 #
33
####################################
44
# Version: 0.1.0
@@ -87,49 +87,42 @@ function Invoke-GitHubApiRequest {
8787
Write-Verbose "GitHub CLI is not installed. Proceeding without authentication."
8888
}
8989

90-
$isDownload = -not [string]::IsNullOrEmpty($OutputFile)
91-
$transientStatusCodes = @(408, 429, 500, 502, 503, 504)
92-
$maxAttempts = $MaxRetryCount + 1
93-
94-
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
95-
try {
96-
if ($isDownload) {
97-
Invoke-WebRequest -Uri $Uri -Method $Method -Headers $headers -OutFile $OutputFile -ErrorAction Stop
98-
return
99-
}
100-
101-
if ($SkipHttpErrorCheck) {
102-
$result = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $headers -SkipHttpErrorCheck -StatusCodeVariable "responseStatusCode"
103-
104-
$code = [int]$responseStatusCode
90+
# Build parameters for the generic retry cmdlet
91+
$retryParams = @{
92+
Uri = $Uri
93+
Method = $Method
94+
MaxRetryCount = $MaxRetryCount
95+
RetryIntervalSeconds = $RetryIntervalSeconds
96+
}
10597

106-
if ($code -in $transientStatusCodes -and $attempt -lt $maxAttempts) {
107-
Write-Warning "Request to $Uri returned status $code (attempt $attempt of $maxAttempts). Retrying in $RetryIntervalSeconds seconds..."
108-
Start-Sleep -Seconds $RetryIntervalSeconds
109-
continue
110-
}
98+
if ($headers.Count -gt 0) {
99+
$retryParams["Headers"] = $headers
100+
}
111101

112-
return @{
113-
Result = $result
114-
StatusCode = $code
115-
}
116-
}
102+
# File download — delegate directly
103+
if (-not [string]::IsNullOrEmpty($OutputFile)) {
104+
Invoke-HttpRequestWithRetry @retryParams -OutFile $OutputFile
105+
return
106+
}
117107

118-
return (Invoke-RestMethod -Uri $Uri -Method $Method -Headers $headers -ErrorAction Stop)
119-
} catch {
120-
$responseCode = $null
121-
if ($_.Exception.Response) {
122-
$responseCode = [int]$_.Exception.Response.StatusCode
123-
}
108+
# API call with SkipHttpErrorCheck — parse JSON and return Result/StatusCode hashtable
109+
if ($SkipHttpErrorCheck) {
110+
$response = Invoke-HttpRequestWithRetry @retryParams -SkipHttpErrorCheck -ReturnStatusCode
124111

125-
$isTransient = $responseCode -in $transientStatusCodes
112+
$parsed = $null
113+
if (-not [string]::IsNullOrWhiteSpace($response.Result.Content)) {
114+
$parsed = $response.Result.Content | ConvertFrom-Json
115+
}
126116

127-
if ($isTransient -and $attempt -lt $maxAttempts) {
128-
Write-Warning "Request to $Uri failed with status $responseCode (attempt $attempt of $maxAttempts). Retrying in $RetryIntervalSeconds seconds..."
129-
Start-Sleep -Seconds $RetryIntervalSeconds
130-
} else {
131-
throw
132-
}
117+
return @{
118+
Result = $parsed
119+
StatusCode = $response.StatusCode
133120
}
134121
}
122+
123+
# Standard API call — parse JSON and return the object
124+
$response = Invoke-HttpRequestWithRetry @retryParams
125+
if (-not [string]::IsNullOrWhiteSpace($response.Content)) {
126+
return ($response.Content | ConvertFrom-Json)
127+
}
135128
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
####################################
2+
# Invoke-HttpRequestWithRetry.ps1 #
3+
####################################
4+
# Version: 0.1.0
5+
6+
<#
7+
.SYNOPSIS
8+
Invokes an HTTP request with automatic retry logic for transient errors.
9+
10+
.DESCRIPTION
11+
Makes HTTP requests using Invoke-WebRequest or Invoke-RestMethod with
12+
automatic retry for transient HTTP errors (408, 429, 500, 502, 503, 504).
13+
14+
.PARAMETER Uri
15+
The URI to send the request to.
16+
17+
.PARAMETER Method
18+
The HTTP method for the request. Defaults to GET.
19+
20+
.PARAMETER MaxRetryCount
21+
Maximum number of retries for transient errors. Defaults to 10.
22+
23+
.PARAMETER RetryIntervalSeconds
24+
Seconds to wait between retries. Defaults to 3.
25+
26+
.PARAMETER OutFile
27+
If specified, downloads the response to this file path using Invoke-WebRequest.
28+
29+
.PARAMETER SkipHttpErrorCheck
30+
If specified, does not throw on HTTP error status codes.
31+
Returns the response object without error.
32+
33+
.PARAMETER TimeoutSec
34+
Timeout in seconds for the HTTP request. If not specified, uses the default.
35+
36+
.PARAMETER Body
37+
The body of the request.
38+
39+
.PARAMETER ContentType
40+
The content type of the request body.
41+
42+
.PARAMETER Headers
43+
Additional headers to include in the request.
44+
45+
.PARAMETER ReturnStatusCode
46+
If specified alongside SkipHttpErrorCheck, returns a hashtable with
47+
Result and StatusCode properties (similar to Invoke-GitHubApiRequest).
48+
49+
.EXAMPLE
50+
Invoke-HttpRequestWithRetry -Uri "https://api.releases.hashicorp.com/v1/releases/terraform?limit=20"
51+
52+
.EXAMPLE
53+
Invoke-HttpRequestWithRetry -Uri "https://example.com/file.zip" -OutFile "./file.zip"
54+
55+
.EXAMPLE
56+
Invoke-HttpRequestWithRetry -Uri "https://example.com" -Method Head -SkipHttpErrorCheck -MaxRetryCount 0
57+
58+
.NOTES
59+
# Release notes 25/03/2026 - V0.1.0:
60+
- Initial release.
61+
#>
62+
63+
function Invoke-HttpRequestWithRetry {
64+
[CmdletBinding()]
65+
param (
66+
[Parameter(Mandatory = $true, Position = 1, HelpMessage = "The URI to send the request to.")]
67+
[string] $Uri,
68+
69+
[Parameter(Mandatory = $false, HelpMessage = "The HTTP method for the request.")]
70+
[string] $Method = "GET",
71+
72+
[Parameter(Mandatory = $false, HelpMessage = "Maximum number of retries for transient errors.")]
73+
[int] $MaxRetryCount = 10,
74+
75+
[Parameter(Mandatory = $false, HelpMessage = "Seconds to wait between retries.")]
76+
[int] $RetryIntervalSeconds = 3,
77+
78+
[Parameter(Mandatory = $false, HelpMessage = "If specified, downloads the response to this file path.")]
79+
[string] $OutFile,
80+
81+
[Parameter(Mandatory = $false, HelpMessage = "If specified, does not throw on HTTP error status codes.")]
82+
[switch] $SkipHttpErrorCheck,
83+
84+
[Parameter(Mandatory = $false, HelpMessage = "Timeout in seconds for the HTTP request.")]
85+
[int] $TimeoutSec,
86+
87+
[Parameter(Mandatory = $false, HelpMessage = "The body of the request.")]
88+
[object] $Body,
89+
90+
[Parameter(Mandatory = $false, HelpMessage = "The content type of the request body.")]
91+
[string] $ContentType,
92+
93+
[Parameter(Mandatory = $false, HelpMessage = "Additional headers to include in the request.")]
94+
[hashtable] $Headers,
95+
96+
[Parameter(Mandatory = $false, HelpMessage = "If specified, returns a hashtable with Result and StatusCode.")]
97+
[switch] $ReturnStatusCode
98+
)
99+
100+
$isDownload = -not [string]::IsNullOrEmpty($OutFile)
101+
$transientStatusCodes = @(408, 429, 500, 502, 503, 504)
102+
$maxAttempts = $MaxRetryCount + 1
103+
104+
# Build common parameters
105+
$commonParams = @{
106+
Uri = $Uri
107+
Method = $Method
108+
ErrorAction = "Stop"
109+
}
110+
111+
if ($PSBoundParameters.ContainsKey("TimeoutSec")) {
112+
$commonParams["TimeoutSec"] = $TimeoutSec
113+
}
114+
115+
if ($PSBoundParameters.ContainsKey("Body")) {
116+
$commonParams["Body"] = $Body
117+
}
118+
119+
if ($PSBoundParameters.ContainsKey("ContentType")) {
120+
$commonParams["ContentType"] = $ContentType
121+
}
122+
123+
if ($PSBoundParameters.ContainsKey("Headers")) {
124+
$commonParams["Headers"] = $Headers
125+
}
126+
127+
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
128+
try {
129+
if ($isDownload) {
130+
Invoke-WebRequest @commonParams -OutFile $OutFile
131+
return
132+
}
133+
134+
if ($SkipHttpErrorCheck) {
135+
$response = Invoke-WebRequest @commonParams -SkipHttpErrorCheck -UseBasicParsing
136+
137+
$code = [int]$response.StatusCode
138+
139+
if ($code -in $transientStatusCodes -and $attempt -lt $maxAttempts) {
140+
Write-Warning "Request to $Uri returned status $code (attempt $attempt of $maxAttempts). Retrying in $RetryIntervalSeconds seconds..."
141+
Start-Sleep -Seconds $RetryIntervalSeconds
142+
continue
143+
}
144+
145+
if ($ReturnStatusCode) {
146+
return @{
147+
Result = $response
148+
StatusCode = $code
149+
}
150+
}
151+
152+
return $response
153+
}
154+
155+
return (Invoke-WebRequest @commonParams -UseBasicParsing)
156+
} catch {
157+
$responseCode = $null
158+
if ($_.Exception.Response) {
159+
$responseCode = [int]$_.Exception.Response.StatusCode
160+
}
161+
162+
$isTransient = $responseCode -in $transientStatusCodes
163+
164+
if ($isTransient -and $attempt -lt $maxAttempts) {
165+
Write-Warning "Request to $Uri failed with status $responseCode (attempt $attempt of $maxAttempts). Retrying in $RetryIntervalSeconds seconds..."
166+
Start-Sleep -Seconds $RetryIntervalSeconds
167+
} else {
168+
throw
169+
}
170+
}
171+
}
172+
}

src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function Test-NetworkConnectivity {
2020
if ($endpoint.Uri -eq "https://api.github.com") {
2121
Invoke-GitHubApiRequest -Uri $endpoint.Uri -Method Head -SkipHttpErrorCheck -MaxRetryCount 0 | Out-Null
2222
} else {
23-
Invoke-WebRequest -Uri $endpoint.Uri -Method Head -TimeoutSec 10 -SkipHttpErrorCheck -ErrorAction Stop -UseBasicParsing | Out-Null
23+
Invoke-HttpRequestWithRetry -Uri $endpoint.Uri -Method Head -TimeoutSec 10 -SkipHttpErrorCheck -MaxRetryCount 0 | Out-Null
2424
}
2525
$results += @{
2626
message = "Network connectivity to $($endpoint.Description) ($($endpoint.Uri)) is available."

src/ALZ/Private/Tools/Get-TerraformTool.ps1

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ function Get-TerraformTool {
1010
$release = $null
1111

1212
if($version -eq "latest") {
13-
$versionResponse = Invoke-WebRequest -Uri "https://api.releases.hashicorp.com/v1/releases/terraform?limit=20"
13+
$versionResponse = Invoke-HttpRequestWithRetry -Uri "https://api.releases.hashicorp.com/v1/releases/terraform?limit=20"
1414
if($versionResponse.StatusCode -ne "200") {
1515
throw "Unable to query Terraform version, please check your internet connection and try again..."
1616
}
@@ -19,7 +19,7 @@ function Get-TerraformTool {
1919
$version = $releases[0].version
2020
Write-Verbose "Latest version of Terraform is $version"
2121
} else {
22-
$versionResponse = Invoke-WebRequest -Uri "https://api.releases.hashicorp.com/v1/releases/terraform/$($version)"
22+
$versionResponse = Invoke-HttpRequestWithRetry -Uri "https://api.releases.hashicorp.com/v1/releases/terraform/$($version)"
2323
if($versionResponse.StatusCode -ne "200") {
2424
throw "Unable to query Terraform version, please check the supplied version and try again..."
2525
}
@@ -55,7 +55,7 @@ function Get-TerraformTool {
5555
New-Item -ItemType Directory -Path $toolsPath| Out-String | Write-Verbose
5656
}
5757

58-
Invoke-WebRequest -Uri $url -OutFile "$zipfilePath" | Out-String | Write-Verbose
58+
Invoke-HttpRequestWithRetry -Uri $url -OutFile "$zipfilePath" | Out-String | Write-Verbose
5959

6060
Expand-Archive -Path $zipfilePath -DestinationPath $unzipdir
6161

src/Tests/Unit/Private/Test-NetworkConnectivity.Tests.ps1

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ InModuleScope 'ALZ' {
2020

2121
Context 'All endpoints are reachable' {
2222
BeforeAll {
23-
Mock -CommandName Invoke-WebRequest -MockWith {
23+
Mock -CommandName Invoke-HttpRequestWithRetry -MockWith {
2424
[PSCustomObject]@{ StatusCode = 200 }
2525
}
2626
Mock -CommandName Invoke-GitHubApiRequest -MockWith {
@@ -51,7 +51,7 @@ InModuleScope 'ALZ' {
5151
Mock -CommandName Invoke-GitHubApiRequest -MockWith {
5252
throw "Unable to connect to the remote server"
5353
}
54-
Mock -CommandName Invoke-WebRequest -MockWith {
54+
Mock -CommandName Invoke-HttpRequestWithRetry -MockWith {
5555
[PSCustomObject]@{ StatusCode = 200 }
5656
}
5757
}
@@ -83,7 +83,7 @@ InModuleScope 'ALZ' {
8383

8484
Context 'All endpoints are unreachable' {
8585
BeforeAll {
86-
Mock -CommandName Invoke-WebRequest -MockWith {
86+
Mock -CommandName Invoke-HttpRequestWithRetry -MockWith {
8787
throw "Network unreachable"
8888
}
8989
Mock -CommandName Invoke-GitHubApiRequest -MockWith {
@@ -110,7 +110,7 @@ InModuleScope 'ALZ' {
110110

111111
It 'checks all endpoints and does not stop at the first failure' {
112112
$result = Test-NetworkConnectivity
113-
Should -Invoke -CommandName Invoke-WebRequest -Times 5 -Scope It
113+
Should -Invoke -CommandName Invoke-HttpRequestWithRetry -Times 5 -Scope It
114114
Should -Invoke -CommandName Invoke-GitHubApiRequest -Times 1 -Scope It
115115
}
116116
}

0 commit comments

Comments
 (0)