Skip to content

Commit be7ae01

Browse files
committed
npmtask: add download artifacts method
1 parent 6687e05 commit be7ae01

9 files changed

Lines changed: 222 additions & 0 deletions

File tree

ATTRIBUTIONS.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22320,6 +22320,25 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
2232022320

2232122321
---
2232222322

22323+
## is-network-error
22324+
22325+
**Version:** 1.3.1
22326+
**License:** MIT
22327+
22328+
```
22329+
MIT License
22330+
22331+
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
22332+
22333+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
22334+
22335+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
22336+
22337+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22338+
```
22339+
22340+
---
22341+
2232322342
## is-node-process
2232422343

2232522344
**Version:** 1.2.0
@@ -27227,6 +27246,25 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
2722727246

2722827247
---
2722927248

27249+
## p-retry
27250+
27251+
**Version:** 8.0.0
27252+
**License:** MIT
27253+
27254+
```
27255+
MIT License
27256+
27257+
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
27258+
27259+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
27260+
27261+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
27262+
27263+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
27264+
```
27265+
27266+
---
27267+
2723027268
## p-try
2723127269

2723227270
**Version:** 2.2.0

package-lock.json

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"license": "MIT",
4646
"dependencies": {
4747
"axios": "^1.12.2",
48+
"p-retry": "^8.0.0",
4849
"zod": "^4.0.17"
4950
},
5051
"engines": {

packages/sdk/src/entities/run-item/process-run-item.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ function buildItem(
1414
termination_reason: overrides.termination_reason ?? undefined,
1515
error_code: null,
1616
error_message: null,
17+
input_artifacts: [],
1718
output_artifacts: [],
1819
} as ItemResultReadResponse;
1920
}

packages/sdk/src/platform-sdk.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,4 +535,30 @@ describe('PlatformSDK', () => {
535535
await expect(sdk.listRunResults('test-run-id')).rejects.toThrow(AuthenticationError);
536536
await expect(sdk.listRunResults('test-run-id')).rejects.toThrow(errorMessage);
537537
});
538+
539+
it('should download artifact successfully', async () => {
540+
mockTokenProvider.mockResolvedValue('mocked-token');
541+
setMockScenario('success');
542+
543+
const result = await sdk.downloadArtifact('test-run-id', 'test-artifact-id');
544+
expect(result).toBeDefined();
545+
expect(result.byteLength).toBe(8);
546+
});
547+
548+
it('should handle download artifact failure', async () => {
549+
mockTokenProvider.mockResolvedValue('mocked-token');
550+
setMockScenario('notFoundError');
551+
552+
await expect(sdk.downloadArtifact('test-run-id', 'test-artifact-id')).rejects.toThrow(
553+
'Resource not found: '
554+
);
555+
}, 30000);
556+
557+
it('should handle no token for download artifact', async () => {
558+
mockTokenProvider.mockResolvedValue(null);
559+
560+
await expect(sdk.downloadArtifact('test-run-id', 'test-artifact-id')).rejects.toThrow(
561+
AuthenticationError
562+
);
563+
});
538564
});

packages/sdk/src/platform-sdk.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { processApplicationRun } from './entities/application-run/process-applic
1616
import { ApplicationRun } from './entities/application-run/types.js';
1717
import { processRunItem } from './entities/run-item/process-run-item.js';
1818
import { ApplicationRunItem } from './entities/run-item/types.js';
19+
import { downloadWithRetry } from './utils/dwonloadWithRetry.js';
1920

