functions/github/Invoke-GitHubRestMethod.ps1
|
function Invoke-GitHubRestMethod { [CmdletBinding()] param ( [Parameter(Mandatory=$True)] [uri] $Uri, [Parameter()] [string] $Verb = 'GET', [Parameter()] [hashtable] $Body, [Parameter()] [string] $Token = $env:GITHUB_TOKEN, [Parameter()] [hashtable] $Headers = @{}, [Parameter()] [int[]] $HttpErrorStatusCodesToIgnore = @(), [Parameter()] [switch] $AllPages, [Parameter()] [int] $MaxRetries = 5, [Parameter()] [float] $RetryBackOffBaseFactor = 1.5, [Parameter()] [int] $InitialBackOffSeconds = 60 ) if ($Headers.Keys -notcontains 'Accept') { $Headers += @{ Accept = 'application/vnd.github.machine-man-preview+json' } } if ($Headers.Keys -notcontains 'Authorization') { $Headers += @{ Authorization = "Token $Token" } } if ($Headers.Keys -notcontains 'Content-Type') { $Headers += @{ "Content-Type" = "application/json" } } # NOTE: We follow the pagination links ourselves rather than using '-FollowRelLink', as # Invoke-RestMethod does not send the 'Authorization' header when requesting subsequent # pages, which results in 401 errors or silently returns only publicly visible data # Results are buffered until all pages have been retrieved, so that a failure part-way # through does not return a truncated result set $results = [System.Collections.Generic.List[object]]::new() $nextUri = $Uri while ($nextUri) { $resp = $null $responseHeaders = $null $succeeded = $false $isRateLimited = $false $retryCount = 0 do { try { $resp = Invoke-RestMethod -Headers $Headers ` -Method $Verb ` -Uri $nextUri ` -Body ($Body|ConvertTo-Json -Depth 100 -Compress) ` -ResponseHeadersVariable responseHeaders $succeeded = $true } catch { if ( $_.Exception.Response.StatusCode -eq 429 -or ` ($_.Exception.Response.StatusCode -eq 403 -and $_.Exception.Message -match 'rate limit') ) { # Handle rate limit errors as per GitHub's API guidelines # ref: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28 # 1. Check for 429 or 403 with 'rate limit' in the error message # 2. Check for 'Retry-After' header # 3. Check for 'X-RateLimit-Remaining' = 0, wait until 'X-RateLimit-Reset' header # 4. Wait for 60 seconds, expoenentially backing off for subsequent retries $isRateLimited = $true $retryAfter = $_.Exception.Response.Headers.Contains("Retry-After") ? ($_.Exception.Response.Headers.GetValues("Retry-After")[0]) : $null if ($retryAfter) { Write-Host "Rate limit exceeded. Waiting for $retryAfter seconds..." } elseif ( $_.Exception.Response.Headers.Contains('X-RateLimit-Remaining') -and ` $_.Exception.Response.Headers.GetValues('X-RateLimit-Remaining')[0] -eq 0 -and ` $_.Exception.Response.Headers.Contains('X-RateLimit-Reset') ) { $RateLimitReset = [datetime]::FromFileTimeUtc($_.Exception.Response.Headers.GetValues('X-RateLimit-Reset')[0]) $retryAfter = ($RateLimitReset - [datetime]::UtcNow).TotalSeconds Write-Host "Rate limit exceeded. Waiting for quota reset in $retryAfter seconds..." } else { # We have hit a secondary rate limit with no retry context $retryAfter = [math]::Pow($RetryBackOffBaseFactor, $retryCount) * $InitialBackOffSeconds Write-Host "Rate limit exceeded. Exponentional back-off, waiting for $retryAfter seconds..." } Start-Sleep -Seconds $retryAfter } elseif ($_.Exception.Response.StatusCode -notin $HttpErrorStatusCodesToIgnore) { throw $_ } else { $isRateLimited = $false } $retryCount++ } } while (!$succeeded -and $retryCount -le $MaxRetries) if (!$succeeded -and $isRateLimited) { throw "Rate limit still exceeded after $MaxRetries retries when requesting '$nextUri'" } # Flatten the result set in the event we traversed multiple pages foreach ($item in $resp) { $results.Add($item) } $nextUri = $null if ($AllPages -and $succeeded -and $responseHeaders -and $responseHeaders['Link']) { # e.g. Link: <https://api.github.com/...?page=2>; rel="next", <https://api.github.com/...?page=5>; rel="last" $nextMatch = [regex]::Match(($responseHeaders['Link'] -join ','), '<([^>]+)>;\s*rel="next"') if ($nextMatch.Success) { $nextUri = [uri]$nextMatch.Groups[1].Value # Only send our credentials to the origin that was originally requested # ref: https://github.com/PowerShell/PowerShell/issues/27861 if ( $nextUri.Scheme -ne $Uri.Scheme -or ` $nextUri.Host -ne $Uri.Host -or ` $nextUri.Port -ne $Uri.Port ) { throw "Refusing to follow pagination link to a different origin than the original request: '$($nextUri.GetLeftPart([UriPartial]::Authority))' (expected '$($Uri.GetLeftPart([UriPartial]::Authority))')" } } } } $results } |