mirror of
https://git.mirrors.martin98.com/https://github.com/actions/toolkit
synced 2025-11-19 16:21:05 +08:00
230 lines
6.8 KiB
TypeScript
230 lines
6.8 KiB
TypeScript
import {HttpClient, HttpClientResponse, HttpCodes} from '@actions/http-client'
|
|
import {BearerCredentialHandler} from '@actions/http-client/lib/auth'
|
|
import {setSecret, info, debug} from '@actions/core'
|
|
import {ArtifactServiceClientJSON} from '../../generated'
|
|
import {getResultsServiceUrl, getRuntimeToken} from './config'
|
|
import {getUserAgentString} from './user-agent'
|
|
import {NetworkError, UsageError} from './errors'
|
|
|
|
// The twirp http client must implement this interface
|
|
interface Rpc {
|
|
request(
|
|
service: string,
|
|
method: string,
|
|
contentType: 'application/json' | 'application/protobuf',
|
|
data: object | Uint8Array
|
|
): Promise<object | Uint8Array>
|
|
}
|
|
|
|
export class ArtifactHttpClient implements Rpc {
|
|
private httpClient: HttpClient
|
|
private baseUrl: string
|
|
private maxAttempts = 5
|
|
private baseRetryIntervalMilliseconds = 3000
|
|
private retryMultiplier = 1.5
|
|
|
|
constructor(
|
|
userAgent: string,
|
|
maxAttempts?: number,
|
|
baseRetryIntervalMilliseconds?: number,
|
|
retryMultiplier?: number
|
|
) {
|
|
const token = getRuntimeToken()
|
|
this.baseUrl = getResultsServiceUrl()
|
|
if (maxAttempts) {
|
|
this.maxAttempts = maxAttempts
|
|
}
|
|
if (baseRetryIntervalMilliseconds) {
|
|
this.baseRetryIntervalMilliseconds = baseRetryIntervalMilliseconds
|
|
}
|
|
if (retryMultiplier) {
|
|
this.retryMultiplier = retryMultiplier
|
|
}
|
|
|
|
this.httpClient = new HttpClient(userAgent, [
|
|
new BearerCredentialHandler(token)
|
|
])
|
|
}
|
|
|
|
// This function satisfies the Rpc interface. It is compatible with the JSON
|
|
// JSON generated client.
|
|
async request(
|
|
service: string,
|
|
method: string,
|
|
contentType: 'application/json' | 'application/protobuf',
|
|
data: object | Uint8Array
|
|
): Promise<object | Uint8Array> {
|
|
const url = new URL(`/twirp/${service}/${method}`, this.baseUrl).href
|
|
debug(`[Request] ${method} ${url}`)
|
|
const headers = {
|
|
'Content-Type': contentType
|
|
}
|
|
try {
|
|
const {body} = await this.retryableRequest(async () =>
|
|
this.httpClient.post(url, JSON.stringify(data), headers)
|
|
)
|
|
|
|
return body
|
|
} catch (error) {
|
|
throw new Error(`Failed to ${method}: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Masks the `sig` parameter in a URL and sets it as a secret.
|
|
* @param url The URL containing the `sig` parameter.
|
|
* @returns A masked URL where the sig parameter value is replaced with '***' if found,
|
|
* or the original URL if no sig parameter is present.
|
|
*/
|
|
maskSigUrl(url: string): string {
|
|
const sigIndex = url.indexOf('sig=')
|
|
if (sigIndex !== -1) {
|
|
const sigValue = url.substring(sigIndex + 4)
|
|
setSecret(sigValue)
|
|
return `${url.substring(0, sigIndex + 4)}***`
|
|
}
|
|
return url
|
|
}
|
|
|
|
maskSecretUrls(body): void {
|
|
if (typeof body === 'object' && body !== null) {
|
|
if (
|
|
'signed_upload_url' in body &&
|
|
typeof body.signed_upload_url === 'string'
|
|
) {
|
|
this.maskSigUrl(body.signed_upload_url)
|
|
}
|
|
if ('signed_url' in body && typeof body.signed_url === 'string') {
|
|
this.maskSigUrl(body.signed_url)
|
|
}
|
|
} else {
|
|
debug('body is not an object or is null')
|
|
}
|
|
}
|
|
|
|
async retryableRequest(
|
|
operation: () => Promise<HttpClientResponse>
|
|
): Promise<{response: HttpClientResponse; body: object}> {
|
|
let attempt = 0
|
|
let errorMessage = ''
|
|
let rawBody = ''
|
|
while (attempt < this.maxAttempts) {
|
|
let isRetryable = false
|
|
|
|
try {
|
|
const response = await operation()
|
|
const statusCode = response.message.statusCode
|
|
rawBody = await response.readBody()
|
|
debug(`[Response] - ${response.message.statusCode}`)
|
|
debug(`Headers: ${JSON.stringify(response.message.headers, null, 2)}`)
|
|
const body = JSON.parse(rawBody)
|
|
this.maskSecretUrls(body)
|
|
debug(`Body: ${JSON.stringify(body, null, 2)}`)
|
|
if (this.isSuccessStatusCode(statusCode)) {
|
|
return {response, body}
|
|
}
|
|
isRetryable = this.isRetryableHttpStatusCode(statusCode)
|
|
errorMessage = `Failed request: (${statusCode}) ${response.message.statusMessage}`
|
|
if (body.msg) {
|
|
if (UsageError.isUsageErrorMessage(body.msg)) {
|
|
throw new UsageError()
|
|
}
|
|
|
|
errorMessage = `${errorMessage}: ${body.msg}`
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) {
|
|
debug(`Raw Body: ${rawBody}`)
|
|
}
|
|
|
|
if (error instanceof UsageError) {
|
|
throw error
|
|
}
|
|
|
|
if (NetworkError.isNetworkErrorCode(error?.code)) {
|
|
throw new NetworkError(error?.code)
|
|
}
|
|
|
|
isRetryable = true
|
|
errorMessage = error.message
|
|
}
|
|
|
|
if (!isRetryable) {
|
|
throw new Error(`Received non-retryable error: ${errorMessage}`)
|
|
}
|
|
|
|
if (attempt + 1 === this.maxAttempts) {
|
|
throw new Error(
|
|
`Failed to make request after ${this.maxAttempts} attempts: ${errorMessage}`
|
|
)
|
|
}
|
|
|
|
const retryTimeMilliseconds =
|
|
this.getExponentialRetryTimeMilliseconds(attempt)
|
|
info(
|
|
`Attempt ${attempt + 1} of ${
|
|
this.maxAttempts
|
|
} failed with error: ${errorMessage}. Retrying request in ${retryTimeMilliseconds} ms...`
|
|
)
|
|
await this.sleep(retryTimeMilliseconds)
|
|
attempt++
|
|
}
|
|
|
|
throw new Error(`Request failed`)
|
|
}
|
|
|
|
isSuccessStatusCode(statusCode?: number): boolean {
|
|
if (!statusCode) return false
|
|
return statusCode >= 200 && statusCode < 300
|
|
}
|
|
|
|
isRetryableHttpStatusCode(statusCode?: number): boolean {
|
|
if (!statusCode) return false
|
|
|
|
const retryableStatusCodes = [
|
|
HttpCodes.BadGateway,
|
|
HttpCodes.GatewayTimeout,
|
|
HttpCodes.InternalServerError,
|
|
HttpCodes.ServiceUnavailable,
|
|
HttpCodes.TooManyRequests
|
|
]
|
|
|
|
return retryableStatusCodes.includes(statusCode)
|
|
}
|
|
|
|
async sleep(milliseconds: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, milliseconds))
|
|
}
|
|
|
|
getExponentialRetryTimeMilliseconds(attempt: number): number {
|
|
if (attempt < 0) {
|
|
throw new Error('attempt should be a positive integer')
|
|
}
|
|
|
|
if (attempt === 0) {
|
|
return this.baseRetryIntervalMilliseconds
|
|
}
|
|
|
|
const minTime =
|
|
this.baseRetryIntervalMilliseconds * this.retryMultiplier ** attempt
|
|
const maxTime = minTime * this.retryMultiplier
|
|
|
|
// returns a random number between minTime and maxTime (exclusive)
|
|
return Math.trunc(Math.random() * (maxTime - minTime) + minTime)
|
|
}
|
|
}
|
|
|
|
export function internalArtifactTwirpClient(options?: {
|
|
maxAttempts?: number
|
|
retryIntervalMs?: number
|
|
retryMultiplier?: number
|
|
}): ArtifactServiceClientJSON {
|
|
const client = new ArtifactHttpClient(
|
|
getUserAgentString(),
|
|
options?.maxAttempts,
|
|
options?.retryIntervalMs,
|
|
options?.retryMultiplier
|
|
)
|
|
return new ArtifactServiceClientJSON(client)
|
|
}
|