Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/k8s/src/k8s/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ export async function execCpToPod(
attempt++
if (attempt >= 30) {
throw new Error(
`cpToPod failed after ${attempt} attempts: ${JSON.stringify(error)}`
`cpToPod failed after ${attempt} attempts: ${error instanceof Error ? error.message : String(error)}`
)
}
await sleep(1000)
Expand Down Expand Up @@ -527,7 +527,7 @@ export async function execCpFromPod(
attempt++
if (attempt >= 30) {
throw new Error(
`execCpFromPod failed after ${attempt} attempts: ${JSON.stringify(error)}`
`execCpFromPod failed after ${attempt} attempts: ${error instanceof Error ? error.message : String(error)}`
)
}
await sleep(1000)
Expand Down Expand Up @@ -574,7 +574,7 @@ export async function waitForJobToComplete(jobName: string): Promise<void> {
return
}
} catch (error) {
throw new Error(`job ${jobName} has failed: ${JSON.stringify(error)}`)
throw new Error(`job ${jobName} has failed: ${error instanceof Error ? error.message : String(error)}`)
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wrap can produce duplicated messages when the underlying error already includes job ${jobName} has failed (e.g. from isJobSucceeded), resulting in job X has failed: job X has failed. Consider rethrowing the original error in that case, or changing the wrapper message to add new context (and/or use new Error(msg, { cause: error }) to preserve the original error).

Suggested change
throw new Error(`job ${jobName} has failed: ${error instanceof Error ? error.message : String(error)}`)
const jobFailedMessage = `job ${jobName} has failed`
if (error instanceof Error && error.message.includes(jobFailedMessage)) {
throw error
}
throw new Error(
`${jobFailedMessage}: ${
error instanceof Error ? error.message : String(error)
}`
)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically valid observation: isJobSucceeded throws "job X has failed", and the catch wraps it as "job X has failed: job X has failed". But this is pre-existing behavior — it was "job X has failed: {}" before. Our
PR just makes the duplication visible instead of hiding it behind {}. I'd say this is out of scope for a bug-fix PR — it's a separate improvement. The suggested fix also adds complexity (conditional rethrow) that goes beyond the intent of this change.

}
await backOffManager.backOff()
}
Expand Down Expand Up @@ -697,7 +697,7 @@ export async function waitForPodPhases(
}
} catch (error) {
throw new Error(
`Pod ${podName} is unhealthy with phase status ${phase}: ${JSON.stringify(error)}`
`Pod ${podName} is unhealthy with phase status ${phase}: ${error instanceof Error ? error.message : String(error)}`
)
Comment on lines 699 to 701
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the pod enters an unhealthy phase, the inner throw already uses the same prefix (Pod ... is unhealthy with phase status ${phase}), so this wrapper will duplicate the message (...: Pod ...). Consider only wrapping errors that come from getPodPhase/backoff (and rethrowing the unhealthy-phase error as-is), or detect the already-prefixed message to avoid duplication. Also consider setting the original error as { cause: error } to preserve context.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reasoning. The inner throw at line 692 uses the same prefix, so the outer catch wraps it redundantly. Again pre-existing, just newly visible. Out of scope.

}
}
Expand Down
200 changes: 200 additions & 0 deletions packages/k8s/tests/error-serialization-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { WritableStreamBuffer } from 'stream-buffers'

Comment thread
jeanschmidt marked this conversation as resolved.
Outdated
const mockExec = jest.fn()
const mockReadNamespacedPod = jest.fn()
const mockReadNamespacedJob = jest.fn()

jest.mock('@kubernetes/client-node', () => {
return {
KubeConfig: jest.fn().mockImplementation(() => ({
loadFromDefault: jest.fn(),
makeApiClient: jest.fn().mockImplementation(ApiClass => {
const name = ApiClass?.name || ApiClass?.toString() || ''
if (name.includes('Batch')) {
return { readNamespacedJob: mockReadNamespacedJob }
}
if (name.includes('Authorization')) {
return { createSelfSubjectAccessReview: jest.fn() }
}
return { readNamespacedPod: mockReadNamespacedPod }
}),
getContexts: jest
.fn()
.mockReturnValue([{ namespace: 'test-namespace' }])
})),
Exec: jest.fn().mockImplementation(() => ({ exec: mockExec })),
CoreV1Api: class CoreV1Api {},
BatchV1Api: class BatchV1Api {},
AuthorizationV1Api: class AuthorizationV1Api {},
Log: jest.fn()
}
})