2021
const validationErrorSchema = z.object({
2122
detail: z.array(
@@ -50,6 +51,24 @@ function handleRequestError(error: unknown): never {
5051
statusCode: 404,
5152
});
5253
}
54+
case 403: {
55+
throw new APIError(`Access forbidden: ${error.message}`, {
56+
context: {
57+
responseBody: errorResponseSchema.parse(error.response?.data),
58+
},
59+
originalError: error,
60+
statusCode: 403,
61+
});
62+
}
63+
case 410: {
64+
throw new APIError(`Resource gone: ${error.message}`, {
65+
context: {
66+
responseBody: errorResponseSchema.parse(error.response?.data),
67+
},
68+
originalError: error,
69+
statusCode: 410,
70+
});
71+
}
5372
default: {
5473
throw new APIError(`API request failed: ${error.message}`, {
5574
context: {
@@ -121,6 +140,7 @@ export interface PlatformSDK {
121140
applicationId: string,
122141
version: string
123142
): Promise<VersionReadResponse>;
143+
downloadArtifact(runId: string, artifactId: string): Promise<ArrayBuffer>;
124144
}
125145
/**
126146
* Main SDK class for interacting with the Aignostics Platform
@@ -615,4 +635,58 @@ export class PlatformSDKHttp implements PlatformSDK {
615635
getConfig(): PlatformSDKConfig {
616636
return { ...this.#config };
617637
}
638+
639+
/**
640+
* Download an artifact file from a completed application run
641+
*
642+
* This method retrieves the binary content of a specific artifact produced
643+
* during an application run. Artifacts can include generated reports, processed
644+
* images, or other output files from the AI model execution.
645+
*
646+
* The download is performed with automatic retries for transient failures.
647+
* Non-retryable HTTP status codes (403, 404, 410, 422) will abort immediately.
648+
*
649+
* @param runId - The unique identifier of the application run
650+
* @param artifactId - The unique identifier of the artifact to download
651+
* @returns A promise that resolves to an ArrayBuffer containing the artifact's binary content
652+
* @throws {AuthenticationError} If no valid authentication token is available
653+
* @throws {APIError} If the API request fails (e.g., 403, 404, 410, 422, or other HTTP errors)
654+
* @throws {UnexpectedError} If a non-HTTP error occurs
655+
*
656+
* @example
657+
* ```typescript
658+
* const sdk = new PlatformSDKHttp({ tokenProvider: () => 'your-token' });
659+
*
660+
* try {
661+
* const buffer = await sdk.downloadArtifact('run-123', 'artifact-456');
662+
* console.log(`Downloaded ${buffer.byteLength} bytes`);
663+
*
664+
* // Write to file (Node.js)
665+
* fs.writeFileSync('output.bin', Buffer.from(buffer));
666+
* } catch (error) {
667+
* console.error('Failed to download artifact:', error.message);
668+
* }
669+
* ```
670+
*/
671+
async downloadArtifact(runId: string, artifactId: string): Promise<ArrayBuffer> {
672+
const client = await this.#getClient();
673+
try {
674+
const response = await downloadWithRetry(
675+
() =>
676+
client.getArtifactUrlV1RunsRunIdArtifactsArtifactIdFileGet(
677+
{
678+
runId,
679+
artifactId,
680+
},
681+
{
682+
responseType: 'arraybuffer',
683+
}
684+
),
685+
[403, 404, 410, 422]
686+
);
687+
return response.data as ArrayBuffer;
688+
} catch (error) {
689+
handleRequestError(error);
690+
}
691+
}
618692
}

packages/sdk/src/test-utils/http-mocks.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ const itemResultFactory = Factory.define<ItemResultReadResponse>(() => ({
118118
terminated_at: faker.date.recent().toISOString(),
119119
output_artifacts: outputArtifactFactory.buildList(faker.number.int({ min: 0, max: 3 })),
120120
error_code: null,
121+
input_artifacts: [],
121122
}));
122123

123124
const runFactory = Factory.define<RunReadResponse>(() => ({
@@ -246,6 +247,12 @@ export const handlers = {
246247
http.get('*/v1/runs/:runId/items', () => {
247248
return HttpResponse.json(mockResponses.runResultsSuccess, { status: 200 });
248249
}),
250+
http.get('*/v1/runs/:runId/artifacts/:artifactId/file', () => {
251+
return new HttpResponse(new ArrayBuffer(8), {
252+
status: 200,
253+
headers: { 'Content-Type': 'application/octet-stream' },
254+
});
255+
}),
249256
],
250257

251258
// Empty responses
@@ -265,6 +272,12 @@ export const handlers = {
265272
http.get('*/v1/runs/:runId/items', () => {
266273
return HttpResponse.json([], { status: 200 });
267274
}),
275+
http.get('*/v1/runs/:runId/artifacts/:artifactId/file', () => {
276+
return new HttpResponse(new ArrayBuffer(0), {
277+
status: 200,
278+
headers: { 'Content-Type': 'application/octet-stream' },
279+
});
280+
}),
268281
],
269282

270283
// Not found responses
@@ -293,6 +306,9 @@ export const handlers = {
293306
http.get('*/v1/runs/:runId/items', () => {
294307
return HttpResponse.json(mockResponses.error, { status: 404 });
295308
}),
309+
http.get('*/v1/runs/:runId/artifacts/:artifactId/file', () => {
310+
return HttpResponse.json(mockResponses.error, { status: 404 });
311+
}),
296312
],
297313

298314
validationError: [
@@ -320,6 +336,9 @@ export const handlers = {
320336
http.get('*/v1/runs/:runId/items', () => {
321337
return HttpResponse.json(mockResponses.validationError, { status: 422 });
322338
}),
339+
http.get('*/v1/runs/:runId/artifacts/:artifactId/file', () => {
340+
return HttpResponse.json(mockResponses.validationError, { status: 422 });
341+
}),
323342
],
324343

325344
internalServerError: [
@@ -347,6 +366,9 @@ export const handlers = {
347366
http.get('*/v1/runs/:runId/items', () => {
348367
return HttpResponse.json(mockResponses.error, { status: 500 });
349368
}),
369+
http.get('*/v1/runs/:runId/artifacts/:artifactId/file', () => {
370+
return HttpResponse.json(mockResponses.error, { status: 500 });
371+
}),
350372
],
351373

352374
// Network error (connection failure)
@@ -375,6 +397,9 @@ export const handlers = {
375397
http.get('*/v1/runs/:runId/items', () => {
376398
return HttpResponse.error();
377399
}),
400+
http.get('*/v1/runs/:runId/artifacts/:artifactId/file', () => {
401+
return HttpResponse.error();
402+
}),
378403
],
379404
};
380405

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { AxiosResponse, isAxiosError } from 'axios';
2+
import pRetry from 'p-retry';
3+
4+
/**
5+
* Executes an HTTP request with automatic retries for transient failures.
6+
*
7+
* Retries are aborted immediately for responses with status codes listed in
8+
* `abortStatuses`, preserving the original AxiosError so that upstream error
9+
* handling (e.g. `handleRequestError`) can inspect it directly.
10+
*
11+
* @param callbackFn - A function that returns a promise resolving to an AxiosResponse
12+
* @param abortStatuses - HTTP status codes that should not be retried (default: `[404]`)
13+
* @returns The successful AxiosResponse
14+
*/
15+
export const downloadWithRetry = async (
16+
callbackFn: () => Promise<AxiosResponse>,
17+
abortStatuses: number[] = [404]
18+
) => {
19+
return await pRetry(callbackFn, {
20+
retries: 3,
21+
shouldRetry: ({ error }) => {
22+
if (isAxiosError(error) && abortStatuses.includes(error.response?.status ?? 0)) {
23+
return false;
24+
}
25+
return true;
26+
},
27+
});
28+
};

packages/sdk/tsup.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ export default defineConfig({
1111
treeshake: true,
1212
outDir: 'dist',
1313
external: ['axios'],
14+
noExternal: ['p-retry'],
1415
});

0 commit comments

Comments
 (0)