1
0
Fork 0
pull/1486/head
Bethany 2023-08-08 13:19:43 -07:00
parent ad9b955fe9
commit c0684c5add
2 changed files with 175 additions and 146 deletions

View File

@ -1,183 +1,200 @@
import * as http from "http" import * as http from 'http'
import * as net from "net" import * as net from 'net'
import { HttpClient } from "@actions/http-client" import {HttpClient} from '@actions/http-client'
import * as config from "../src/internal/shared/config" import * as config from '../src/internal/shared/config'
import { createArtifactTwirpClient } from "../src/internal/shared/artifact-twirp-client" import {createArtifactTwirpClient} from '../src/internal/shared/artifact-twirp-client'
import * as core from "@actions/core" import * as core from '@actions/core'
jest.mock("@actions/http-client") jest.mock('@actions/http-client')
describe("artifact-http-client", () => { describe('artifact-http-client', () => {
beforeAll(() => { beforeAll(() => {
// mock all output so that there is less noise when running tests // mock all output so that there is less noise when running tests
jest.spyOn(console, "log").mockImplementation(() => {}) jest.spyOn(console, 'log').mockImplementation(() => {})
jest.spyOn(core, "debug").mockImplementation(() => {}) jest.spyOn(core, 'debug').mockImplementation(() => {})
jest.spyOn(core, "info").mockImplementation(() => {}) jest.spyOn(core, 'info').mockImplementation(() => {})
jest.spyOn(core, "warning").mockImplementation(() => {}) jest.spyOn(core, 'warning').mockImplementation(() => {})
jest.spyOn(config, "getResultsServiceUrl").mockReturnValue("http://localhost:8080") jest
jest.spyOn(config, "getRuntimeToken").mockReturnValue("token") .spyOn(config, 'getResultsServiceUrl')
.mockReturnValue('http://localhost:8080')
jest.spyOn(config, 'getRuntimeToken').mockReturnValue('token')
}) })
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
it("should successfully create a client", () => { it('should successfully create a client', () => {
const client = createArtifactTwirpClient("upload") const client = createArtifactTwirpClient('upload')
expect(client).toBeDefined() expect(client).toBeDefined()
}) })
it("should make a request", async () => { it('should make a request', async () => {
const mockPost = jest.fn(() => { const mockPost = jest.fn(() => {
const msg = new http.IncomingMessage(new net.Socket()) const msg = new http.IncomingMessage(new net.Socket())
msg.statusCode = 200 msg.statusCode = 200
return { return {
message: msg, message: msg,
readBody: () => {return Promise.resolve(`{"ok": true, "signedUploadUrl": "http://localhost:8080/upload"}`)} readBody: () => {
return Promise.resolve(
`{"ok": true, "signedUploadUrl": "http://localhost:8080/upload"}`
)
}
} }
}) })
const mockHttpClient = (HttpClient as unknown as jest.Mock).mockImplementation(() => { const mockHttpClient = (
HttpClient as unknown as jest.Mock
).mockImplementation(() => {
return { return {
post: mockPost post: mockPost
} }
}) })
const client = createArtifactTwirpClient("upload") const client = createArtifactTwirpClient('upload')
const artifact = await client.CreateArtifact( const artifact = await client.CreateArtifact({
{ workflowRunBackendId: '1234',
workflowRunBackendId: "1234", workflowJobRunBackendId: '5678',
workflowJobRunBackendId: "5678", name: 'artifact',
name: "artifact", version: 4
version: 4 })
}
)
expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockHttpClient).toHaveBeenCalledTimes(1)
expect(mockPost).toHaveBeenCalledTimes(1) expect(mockPost).toHaveBeenCalledTimes(1)
expect(artifact).toBeDefined() expect(artifact).toBeDefined()
expect(artifact.ok).toBe(true) expect(artifact.ok).toBe(true)
expect(artifact.signedUploadUrl).toBe("http://localhost:8080/upload") expect(artifact.signedUploadUrl).toBe('http://localhost:8080/upload')
}) })
it("should retry if the request fails", async () => { it('should retry if the request fails', async () => {
const mockPost = jest const mockPost = jest
.fn(() => { .fn(() => {
const msgSucceeded = new http.IncomingMessage(new net.Socket()) const msgSucceeded = new http.IncomingMessage(new net.Socket())
msgSucceeded.statusCode = 200 msgSucceeded.statusCode = 200
return { return {
message: msgSucceeded, message: msgSucceeded,
readBody: () => {return Promise.resolve(`{"ok": true, "signedUploadUrl": "http://localhost:8080/upload"}`)} readBody: () => {
} return Promise.resolve(
}) `{"ok": true, "signedUploadUrl": "http://localhost:8080/upload"}`
.mockImplementationOnce(() => { )
const msgFailed = new http.IncomingMessage(new net.Socket()) }
msgFailed.statusCode = 500 }
msgFailed.statusMessage = "Internal Server Error" })
return { .mockImplementationOnce(() => {
message: msgFailed, const msgFailed = new http.IncomingMessage(new net.Socket())
readBody: () => {return Promise.resolve(`{"ok": false}`)} msgFailed.statusCode = 500
} msgFailed.statusMessage = 'Internal Server Error'
}) return {
const mockHttpClient = (HttpClient as unknown as jest.Mock).mockImplementation(() => { message: msgFailed,
readBody: () => {
return Promise.resolve(`{"ok": false}`)
}
}
})
const mockHttpClient = (
HttpClient as unknown as jest.Mock
).mockImplementation(() => {
return { return {
post: mockPost post: mockPost
} }
}) })
const client = createArtifactTwirpClient( const client = createArtifactTwirpClient(
"upload", 'upload',
5, // retry 5 times 5, // retry 5 times
1, // wait 1 ms 1, // wait 1 ms
1.5 // backoff factor 1.5 // backoff factor
) )
const artifact = await client.CreateArtifact( const artifact = await client.CreateArtifact({
{ workflowRunBackendId: '1234',
workflowRunBackendId: "1234", workflowJobRunBackendId: '5678',
workflowJobRunBackendId: "5678", name: 'artifact',
name: "artifact", version: 4
version: 4 })
}
)
expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockHttpClient).toHaveBeenCalledTimes(1)
expect(artifact).toBeDefined() expect(artifact).toBeDefined()
expect(artifact.ok).toBe(true) expect(artifact.ok).toBe(true)
expect(artifact.signedUploadUrl).toBe("http://localhost:8080/upload") expect(artifact.signedUploadUrl).toBe('http://localhost:8080/upload')
expect(mockPost).toHaveBeenCalledTimes(2) expect(mockPost).toHaveBeenCalledTimes(2)
}) })
it("should fail if the request fails 5 times", async () => { it('should fail if the request fails 5 times', async () => {
const mockPost = jest const mockPost = jest.fn(() => {
.fn(() => { const msgFailed = new http.IncomingMessage(new net.Socket())
const msgFailed = new http.IncomingMessage(new net.Socket()) msgFailed.statusCode = 500
msgFailed.statusCode = 500 msgFailed.statusMessage = 'Internal Server Error'
msgFailed.statusMessage = "Internal Server Error" return {
return { message: msgFailed,
message: msgFailed, readBody: () => {
readBody: () => {return Promise.resolve(`{"ok": false}`)} return Promise.resolve(`{"ok": false}`)
} }
}) }
})
const mockHttpClient = (HttpClient as unknown as jest.Mock).mockImplementation(() => { const mockHttpClient = (
return { HttpClient as unknown as jest.Mock
post: mockPost ).mockImplementation(() => {
} return {
}) post: mockPost
const client = createArtifactTwirpClient( }
"upload", })
5, // retry 5 times const client = createArtifactTwirpClient(
1, // wait 1 ms 'upload',
1.5 // backoff factor 5, // retry 5 times
) 1, // wait 1 ms
1.5 // backoff factor
)
await expect(async () => { await expect(async () => {
await client.CreateArtifact( await client.CreateArtifact({
{ workflowRunBackendId: '1234',
workflowRunBackendId: "1234", workflowJobRunBackendId: '5678',
workflowJobRunBackendId: "5678", name: 'artifact',
name: "artifact", version: 4
version: 4 })
} }).rejects.toThrowError(
) 'Failed to make request after 5 attempts: Failed request: (500) Internal Server Error'
}).rejects.toThrowError("Failed to make request after 5 attempts: Failed request: (500) Internal Server Error") )
expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockHttpClient).toHaveBeenCalledTimes(1)
expect(mockPost).toHaveBeenCalledTimes(5) expect(mockPost).toHaveBeenCalledTimes(5)
}) })
it("should fail immediately if there is a non-retryable error", async () => {
const mockPost = jest
.fn(() => {
const msgFailed = new http.IncomingMessage(new net.Socket())
msgFailed.statusCode = 401
msgFailed.statusMessage = "Unauthorized"
return {
message: msgFailed,
readBody: () => {return Promise.resolve(`{"ok": false}`)}
}
})
const mockHttpClient = (HttpClient as unknown as jest.Mock).mockImplementation(() => { it('should fail immediately if there is a non-retryable error', async () => {
return { const mockPost = jest.fn(() => {
post: mockPost const msgFailed = new http.IncomingMessage(new net.Socket())
msgFailed.statusCode = 401
msgFailed.statusMessage = 'Unauthorized'
return {
message: msgFailed,
readBody: () => {
return Promise.resolve(`{"ok": false}`)
} }
}) }
const client = createArtifactTwirpClient( })
"upload",
5, // retry 5 times const mockHttpClient = (
1, // wait 1 ms HttpClient as unknown as jest.Mock
1.5 // backoff factor ).mockImplementation(() => {
) return {
post: mockPost
}
})
const client = createArtifactTwirpClient(
'upload',
5, // retry 5 times
1, // wait 1 ms
1.5 // backoff factor
)
await expect(async () => { await expect(async () => {
await client.CreateArtifact( await client.CreateArtifact({
{ workflowRunBackendId: '1234',
workflowRunBackendId: "1234", workflowJobRunBackendId: '5678',
workflowJobRunBackendId: "5678", name: 'artifact',
name: "artifact", version: 4
version: 4 })
} }).rejects.toThrowError(
) 'Received non-retryable error: Failed request: (401) Unauthorized'
}).rejects.toThrowError("Received non-retryable error: Failed request: (401) Unauthorized") )
expect(mockHttpClient).toHaveBeenCalledTimes(1) expect(mockHttpClient).toHaveBeenCalledTimes(1)
expect(mockPost).toHaveBeenCalledTimes(1) expect(mockPost).toHaveBeenCalledTimes(1)
}) })
}) })

