Repository: actions/toolkit
Pull request #1603 (2023-12-11 22:09:57)

Add specific messages for network-specific node error codes
Will help customers debug network failures with self hosted runners faster.

Example error text would be:

> Failed to CreateArtifact: Unable to make request: ENOTFOUND
> If 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

Closes: https://github.com/github/actions-results-team/issues/1805


Patch:
diff --git a/packages/artifact/src/internal/shared/artifact-twirp-client.ts b/packages/artifact/src/internal/shared/artifact-twirp-client.ts
index 4feba62..be26c64 100644
--- a/packages/artifact/src/internal/shared/artifact-twirp-client.ts
+++ b/packages/artifact/src/internal/shared/artifact-twirp-client.ts
@@ -4,6 +4,7 @@ import {info, debug} from '@actions/core'
 import {ArtifactServiceClientJSON} from '../../generated'
 import {getResultsServiceUrl, getRuntimeToken} from './config'
 import {getUserAgentString} from './user-agent'
+import {NetworkError} from './errors'
 
 // The twirp http client must implement this interface
 interface Rpc {
@@ -96,6 +97,9 @@ class ArtifactHttpClient implements Rpc {
       } catch (error) {
         isRetryable = true
         errorMessage = error.message
+        if (NetworkError.isNetworkErrorCode(error?.code)) {
+          throw new NetworkError(error?.code)
+        }
       }
 
       if (!isRetryable) {
diff --git a/packages/artifact/src/internal/shared/errors.ts b/packages/artifact/src/internal/shared/errors.ts
index 841c44a..4f23ecd 100644
--- a/packages/artifact/src/internal/shared/errors.ts
+++ b/packages/artifact/src/internal/shared/errors.ts
@@ -35,3 +35,25 @@ export class GHESNotSupportedError extends Error {
     this.name = 'GHESNotSupportedError'
   }
 }
+
+export class NetworkError extends Error {
+  code: string
+
+  constructor(code: string) {
+    const message = `Unable to make request: ${code}\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`
+    super(message)
+    this.code = code
+    this.name = 'NetworkError'
+  }
+
+  static isNetworkErrorCode = (code?: string): boolean => {
+    if (!code) return false
+    return [
+      'ECONNRESET',
+      'ENOTFOUND',
+      'ETIMEDOUT',
+      'ECONNREFUSED',
+      'EHOSTUNREACH'
+    ].includes(code)
+  }
+}
diff --git a/packages/artifact/src/internal/upload/blob-upload.ts b/packages/artifact/src/internal/upload/blob-upload.ts
index 5947238..87bb723 100644
--- a/packages/artifact/src/internal/upload/blob-upload.ts
+++ b/packages/artifact/src/internal/upload/blob-upload.ts
@@ -5,6 +5,7 @@ import {getUploadChunkSize, getConcurrency} from '../shared/config'
 import * as core from '@actions/core'
 import * as crypto from 'crypto'
 import * as stream from 'stream'
+import {NetworkError} from '../shared/errors'
 
 export interface BlobUploadResponse {
   /**
@@ -52,12 +53,20 @@ export async function uploadZipToBlobStorage(
 
   core.info('Beginning upload of artifact content to blob storage')
 
-  await blockBlobClient.uploadStream(
-    uploadStream,
-    bufferSize,
-    maxConcurrency,
-    options
-  )
+  try {
+    await blockBlobClient.uploadStream(
+      uploadStream,
+      bufferSize,
+      maxConcurrency,
+      options
+    )
+  } catch (error) {
+    if (NetworkError.isNetworkErrorCode(error?.code)) {
+      throw new NetworkError(error?.code)
+    }
+
+    throw error
+  }
 
   core.info('Finished uploading artifact content to blob storage!')
 


Test patch:
diff --git a/packages/artifact/__tests__/artifact-http-client.test.ts b/packages/artifact/__tests__/artifact-http-client.test.ts
index 3c6c378..d96c0d5 100644
--- a/packages/artifact/__tests__/artifact-http-client.test.ts
+++ b/packages/artifact/__tests__/artifact-http-client.test.ts
@@ -7,6 +7,12 @@ import {noopLogs} from './common'
 
 jest.mock('@actions/http-client')
 
+const clientOptions = {
+  maxAttempts: 5,
+  retryIntervalMs: 1,
+  retryMultiplier: 1.5
+}
+
 describe('artifact-http-client', () => {
   beforeAll(() => {
     noopLogs()
@@ -94,11 +100,7 @@ describe('artifact-http-client', () => {
       }
     })
 
-    const client = internalArtifactTwirpClient({
-      maxAttempts: 5,
-      retryIntervalMs: 1,
-      retryMultiplier: 1.5
-    })
+    const client = internalArtifactTwirpClient(clientOptions)
     const artifact = await client.CreateArtifact({
       workflowRunBackendId: '1234',
       workflowJobRunBackendId: '5678',
@@ -133,11 +135,7 @@ describe('artifact-http-client', () => {
         post: mockPost
       }
     })
-    const client = internalArtifactTwirpClient({
-      maxAttempts: 5,
-      retryIntervalMs: 1,
-      retryMultiplier: 1.5
-    })
+    const client = internalArtifactTwirpClient(clientOptions)
     await expect(async () => {
       await client.CreateArtifact({
         workflowRunBackendId: '1234',
@@ -172,11 +170,7 @@ describe('artifact-http-client', () => {
         post: mockPost
       }
     })
-    const client = internalArtifactTwirpClient({
-      maxAttempts: 5,
-      retryIntervalMs: 1,
-      retryMultiplier: 1.5
-    })
+    const client = internalArtifactTwirpClient(clientOptions)
     await expect(async () => {
       await client.CreateArtifact({
         workflowRunBackendId: '1234',
@@ -214,11 +208,7 @@ describe('artifact-http-client', () => {
         post: mockPost
       }
     })
-    const client = internalArtifactTwirpClient({
-      maxAttempts: 5,
-      retryIntervalMs: 1,
-      retryMultiplier: 1.5
-    })
+    const client = internalArtifactTwirpClient(clientOptions)
     await expect(async () => {
       await client.CreateArtifact({
         workflowRunBackendId: '1234',
@@ -238,4 +228,39 @@ describe('artifact-http-client', () => {
     expect(mockHttpClient).toHaveBeenCalledTimes(1)
     expect(mockPost).toHaveBeenCalledTimes(1)
   })
+
+  it('should properly describe a network failure', async () => {
+    class FakeNodeError extends Error {
+      code: string
+      constructor(code: string) {
+        super()
+        this.code = code
+      }
+    }
+
+    const mockPost = jest.fn(() => {
+      throw new FakeNodeError('ENOTFOUND')
+    })
+
+    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(
+      '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)
+  })
 })