mirror of https://github.com/actions/toolkit
Merge pull request #1605 from actions/robherley/usage-message
Better error message for artifact usage limitspull/1607/head
commit
eff198be5b
|
@ -4,6 +4,7 @@ import {HttpClient} from '@actions/http-client'
|
||||||
import * as config from '../src/internal/shared/config'
|
import * as config from '../src/internal/shared/config'
|
||||||
import {internalArtifactTwirpClient} from '../src/internal/shared/artifact-twirp-client'
|
import {internalArtifactTwirpClient} from '../src/internal/shared/artifact-twirp-client'
|
||||||
import {noopLogs} from './common'
|
import {noopLogs} from './common'
|
||||||
|
import {NetworkError, UsageError} from '../src/internal/shared/errors'
|
||||||
|
|
||||||
jest.mock('@actions/http-client')
|
jest.mock('@actions/http-client')
|
||||||
|
|
||||||
|
@ -257,9 +258,42 @@ describe('artifact-http-client', () => {
|
||||||
name: 'artifact',
|
name: 'artifact',
|
||||||
version: 4
|
version: 4
|
||||||
})
|
})
|
||||||
}).rejects.toThrowError(
|
}).rejects.toThrowError(new NetworkError('ENOTFOUND').message)
|
||||||
'Failed to CreateArtifact: Unable to make request: ENOTFOUND\nIf you are using self-hosted runners, please make sure your runner has access to all GitHub endpoints: https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#communication-between-self-hosted-runners-and-github'
|
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should properly describe a usage error', async () => {
|
||||||
|
const mockPost = jest.fn(() => {
|
||||||
|
const msgFailed = new http.IncomingMessage(new net.Socket())
|
||||||
|
msgFailed.statusCode = 403
|
||||||
|
msgFailed.statusMessage = 'Forbidden'
|
||||||
|
return {
|
||||||
|
message: msgFailed,
|
||||||
|
readBody: async () => {
|
||||||
|
return Promise.resolve(
|
||||||
|
`{"msg": "insufficient usage to create artifact"}`
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockHttpClient = (
|
||||||
|
HttpClient as unknown as jest.Mock
|
||||||
|
).mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
post: mockPost
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const client = internalArtifactTwirpClient()
|
||||||
|
await expect(async () => {
|
||||||
|
await client.CreateArtifact({
|
||||||
|
workflowRunBackendId: '1234',
|
||||||
|
workflowJobRunBackendId: '5678',
|
||||||
|
name: 'artifact',
|
||||||
|
version: 4
|
||||||
|
})
|
||||||
|
}).rejects.toThrowError(new UsageError().message)
|
||||||
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
expect(mockHttpClient).toHaveBeenCalledTimes(1)
|
||||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {info, debug} from '@actions/core'
|
||||||
import {ArtifactServiceClientJSON} from '../../generated'
|
import {ArtifactServiceClientJSON} from '../../generated'
|
||||||
import {getResultsServiceUrl, getRuntimeToken} from './config'
|
import {getResultsServiceUrl, getRuntimeToken} from './config'
|
||||||
import {getUserAgentString} from './user-agent'
|
import {getUserAgentString} from './user-agent'
|
||||||
import {NetworkError} from './errors'
|
import {NetworkError, UsageError} from './errors'
|
||||||
|
|
||||||
// The twirp http client must implement this interface
|
// The twirp http client must implement this interface
|
||||||
interface Rpc {
|
interface Rpc {
|
||||||
|
@ -64,7 +64,7 @@ class ArtifactHttpClient implements Rpc {
|
||||||
this.httpClient.post(url, JSON.stringify(data), headers)
|
this.httpClient.post(url, JSON.stringify(data), headers)
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSON.parse(body)
|
return body
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to ${method}: ${error.message}`)
|
throw new Error(`Failed to ${method}: ${error.message}`)
|
||||||
}
|
}
|
||||||
|
@ -72,34 +72,49 @@ class ArtifactHttpClient implements Rpc {
|
||||||
|
|
||||||
async retryableRequest(
|
async retryableRequest(
|
||||||
operation: () => Promise<HttpClientResponse>
|
operation: () => Promise<HttpClientResponse>
|
||||||
): Promise<{response: HttpClientResponse; body: string}> {
|
): Promise<{response: HttpClientResponse; body: object}> {
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
let errorMessage = ''
|
let errorMessage = ''
|
||||||
|
let rawBody = ''
|
||||||
while (attempt < this.maxAttempts) {
|
while (attempt < this.maxAttempts) {
|
||||||
let isRetryable = false
|
let isRetryable = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await operation()
|
const response = await operation()
|
||||||
const statusCode = response.message.statusCode
|
const statusCode = response.message.statusCode
|
||||||
const body = await response.readBody()
|
rawBody = await response.readBody()
|
||||||
debug(`[Response] - ${response.message.statusCode}`)
|
debug(`[Response] - ${response.message.statusCode}`)
|
||||||
debug(`Headers: ${JSON.stringify(response.message.headers, null, 2)}`)
|
debug(`Headers: ${JSON.stringify(response.message.headers, null, 2)}`)
|
||||||
debug(`Body: ${body}`)
|
const body = JSON.parse(rawBody)
|
||||||
|
debug(`Body: ${JSON.stringify(body, null, 2)}`)
|
||||||
if (this.isSuccessStatusCode(statusCode)) {
|
if (this.isSuccessStatusCode(statusCode)) {
|
||||||
return {response, body}
|
return {response, body}
|
||||||
}
|
}
|
||||||
isRetryable = this.isRetryableHttpStatusCode(statusCode)
|
isRetryable = this.isRetryableHttpStatusCode(statusCode)
|
||||||
errorMessage = `Failed request: (${statusCode}) ${response.message.statusMessage}`
|
errorMessage = `Failed request: (${statusCode}) ${response.message.statusMessage}`
|
||||||
const responseMessage = JSON.parse(body).msg
|
if (body.msg) {
|
||||||
if (responseMessage) {
|
if (UsageError.isUsageErrorMessage(body.msg)) {
|
||||||
errorMessage = `${errorMessage}: ${responseMessage}`
|
throw new UsageError()
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = `${errorMessage}: ${body.msg}`
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isRetryable = true
|
if (error instanceof SyntaxError) {
|
||||||
errorMessage = error.message
|
debug(`Raw Body: ${rawBody}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof UsageError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
if (NetworkError.isNetworkErrorCode(error?.code)) {
|
if (NetworkError.isNetworkErrorCode(error?.code)) {
|
||||||
throw new NetworkError(error?.code)
|
throw new NetworkError(error?.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isRetryable = true
|
||||||
|
errorMessage = error.message
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRetryable) {
|
if (!isRetryable) {
|
||||||
|
|
|
@ -57,3 +57,16 @@ export class NetworkError extends Error {
|
||||||
].includes(code)
|
].includes(code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UsageError extends Error {
|
||||||
|
constructor() {
|
||||||
|
const message = `Artifact storage quota has been hit. Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.\nMore info on storage limits: https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#calculating-minute-and-storage-spending`
|
||||||
|
super(message)
|
||||||
|
this.name = 'UsageError'
|
||||||
|
}
|
||||||
|
|
||||||
|
static isUsageErrorMessage = (msg?: string): boolean => {
|
||||||
|
if (!msg) return false
|
||||||
|
return msg.includes('insufficient usage')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue