Skip to content

task: add download artifacts method#70

Open
na66im wants to merge 1 commit intomainfrom
task/tssdk-53-download-artifacts-method
Open

task: add download artifacts method#70
na66im wants to merge 1 commit intomainfrom
task/tssdk-53-download-artifacts-method

Conversation

@na66im
Copy link
Copy Markdown
Contributor

@na66im na66im commented Apr 8, 2026

Summary

Adds a new downloadArtifact method to PlatformSDKHttp that allows downloading binary artifact files (reports, images, etc.) from completed application runs, with built-in retry logic for transient failures.

Changes

New feature: downloadArtifact(runId, artifactId)

  • Requests artifact binary content using responseType: 'arraybuffer' to prevent corruption of binary data (images, etc.)
  • Wraps the API call with downloadWithRetry (powered by p-retry) — retries up to 3 times with exponential backoff, aborting immediately on non-retryable status codes (403, 404, 410, 422)
  • Returns ArrayBuffer for straightforward binary handling by consumers
  • Full JSDoc with @throws annotations for AuthenticationError, APIError, and UnexpectedError

New utility: downloadWithRetry (dwonloadWithRetry.ts)

  • Generic retry wrapper around an Axios call using p-retry
  • Accepts configurable abort status codes to short-circuit retries on permanent errors

Error handling improvements

  • Added 403 Forbidden and 410 Gone cases to handleRequestError for more precise error classification

Interface update

  • Added downloadArtifact to the PlatformSDK interface

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 8, 2026

Codecov Report

❌ Patch coverage is 62.50000% with 21 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/sdk/src/platform-sdk.ts 55.00% 18 Missing ⚠️
packages/sdk/src/utils/dwonloadWithRetry.ts 81.25% 3 Missing ⚠️

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #70      +/-   ##
==========================================
- Coverage   96.38%   94.98%   -1.40%     
==========================================
  Files          13       14       +1     
  Lines        1301     1357      +56     
  Branches      191      197       +6     
==========================================
+ Hits         1254     1289      +35     
- Misses         47       68      +21     
Flag Coverage Δ
unittests 94.98% <62.50%> (-1.40%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
packages/sdk/src/utils/dwonloadWithRetry.ts 81.25% <81.25%> (ø)
packages/sdk/src/platform-sdk.ts 87.65% <55.00%> (-6.44%) ⬇️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds initial support in the SDK for retrieving a downloadable artifact reference (via a new downloadArtifact method) and updates the test/mocking utilities to support the new endpoint and updated response shapes.

Changes:

  • Added downloadArtifact(runId, artifactId) to PlatformSDK / PlatformSDKHttp.
  • Extended MSW HTTP mocks to handle GET /v1/runs/:runId/artifacts/:artifactId/file across scenarios.
  • Updated tests and item factories/specs to include the newly-required input_artifacts field.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
packages/sdk/src/test-utils/http-mocks.ts Adds mock handlers for the artifact file URL endpoint; updates item result factory shape (input_artifacts).
packages/sdk/src/platform-sdk.ts Introduces the new SDK method and interface entry for artifact download/URL retrieval.
packages/sdk/src/platform-sdk.test.ts Adds tests for the new artifact method (success, not found, no token).
packages/sdk/src/entities/run-item/process-run-item.spec.ts Updates test fixture to include input_artifacts to match the updated API type shape.

Comment on lines 121 to 125
applicationId: string,
version: string
): Promise<VersionReadResponse>;
downloadArtifact(runId: string, artifactId: string): Promise<unknown>;
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

downloadArtifact is typed as Promise<unknown>, which leaks no usable type information to SDK consumers and makes it unclear what the method returns. Since the endpoint handler returns an object with a url, this should return a concrete type (ideally the generated response type, or at least { url: string }).

