diff --git a/sources/src/dependency-graph.ts b/sources/src/dependency-graph.ts index 19581d9..33fed02 100644 --- a/sources/src/dependency-graph.ts +++ b/sources/src/dependency-graph.ts @@ -204,7 +204,8 @@ async function submitDependencyGraphs(dependencyGraphFiles: string[]): Promise { const octokit = getOctokit() const jsonContent = fs.readFileSync(jsonFile, 'utf8') @@ -221,10 +225,42 @@ async function submitDependencyGraphFile(jsonFile: string): Promise { const jsonObject = JSON.parse(jsonContent) jsonObject.owner = github.context.repo.owner jsonObject.repo = github.context.repo.repo - const response = await octokit.request('POST /repos/{owner}/{repo}/dependency-graph/snapshots', jsonObject) - const relativeJsonFile = getRelativePathFromWorkspace(jsonFile) - core.notice(`Submitted ${relativeJsonFile}: ${response.data.message}`) + for (let attempt = 1; attempt <= DEPENDENCY_SUBMISSION_MAX_ATTEMPTS; attempt++) { + try { + const response = await octokit.request('POST /repos/{owner}/{repo}/dependency-graph/snapshots', jsonObject) + const relativeJsonFile = getRelativePathFromWorkspace(jsonFile) + core.notice(`Submitted ${relativeJsonFile}: ${response.data.message}`) + return + } catch (error) { + if (isRetryableError(error) && attempt < DEPENDENCY_SUBMISSION_MAX_ATTEMPTS) { + const delay = DEPENDENCY_SUBMISSION_BASE_DELAY_MS * Math.pow(2, attempt - 1) + core.info( + `Dependency submission attempt ${attempt} failed` + + `${getErrorStatusText(error)}. ` + + `Retrying in ${delay}ms...` + ) + await new Promise(resolve => setTimeout(resolve, delay)) + } else { + throw error + } + } + } +} + +export function isRetryableError(error: unknown): boolean { + if (error instanceof Error && 'status' in error) { + const status = (error as Error & {status: number}).status + return status === 429 || status >= 500 + } + return false +} + +export function getErrorStatusText(error: unknown): string { + if (error instanceof Error && 'status' in error) { + return ` (HTTP ${(error as Error & {status: number}).status})` + } + return '' } function getReportDirectory(): string { return process.env.DEPENDENCY_GRAPH_REPORT_DIR! diff --git a/sources/test/jest/dependency-graph.test.ts b/sources/test/jest/dependency-graph.test.ts index c2c02da..0621a75 100644 --- a/sources/test/jest/dependency-graph.test.ts +++ b/sources/test/jest/dependency-graph.test.ts @@ -1,6 +1,7 @@ import {describe, expect, it} from '@jest/globals' import {DependencyGraphConfig} from "../../src/configuration" +import {isRetryableError, getErrorStatusText} from "../../src/dependency-graph" describe('dependency-graph', () => { describe('constructs job correlator', () => { @@ -33,4 +34,68 @@ describe('dependency-graph', () => { expect(id).toBe('workflow-jobid-windows-211-value_with_comma') }) }) + + describe('isRetryableError', () => { + function httpError(status: number, message: string): Error { + const error = new Error(message) + error.name = 'HttpError' + ;(error as Error & {status: number}).status = status + return error + } + + it('returns true for HTTP 429 (rate limit)', () => { + expect(isRetryableError(httpError(429, 'rate limit exceeded'))).toBe(true) + }) + + it('returns true for HTTP 500 (internal server error)', () => { + expect(isRetryableError(httpError(500, 'Internal Server Error'))).toBe(true) + }) + + it('returns true for HTTP 502 (bad gateway)', () => { + expect(isRetryableError(httpError(502, 'Bad Gateway'))).toBe(true) + }) + + it('returns true for HTTP 503 (service unavailable)', () => { + expect(isRetryableError(httpError(503, 'Service Unavailable'))).toBe(true) + }) + + it('returns false for HTTP 403 (forbidden)', () => { + expect(isRetryableError(httpError(403, 'Resource not accessible by integration'))).toBe(false) + }) + + it('returns false for HTTP 404 (not found)', () => { + expect(isRetryableError(httpError(404, 'Not Found'))).toBe(false) + }) + + it('returns false for HTTP 422 (unprocessable entity)', () => { + expect(isRetryableError(httpError(422, 'Validation Failed'))).toBe(false) + }) + + it('returns false for a plain Error without status', () => { + expect(isRetryableError(new Error('network error'))).toBe(false) + }) + + it('returns false for non-Error values', () => { + expect(isRetryableError('some string')).toBe(false) + expect(isRetryableError(null)).toBe(false) + expect(isRetryableError(undefined)).toBe(false) + }) + }) + + describe('getErrorStatusText', () => { + it('returns status text for HttpError with status', () => { + const error = new Error('Server Error') + ;(error as Error & {status: number}).status = 503 + expect(getErrorStatusText(error)).toBe(' (HTTP 503)') + }) + + it('returns empty string for plain Error without status', () => { + expect(getErrorStatusText(new Error('no status'))).toBe('') + }) + + it('returns empty string for non-Error values', () => { + expect(getErrorStatusText('string')).toBe('') + expect(getErrorStatusText(null)).toBe('') + }) + }) })