jest.mock('tar-fs', () => ({
default: {
pack: jest.fn().mockReturnValue({ pipe: jest.fn() }),
extract: jest.fn().mockReturnValue({
on: jest.fn(),
pipe: jest.fn()
})
},
__esModule: true
}))

jest.mock('../src/k8s/utils', () => {
const actual = jest.requireActual('../src/k8s/utils')
return {
...actual,
sleep: jest.fn().mockResolvedValue(undefined)
}
})

import {
execCpToPod,
execCpFromPod,
waitForJobToComplete,
waitForPodPhases
} from '../src/k8s'
import { PodPhase } from '../src/k8s/utils'

describe('error serialization', () => {
beforeEach(() => {
jest.clearAllMocks()
process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE'] = 'test-namespace'
})

afterEach(() => {
delete process.env['ACTIONS_RUNNER_KUBERNETES_NAMESPACE']
})

describe('execCpToPod', () => {
it('should include Error.message in thrown error after retries', async () => {
mockExec.mockRejectedValue(new Error('connection refused'))

await expect(
execCpToPod('test-pod', '/tmp/src', '/workspace')
).rejects.toThrow('cpToPod failed after 30 attempts: connection refused')
})

it('should use String() for non-Error throwables', async () => {
mockExec.mockRejectedValue('raw string error')

await expect(
execCpToPod('test-pod', '/tmp/src', '/workspace')
).rejects.toThrow('cpToPod failed after 30 attempts: raw string error')
})

it('should not produce empty braces in error message', async () => {
mockExec.mockRejectedValue(new Error('ETIMEOUT'))

await expect(
execCpToPod('test-pod', '/tmp/src', '/workspace')
).rejects.toThrow(
expect.not.objectContaining({ message: expect.stringContaining('{}') })
)
Comment thread
jeanschmidt marked this conversation as resolved.
Outdated
})
})

describe('execCpFromPod', () => {
it('should include Error.message in thrown error after retries', async () => {
mockExec.mockRejectedValue(new Error('container not found'))

await expect(
execCpFromPod('test-pod', '/workspace/output', '/tmp/dst')
).rejects.toThrow(
'execCpFromPod failed after 30 attempts: container not found'
)
})

it('should use String() for non-Error throwables', async () => {
mockExec.mockRejectedValue(42)

await expect(
execCpFromPod('test-pod', '/workspace/output', '/tmp/dst')
).rejects.toThrow('execCpFromPod failed after 30 attempts: 42')
})
})

describe('waitForJobToComplete', () => {
it('should include Error.message when job fails', async () => {
mockReadNamespacedJob.mockResolvedValue({
status: { failed: 1 }
})

await expect(waitForJobToComplete('my-job')).rejects.toThrow(
'job my-job has failed: job my-job has failed'
)
})

it('should include Error.message when API call throws', async () => {
mockReadNamespacedJob.mockRejectedValue(
new Error('403 Forbidden')
)

await expect(waitForJobToComplete('my-job')).rejects.toThrow(
'job my-job has failed: 403 Forbidden'
)
})

it('should use String() for non-Error throwables from API', async () => {
mockReadNamespacedJob.mockRejectedValue('unexpected API failure')

await expect(waitForJobToComplete('my-job')).rejects.toThrow(
'job my-job has failed: unexpected API failure'
)
})
})

describe('waitForPodPhases', () => {
it('should include error message when pod enters unhealthy phase', async () => {
mockReadNamespacedPod.mockResolvedValue({
status: { phase: 'Failed' }
})

await expect(
waitForPodPhases(
'test-pod',
new Set([PodPhase.RUNNING]),
new Set([PodPhase.PENDING])
)
).rejects.toThrow(
/Pod test-pod is unhealthy with phase status Failed/
)
})

it('should include Error.message when API call throws', async () => {
mockReadNamespacedPod.mockRejectedValue(
new Error('network timeout')
)

await expect(
waitForPodPhases(
'test-pod',
new Set([PodPhase.RUNNING]),
new Set([PodPhase.PENDING])
)
).rejects.toThrow(
'Pod test-pod is unhealthy with phase status Unknown: network timeout'
)
})

it('should not produce empty braces from Error objects', async () => {
mockReadNamespacedPod.mockRejectedValue(
new Error('socket hang up')
)

try {
await waitForPodPhases(
'test-pod',
new Set([PodPhase.RUNNING]),
new Set([PodPhase.PENDING])
)
fail('Expected waitForPodPhases to throw')
} catch (error) {
const msg = (error as Error).message
expect(msg).not.toContain('{}')
expect(msg).toContain('socket hang up')
}
})
})
})
Loading