Functions/Invoke-RateLimitedEndpoint.ps1
Function Invoke-RateLimitedEndpoint { Param( [string] $Uri, [string] $Method, [string] $Body, [hashtable] $Headers, [ref] $ResponseHeaders, [ref] $StatusCode, [switch] $Raw, [switch] $SkipHttpErrorCheck, [string] $SearchText, [string] $OutFile ) $_responseHeaders = [hashtable]::new() $_statusCode = [int]::new() # Ensure Headers has Content-Type if(-not $Headers){ $Headers = [hashtable]::new() } if(-not $Headers["Content-Type"]){ $Headers.Add("Content-Type", "application/json") } # Add User-Agent if(-not $Headers["User-Agent"]){ $Headers.Add("User-Agent", "PowerShell") } if(-not $Method){ $Method = "GET" } $Headers.Remove("Content-Type") # Write-Host $($Headers | ConvertTo-Json -Depth 10) Write-Host "$($Method) $Uri" # Add Etag and Last-Modified headers if the URI is a GitHub API $lastCachedEtag = $null $lastCachedLastModified = $null $lastEtag = $null $lastModified = $null if($Uri -match "https://api.github.com/" -and $Method -eq "GET"){ $lastEtag = $Global:CachedEtags[$Uri].Value $lastModified = $Global:CachedLastModified[$Uri].Value $lastCachedEtag = $Global:CachedEtags[$Uri].Cached $lastCachedLastModified = $Global:CachedLastModified[$Uri].Cached if($lastEtag){ Write-Host "Using Etag: $lastEtag" $Headers["If-None-Match"] = "$($lastEtag)" } if($lastModified){ Write-Host "Using Last-Modified: $lastModified" $Headers["If-Modified-Since"] = "$($lastModified)" } } # Wait for 1 second if not a GET request if($Method -ine "GET"){ $newLastNonGetRequest = [datetime]::UtcNow $timeSinceLastNonGetRequest = $newLastNonGetRequest - $Global:LastNonGetRequest if($timeSinceLastNonGetRequest.TotalSeconds -lt 1){ Write-Host "Waiting for 1 second to avoid rate limiting" Start-Sleep -Seconds 1 } $Global:LastNonGetRequest = $newLastNonGetRequest } $extraParameters = @{} # If Powershell 5.1 add TimeoutSec if($PSVersionTable.PSVersion.Major -eq 5 -and $PSVersionTable.PSVersion.Minor -eq 1){ $extraParameters.Add("TimeoutSec", 60) } # If Powershell 7.0 add OperationTimeoutSeconds if($PSVersionTable.PSVersion.Major -eq 7 -and $PSVersionTable.PSVersion.Minor -eq 0){ $extraParameters.Add("OperationTimeoutSeconds", 60) $extraParameters.Add("ConnectionTimeoutSeconds", 60) } $httpClient = New-Object System.Net.Http.HttpClient $httpRequestMessage = New-Object System.Net.Http.HttpRequestMessage $httpRequestMessage.Method = [System.Net.Http.HttpMethod]::$Method $httpRequestMessage.RequestUri = [Uri]$Uri foreach ($key in $Headers.Keys) { $httpRequestMessage.Headers.Add($key, $Headers[$key]) } if ($Body) { $httpRequestMessage.Content = [System.Net.Http.StringContent]::new($Body, [System.Text.Encoding]::UTF8, "application/json") } $httpResponse = $httpClient.SendAsync($httpRequestMessage, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result $stream = $httpResponse.Content.ReadAsStreamAsync().Result $reader = [System.IO.StreamReader]::new($stream) if($OutFile){ # Pipe the stream to outfile $writer = [System.IO.StreamWriter]::new($OutFile) $reader.BaseStream.CopyTo($writer.BaseStream) $writer.Close() $reader.Close() } $found = $false $content = "" try { while ($true -and -not $OutFile) { $buffer = $reader.ReadLine() if ($buffer -eq $null) { break } if($OutFile){ Add-Content -Path $OutFile -Value $buffer } $content += $buffer + "`n" if (-not [String]::IsNullOrEmpty($SearchText) -and $content -match $SearchText) { $found = $true break } } } finally { $reader.Close() $stream.Close() $httpClient.Dispose() } # Write-Host "Response: $content" $data = if($Raw -or $OutFile){ $content } else { $content | ConvertFrom-Json } $_statusCode = $httpResponse.StatusCode $_responseHeaders = [hashtable]::new() foreach($head in $httpResponse.Headers){ $_responseHeaders.Add($head.Key, $head.Value) } # Response Headers # Write-Host $($_responseHeaders | ConvertTo-Json) # Cache Etag and Last-Modified headers if($Uri -match "https://api.github.com/"){ if($_statusCode -eq 304){ Write-Host "Using cached data" if($lastCachedEtag){ $data = $lastCachedEtag } if($lastCachedLastModified){ $data =$lastCachedLastModified } $_statusCode = 200 } else{ if($_responseHeaders["ETag"]){ $Global:CachedEtags[$Uri] = @{ Value = $_responseHeaders["ETag"] Cached = $data } } if($_responseHeaders["Last-Modified"]){ $Global:CachedLastModified[$Uri] = { Value = $_responseHeaders["Last-Modified"] Cached = $data } } } } # Clear Cached Etags and Last-Modified headers $Global:CachedEtags = @{} $Global:CachedLastModified = @{} # Retry if 401 after updating token if($_statusCode -eq 401) { Update-GitHubToken -Organization $env:GH_AUTH_ORG -Repository $env:GH_AUTH_REPO $Headers["Authorization"] = "Bearer $(gh auth token)" Write-Host "Retrying after 5 seconds..." Start-Sleep -Seconds 5 # Clear Cached Etags and Last-Modified headers $Global:CachedEtags = @{} $Global:CachedLastModified = @{} $Global:RateLimitRetryCount += 1 try{ return Invoke-RateLimitedEndpoint -Uri $Uri -Method $Method -Headers $Headers -Body $Body -ResponseHeaders $ResponseHeaders -StatusCode $StatusCode -Raw:$Raw -SkipHttpErrorCheck:$SkipHttpErrorCheck -SearchText $SearchText } catch{ throw $_ } finally { $Global:RateLimitRetryCount -= 1 } } # Retry if 202 if($_responseHeaders["Retry-After"]){ $wait = [int]::Parse($_responseHeaders["Retry-After"]) # Exponential backoff $waitTotalSeconds = [Math]::Pow($wait.TotalSeconds, [Math]::Pow(2, $Global:RateLimitRetryCount)) Write-Host "Waiting for $waitTotalSeconds seconds due to Retry-After header `n (Wait Time $($wait.TotalSeconds) seconds, Retry Count $($Global:RateLimitRetryCount), Total Wait Time $($waitTotalSeconds) seconds)" Start-Sleep -Seconds $waitTotalSeconds # Clear Cached Etags and Last-Modified headers $Global:CachedEtags = @{} $Global:CachedLastModified = @{} $Global:RateLimitRetryCount += 1 try{ return Invoke-RateLimitedEndpoint -Uri $Uri -Method $Method -Headers $Headers -Body $Body -ResponseHeaders $ResponseHeaders -StatusCode $StatusCode -Raw:$Raw -SkipHttpErrorCheck:$SkipHttpErrorCheck -SearchText $SearchText } catch{ throw $_ } finally { $Global:RateLimitRetryCount -= 1 } } # Check rate limit if($_responseHeaders["X-RateLimit-Limit"]){ $rateLimitRemaining = [int]::Parse($_responseHeaders["X-RateLimit-Remaining"]) $rateLimitReset = [int]::Parse($_responseHeaders["X-RateLimit-Reset"]) $rateLimitResetAt = [datetime]::new(1970, 1, 1, 0, 0, 0, 0, [DateTimeKind]::Utc).AddSeconds($rateLimitReset) # Write-Host "Rate Limit: $rateLimitRemaining of $rateLimit remaining" # Write-Host "Rate Limit Reset: $rateLimitResetAt" # Write-Host "Rate Limit Cost: $rateLimitCost" # Write-Host "Rate Limit Reset: $rateLimitResetAt" # Wait for the rate limit to reset if($rateLimitRemaining -eq 0){ $wait = $rateLimitResetAt - [datetime]::UtcNow # Exponential backoff $waitTotalSeconds = [Math]::Pow($wait.TotalSeconds, [Math]::Pow(2, $Global:RateLimitRetryCount)) Write-Host "Waiting for $waitTotalSeconds seconds due to rate limit `n (Wait Time $($wait.TotalSeconds) seconds, Retry Count $($Global:RateLimitRetryCount), Total Wait Time $($waitTotalSeconds) seconds)" $waitTotalSeconds += 20 Start-Sleep -Seconds $waitTotalSeconds if(-not $ResponseHeaders){ $ResponseHeaders = ([ref]([hashtable]::new())) } if(-not $StatusCode){ $StatusCode = ([ref]([int]::new())) } # Clear Cached Etags and Last-Modified headers $Global:CachedEtags = @{} $Global:CachedLastModified = @{} # Retry the request $Global:RateLimitRetryCount += 1 try{ return Invoke-RateLimitedEndpoint -Uri $Uri -Method $Method -Body $Body -Headers $Headers -ResponseHeaders $ResponseHeaders -StatusCode $StatusCode -Raw:$Raw -SkipHttpErrorCheck:$SkipHttpErrorCheck } catch{ throw $_ } finally { $Global:RateLimitRetryCount -= 1 } } } # Check if the status code is an error if(-not $SkipHttpErrorCheck -and ($_statusCode -lt 200 -or $_statusCode -gt 299)) { throw "HTTP Error: $($_statusCode), `n$data" } if($ResponseHeaders) { $ResponseHeaders.Value = $_responseHeaders } if($StatusCode) { $StatusCode.Value = $_statusCode } return $data } |