Copilot uses AI. Check for mistakes.
Comment on lines +620 to +627
async downloadArtifact(runId: string, artifactId: string): Promise<unknown> {
const client = await this.#getClient();
try {
const response = await client.getArtifactUrlV1RunsRunIdArtifactsArtifactIdFileGet({
runId,
artifactId,
});
return response;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The method name downloadArtifact suggests it downloads the artifact bytes, but the implementation only calls the API that returns a presigned URL and then returns the full client response. Consider either (a) renaming this to something like getArtifactDownloadUrl/getArtifactUrl and returning response.data (or the url string), or (b) actually performing the download and returning the file content/stream. Also, returning the raw response is inconsistent with the other SDK methods here, which return response.data.

Suggested change
async downloadArtifact(runId: string, artifactId: string): Promise<unknown> {
const client = await this.#getClient();
try {
const response = await client.getArtifactUrlV1RunsRunIdArtifactsArtifactIdFileGet({
runId,
artifactId,
});
return response;
async downloadArtifact(runId: string, artifactId: string): Promise<Uint8Array> {
const client = await this.#getClient();
try {
const response = await client.getArtifactUrlV1RunsRunIdArtifactsArtifactIdFileGet({
runId,
artifactId,
});
const artifactUrl = response.data?.url;
if (typeof artifactUrl !== 'string' || artifactUrl.length === 0) {
throw new UnexpectedError('Artifact download URL is missing from the API response');
}
const downloadResponse = await fetch(artifactUrl);
if (!downloadResponse.ok) {
throw new UnexpectedError(
`Artifact download failed with status ${downloadResponse.status} ${downloadResponse.statusText}`
);
}
return new Uint8Array(await downloadResponse.arrayBuffer());

Copilot uses AI. Check for mistakes.
Comment on lines +619 to +624

async downloadArtifact(runId: string, artifactId: string): Promise<unknown> {
const client = await this.#getClient();
try {
const response = await client.getArtifactUrlV1RunsRunIdArtifactsArtifactIdFileGet({
runId,
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

This new public SDK method is missing the JSDoc block that the other public methods in PlatformSDKHttp have, which makes the public API less discoverable (params, return shape, and whether it returns a URL vs content). Please add documentation consistent with the surrounding methods.

Copilot uses AI. Check for mistakes.
setMockScenario('success');

const result = await sdk.downloadArtifact('test-run-id', 'test-artifact-id');
expect(result).toBeDefined();
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The success test only asserts that the result is defined, so it wouldn’t catch regressions like returning the wrong shape (e.g., Axios response vs { url }) or an empty URL. Since the mock returns a deterministic URL, assert on the returned value/shape (e.g., that the URL matches the expected presigned URL).

Suggested change
expect(result).toBeDefined();
expect(result).toEqual({
url: 'https://example.com/presigned-download-url',
});

Copilot uses AI. Check for mistakes.
@na66im na66im force-pushed the task/tssdk-53-download-artifacts-method branch from ee41883 to 817fc31 Compare April 9, 2026 13:59
Copilot AI review requested due to automatic review settings April 9, 2026 14:39
@na66im na66im force-pushed the task/tssdk-53-download-artifacts-method branch from 817fc31 to b33b780 Compare April 9, 2026 14:39
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

packages/sdk/package.json:53

  • The newly added p-retry dependency resolves to v8.0.0, which (per package-lock.json) requires Node >=22. This repo pins Node 20.18.0 in .node-version and declares engines >=18, so npm install will fail on the supported Node versions. Pin p-retry to a major that supports Node 18/20, or bump the repo's Node/engines consistently if Node 22 is intended.
  "dependencies": {
    "axios": "^1.12.2",
    "p-retry": "^8.0.0",
    "zod": "^4.0.17"
  },
  "engines": {
    "node": ">=18.0.0"
  }

Comment on lines +1 to +20
import { AxiosResponse } from 'axios';
import pRetry, { AbortError } from 'p-retry';

const run = (callbackFn: () => Promise<AxiosResponse>, abortStatuses: number[]) => async () => {
const response = await callbackFn();

// Abort retrying if the resource doesn't exist
if (abortStatuses.includes(response.status)) {
throw new AbortError(response.statusText);
}

return response;
};

export const downloadWithRetry = async (
callbackFn: () => Promise<AxiosResponse>,
abortStatuses: number[] = [404]
) => {
return await pRetry(run(callbackFn, abortStatuses), {
retries: 3,
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

abortStatuses handling here is unlikely to work with axios: for non-2xx statuses axios typically rejects the promise, so callbackFn() won’t resolve to an AxiosResponse and this response.status check won’t run. As a result, p-retry will retry even for 4xx responses like 404/403/422. Consider inspecting the thrown error (e.g., AxiosError) and using p-retry’s shouldRetry to stop retries for selected HTTP status codes while preserving the original axios error for handleRequestError.

Suggested change
import { AxiosResponse } from 'axios';
import pRetry, { AbortError } from 'p-retry';
const run = (callbackFn: () => Promise<AxiosResponse>, abortStatuses: number[]) => async () => {
const response = await callbackFn();
// Abort retrying if the resource doesn't exist
if (abortStatuses.includes(response.status)) {
throw new AbortError(response.statusText);
}
return response;
};
export const downloadWithRetry = async (
callbackFn: () => Promise<AxiosResponse>,
abortStatuses: number[] = [404]
) => {
return await pRetry(run(callbackFn, abortStatuses), {
retries: 3,
import { AxiosError, AxiosResponse, isAxiosError } from 'axios';
import pRetry from 'p-retry';
const run = (callbackFn: () => Promise<AxiosResponse>) => async () => {
return await callbackFn();
};
export const downloadWithRetry = async (
callbackFn: () => Promise<AxiosResponse>,
abortStatuses: number[] = [404]
) => {
return await pRetry(run(callbackFn), {
retries: 3,
shouldRetry: ({ error }) => {
if (isAxiosError(error)) {
const status = (error as AxiosError).response?.status;
if (status !== undefined && abortStatuses.includes(status)) {
return false;
}
}
return true;
},

Copilot uses AI. Check for mistakes.
Comment on lines +658 to +659
* const stream = await sdk.downloadArtifact('run-123', 'artifact-456');
* // Process the readable stream
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The JSDoc example refers to the return value as stream and mentions processing a readable stream, but downloadArtifact returns an ArrayBuffer. Update the example/comment to reflect the actual return type (e.g., const buffer = ... and how to consume it).

Suggested change
* const stream = await sdk.downloadArtifact('run-123', 'artifact-456');
* // Process the readable stream
* const buffer = await sdk.downloadArtifact('run-123', 'artifact-456');
* const bytes = new Uint8Array(buffer);
* console.log(`Downloaded ${bytes.byteLength} bytes`);

Copilot uses AI. Check for mistakes.
Comment on lines +552 to +556
await expect(sdk.downloadArtifact('test-run-id', 'test-artifact-id')).rejects.toThrow(
'Resource not found: '
);
}, 30000);

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

This test needs a 30s timeout likely because downloadArtifact retries even for 404s. Once the retry helper is updated to avoid retrying on non-retriable 4xx statuses, this timeout should be removable (or reduced) to keep the test suite fast and to catch accidental retry regressions.

Suggested change
await expect(sdk.downloadArtifact('test-run-id', 'test-artifact-id')).rejects.toThrow(
'Resource not found: '
);
}, 30000);
vi.useFakeTimers();
try {
const downloadPromise = sdk.downloadArtifact('test-run-id', 'test-artifact-id');
await vi.runAllTimersAsync();
await expect(downloadPromise).rejects.toThrow('Resource not found: ');
} finally {
vi.useRealTimers();
}
});

Copilot uses AI. Check for mistakes.
@na66im na66im force-pushed the task/tssdk-53-download-artifacts-method branch from b33b780 to efe6a43 Compare April 9, 2026 14:46
artifactId,
},
{
responseType: 'arraybuffer',
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The downloadArtifact error handler incorrectly tries to parse an ArrayBuffer as JSON because responseType: 'arraybuffer' is set for the request, leading to incorrect error handling.
Severity: HIGH

Suggested Fix

In handleRequestError, before parsing error.response.data with Zod, check if it's an ArrayBuffer. If so, decode it to a string (e.g., using new TextDecoder().decode(data)), parse the string as JSON, and then pass the resulting object to the Zod schema. This ensures the schema receives the expected JSON input.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/sdk/src/platform-sdk.ts#L676

Potential issue: The `downloadArtifact` function makes a request with `responseType:
'arraybuffer'`. When an HTTP error occurs, the server sends a JSON error body, but
`axios` converts it to an `ArrayBuffer`. The error handler in `handleRequestError` then
attempts to parse this `ArrayBuffer` using Zod schemas that expect JSON. For 422 errors,
this will throw an unhandled `ZodError`, masking the original API error. For other
errors like 404, the error context will contain a useless `ArrayBuffer` instead of the
parsed error details from the server, hindering debugging.

@na66im na66im force-pushed the task/tssdk-53-download-artifacts-method branch from efe6a43 to be7ae01 Compare April 9, 2026 14:55
Copilot AI review requested due to automatic review settings April 9, 2026 14:55
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

packages/sdk/package.json:53

  • Adding p-retry as ^8.0.0 pulls in a dependency that (per the updated lockfile) requires Node >=22, while this package declares engines.node >=18 and the workspace uses Node 20. Please align the p-retry version with the Node versions you support (or update the package/workspace engines and build targets).
  "license": "MIT",
  "dependencies": {
    "axios": "^1.12.2",
    "p-retry": "^8.0.0",
    "zod": "^4.0.17"
  },
  "engines": {
    "node": ">=18.0.0"
  }

Comment on lines +1 to +3
import { AxiosResponse, isAxiosError } from 'axios';
import pRetry from 'p-retry';

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The file name dwonloadWithRetry.ts appears to be a typo ("dwonload"). Please rename it to downloadWithRetry.ts (and update any imports) to avoid confusion and to keep naming consistent with the exported downloadWithRetry symbol.

Copilot uses AI. Check for mistakes.
Comment on lines +539 to +555
it('should download artifact successfully', async () => {
mockTokenProvider.mockResolvedValue('mocked-token');
setMockScenario('success');

const result = await sdk.downloadArtifact('test-run-id', 'test-artifact-id');
expect(result).toBeDefined();
expect(result.byteLength).toBe(8);
});

it('should handle download artifact failure', async () => {
mockTokenProvider.mockResolvedValue('mocked-token');
setMockScenario('notFoundError');

await expect(sdk.downloadArtifact('test-run-id', 'test-artifact-id')).rejects.toThrow(
'Resource not found: '
);
}, 30000);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The new tests validate success and a 404 failure, but they don’t verify the newly introduced retry behavior (e.g., retrying on transient 500/network failures, and not retrying on abort statuses like 404/422). Please add at least one deterministic test that asserts retry attempts (e.g., fail once with 500 then succeed) and one that asserts immediate abort (e.g., ensure only one request is made for 404).

Copilot uses AI. Check for mistakes.
await expect(sdk.downloadArtifact('test-run-id', 'test-artifact-id')).rejects.toThrow(
'Resource not found: '
);
}, 30000);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

This test uses a 30s timeout even though a 404 should be handled immediately; the long timeout risks masking retry/delay issues and slows the suite when something regresses. After making retry behavior deterministic (e.g., configurable delays or asserted abort behavior), consider removing/reducing this timeout.

Suggested change
}, 30000);
});

Copilot uses AI. Check for mistakes.
[403, 404, 410, 422]
);
return response.data as ArrayBuffer;
} catch (error) {
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

downloadArtifact forces responseType: 'arraybuffer'. If the API returns a JSON error payload (notably 422 validation errors), Axios will still surface error.response.data in the configured binary format (ArrayBuffer/Buffer), which won’t match validationErrorSchema in handleRequestError and can result in a Zod parsing error instead of a structured APIError. Consider normalizing JSON error bodies (e.g., decode/JSON-parse when content-type is JSON) before calling handleRequestError, or make handleRequestError robust to binary response.data for 422s.

Suggested change
} catch (error) {
} catch (error) {
if (isAxiosError(error) && error.response) {
const contentTypeHeader = error.response.headers?.['content-type'];
const contentType = Array.isArray(contentTypeHeader)
? contentTypeHeader.join(',')
: contentTypeHeader;
if (contentType?.toLowerCase().includes('application/json')) {
const responseData = error.response.data;
try {
if (typeof responseData === 'string') {
error.response.data = JSON.parse(responseData);
} else if (responseData instanceof ArrayBuffer) {
error.response.data = JSON.parse(
new TextDecoder().decode(new Uint8Array(responseData))
);
} else if (ArrayBuffer.isView(responseData)) {
error.response.data = JSON.parse(
new TextDecoder().decode(
new Uint8Array(
responseData.buffer,
responseData.byteOffset,
responseData.byteLength
)
)
);
}
} catch {
// Leave the original response data untouched if it cannot be decoded as JSON.
}
}
}

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 9, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants