Skip to content

Commit 8a1fed4

Browse files
authored
task: add download artifacts method (#70)
* npmtask: add download artifacts method * increase test coverage * increase test coverage
1 parent 6687e05 commit 8a1fed4

10 files changed

Lines changed: 348 additions & 3 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: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { http, HttpResponse } from 'msw';
23
import { PlatformSDKHttp } from './platform-sdk.js';
3-
import { AuthenticationError } from './errors.js';
4-
import { setMockScenario } from './test-utils/http-mocks.js';
4+
import { APIError, AuthenticationError } from './errors.js';
5+
import { setMockScenario, server } from './test-utils/http-mocks.js';
56
import { ItemState, ItemTerminationReason } from './generated/api.js';
67

78
describe('PlatformSDK', () => {
@@ -525,7 +526,6 @@ describe('PlatformSDK', () => {
525526
items: [],
526527
})
527528
).rejects.toThrow(errorMessage);
528-
529529
await expect(sdk.getRun('test-run-id')).rejects.toThrow(AuthenticationError);
530530
await expect(sdk.getRun('test-run-id')).rejects.toThrow(errorMessage);
531531

@@ -535,4 +535,103 @@ 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+
});
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+
});
564+
565+
it('should retry on transient 500 error and succeed on second attempt', async () => {
566+
mockTokenProvider.mockResolvedValue('mocked-token');
567+
568+
let callCount = 0;
569+
server.use(
570+
http.get('*/v1/runs/:runId/artifacts/:artifactId/file', () => {
571+
callCount++;
572+
if (callCount === 1) {
573+
return HttpResponse.json({}, { status: 500 });
574+
}
575+
return new HttpResponse(new ArrayBuffer(8), {
576+
status: 200,
577+
headers: { 'Content-Type': 'application/octet-stream' },
578+
});
579+
})
580+
);
581+
582+
const result = await sdk.downloadArtifact('test-run-id', 'test-artifact-id');
583+
expect(result.byteLength).toBe(8);
584+
expect(callCount).toBe(2);
585+
}, 10_000);
586+
587+
it('should not retry on 404 and make exactly one request', async () => {
588+
mockTokenProvider.mockResolvedValue('mocked-token');
589+
590+
let callCount = 0;
591+
server.use(
592+
http.get('*/v1/runs/:runId/artifacts/:artifactId/file', () => {
593+
callCount++;
594+
return HttpResponse.json({}, { status: 404 });
595+
})
596+
);
597+
598+
await expect(sdk.downloadArtifact('test-run-id', 'test-artifact-id')).rejects.toThrow(
599+
'Resource not found:'
600+
);
601+
expect(callCount).toBe(1);
602+
});
603+
604+
it('should throw APIError with 422 status when validation error occurs', async () => {
605+
mockTokenProvider.mockResolvedValue('mocked-token');
606+
setMockScenario('validationError');
607+
608+
await expect(sdk.listApplications()).rejects.toThrow(APIError);
609+
await expect(sdk.listApplications()).rejects.toThrow('Validation error:');
610+
});
611+
612+
it('should throw APIError with 403 status when access is forbidden', async () => {
613+
mockTokenProvider.mockResolvedValue('mocked-token');
614+
615+
server.use(
616+
http.get('*/v1/applications', () => {
617+
return HttpResponse.json({ detail: 'Forbidden' }, { status: 403 });
618+
})
619+
);
620+
621+
await expect(sdk.listApplications()).rejects.toThrow(APIError);
622+
await expect(sdk.listApplications()).rejects.toThrow('Access forbidden:');
623+
});
624+
625+
it('should throw APIError with 410 status when resource is gone', async () => {
626+
mockTokenProvider.mockResolvedValue('mocked-token');
627+
628+
server.use(
629+
http.get('*/v1/runs/:runId', () => {
630+
return HttpResponse.json({ detail: 'Gone' }, { status: 410 });
631+
})
632+
);
633+
634+
await expect(sdk.getRun('test-run-id')).rejects.toThrow(APIError);
635+
await expect(sdk.getRun('test-run-id')).rejects.toThrow('Resource gone:');
636+
});
538637
});

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/downloadWithRetry.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
}

0 commit comments

Comments
 (0)