View File

@ -1,15 +1,15 @@
import { HttpClient, HttpClientResponse, HttpCodes } from "@actions/http-client" import {HttpClient, HttpClientResponse, HttpCodes} from '@actions/http-client'
import { BearerCredentialHandler } from "@actions/http-client/lib/auth" import {BearerCredentialHandler} from '@actions/http-client/lib/auth'
import { info } from "@actions/core" import {info} from '@actions/core'
import { ArtifactServiceClientJSON } from "../../generated" import {ArtifactServiceClientJSON} from '../../generated'
import { getResultsServiceUrl, getRuntimeToken } from "./config" import {getResultsServiceUrl, getRuntimeToken} from './config'
// The twirp http client must implement this interface // The twirp http client must implement this interface
interface Rpc { interface Rpc {
request( request(
service: string, service: string,
method: string, method: string,
contentType: "application/json" | "application/protobuf", contentType: 'application/json' | 'application/protobuf',
data: object | Uint8Array data: object | Uint8Array
): Promise<object | Uint8Array> ): Promise<object | Uint8Array>
} }
@ -21,7 +21,12 @@ class ArtifactHttpClient implements Rpc {
private baseRetryIntervalMilliseconds: number = 3000 private baseRetryIntervalMilliseconds: number = 3000
private retryMultiplier: number = 1.5 private retryMultiplier: number = 1.5
constructor(userAgent: string, maxAttempts?: number, baseRetryIntervalMilliseconds?: number, retryMultiplier?: number) { constructor(
userAgent: string,
maxAttempts?: number,
baseRetryIntervalMilliseconds?: number,
retryMultiplier?: number
) {
const token = getRuntimeToken() const token = getRuntimeToken()
this.baseUrl = getResultsServiceUrl() this.baseUrl = getResultsServiceUrl()
if (maxAttempts) { if (maxAttempts) {
@ -34,10 +39,9 @@ class ArtifactHttpClient implements Rpc {
this.retryMultiplier = retryMultiplier this.retryMultiplier = retryMultiplier
} }
this.httpClient = new HttpClient( this.httpClient = new HttpClient(userAgent, [
userAgent, new BearerCredentialHandler(token)
[new BearerCredentialHandler(token)], ])
)
} }
// This function satisfies the Rpc interface. It is compatible with the JSON // This function satisfies the Rpc interface. It is compatible with the JSON
@ -45,17 +49,19 @@ class ArtifactHttpClient implements Rpc {
async request( async request(
service: string, service: string,
method: string, method: string,
contentType: "application/json" | "application/protobuf", contentType: 'application/json' | 'application/protobuf',
data: object | Uint8Array data: object | Uint8Array
): Promise<object | Uint8Array> { ): Promise<object | Uint8Array> {
let url = `${this.baseUrl}/twirp/${service}/${method}` let url = `${this.baseUrl}/twirp/${service}/${method}`
let headers = { let headers = {
"Content-Type": contentType, 'Content-Type': contentType
} }
info(`Making request to ${url} with data: ${JSON.stringify(data)}`) info(`Making request to ${url} with data: ${JSON.stringify(data)}`)
try { try {
const response = await this.retryableRequest(() => this.httpClient.post(url, JSON.stringify(data), headers)) const response = await this.retryableRequest(() =>
this.httpClient.post(url, JSON.stringify(data), headers)
)
const body = await response.readBody() const body = await response.readBody()
return JSON.parse(body) return JSON.parse(body)
} catch (error) { } catch (error) {
@ -64,10 +70,10 @@ class ArtifactHttpClient implements Rpc {
} }
async retryableRequest( async retryableRequest(
operation: () => Promise<HttpClientResponse>, operation: () => Promise<HttpClientResponse>
): Promise<HttpClientResponse> { ): Promise<HttpClientResponse> {
let attempt = 0 let attempt = 0
let errorMessage = "" let errorMessage = ''
while (attempt < this.maxAttempts) { while (attempt < this.maxAttempts) {
let isRetryable = false let isRetryable = false
@ -91,12 +97,17 @@ class ArtifactHttpClient implements Rpc {
} }
if (attempt + 1 === this.maxAttempts) { if (attempt + 1 === this.maxAttempts) {
throw new Error(`Failed to make request after ${this.maxAttempts} attempts: ${errorMessage}`) throw new Error(
`Failed to make request after ${this.maxAttempts} attempts: ${errorMessage}`
)
} }
const retryTimeMilliseconds = this.getExponentialRetryTimeMilliseconds(attempt) const retryTimeMilliseconds =
this.getExponentialRetryTimeMilliseconds(attempt)
info( info(
`Attempt ${attempt + 1} of ${this.maxAttempts} failed with error: ${errorMessage}. Retrying request in ${retryTimeMilliseconds} ms...` `Attempt ${attempt + 1} of ${
this.maxAttempts
} failed with error: ${errorMessage}. Retrying request in ${retryTimeMilliseconds} ms...`
) )
await this.sleep(retryTimeMilliseconds) await this.sleep(retryTimeMilliseconds)
attempt++ attempt++
@ -131,14 +142,15 @@ class ArtifactHttpClient implements Rpc {
getExponentialRetryTimeMilliseconds(attempt: number): number { getExponentialRetryTimeMilliseconds(attempt: number): number {
if (attempt < 0) { if (attempt < 0) {
throw new Error("attempt should be a positive integer") throw new Error('attempt should be a positive integer')
} }
if (attempt === 0) { if (attempt === 0) {
return this.baseRetryIntervalMilliseconds return this.baseRetryIntervalMilliseconds
} }
const minTime = this.baseRetryIntervalMilliseconds * this.retryMultiplier ** (attempt) const minTime =
this.baseRetryIntervalMilliseconds * this.retryMultiplier ** attempt
const maxTime = minTime * this.retryMultiplier const maxTime = minTime * this.retryMultiplier
// returns a random number between minTime and maxTime (exclusive) // returns a random number between minTime and maxTime (exclusive)
@ -147,15 +159,15 @@ class ArtifactHttpClient implements Rpc {
} }
export function createArtifactTwirpClient( export function createArtifactTwirpClient(
type: "upload" | "download", type: 'upload' | 'download',
maxAttempts?: number, maxAttempts?: number,
baseRetryIntervalMilliseconds?: number, baseRetryIntervalMilliseconds?: number,
retryMultiplier?: number retryMultiplier?: number
): ArtifactServiceClientJSON { ): ArtifactServiceClientJSON {
const client = new ArtifactHttpClient( const client = new ArtifactHttpClient(
`@actions/artifact-${type}`, `@actions/artifact-${type}`,
maxAttempts, maxAttempts,
baseRetryIntervalMilliseconds, baseRetryIntervalMilliseconds,
retryMultiplier retryMultiplier
) )
return new ArtifactServiceClientJSON(client) return new ArtifactServiceClientJSON(client)