Skip to content

Commit 804905c

Browse files
committed
Use a more reliable Http client
Change-type: patch
1 parent 24351b2 commit 804905c

File tree

8 files changed

+145
-725
lines changed

8 files changed

+145
-725
lines changed

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ inputs:
3939
description: >
4040
The size of the chunks to split the file into when uploading.
4141
If the file is smaller than this size, it will be uploaded in one go.
42-
default: '104857600' # 100MB
42+
default: '134217728' # 128MB
4343
parallel-chunks:
4444
description: >
4545
The number of parallel chunks to upload at the same time.

build/index.cjs

Lines changed: 36 additions & 33 deletions
Large diffs are not rendered by default.

package-lock.json

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"description": "",
3636
"dependencies": {
3737
"@actions/core": "^1.11.1",
38+
"ky": "^1.8.1",
3839
"mime-types": "^3.0.1",
3940
"p-limit": "^6.2.0",
4041
"zod": "^3.25.7"

src/api.ts

Lines changed: 63 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,155 +1,62 @@
11
import { info } from '@actions/core';
22
import type { FileMetadata } from './uploadManager.js';
3-
import { sleep } from './uploadManager.js';
43
import type { webResourceHandler as webresources } from '@balena/pinejs';
54
import type { ProviderCommitPayload } from './uploader.js';
5+
import ky, { HTTPError, type KyInstance } from 'ky';
66

7-
const MAX_RETRIES = 5; // Maximum number of retries for transient errors
8-
const INITIAL_BACKOFF_MS = 1000; // Initial backoff sleep for retries
7+
const MAX_RETRIES = 5;
8+
export type OData<T> = {
9+
d?: T[];
10+
};
11+
12+
type ODataID = OData<{ id?: number }>;
13+
14+
type ReleaseAssetBeginUpload = {
15+
asset: {
16+
uuid: string;
17+
uploadParts: webresources.UploadPart[];
18+
};
19+
};
920

1021
export class BalenaAPI {
11-
private apiHost: string;
22+
public request: KyInstance;
1223
constructor(
1324
private readonly auth: string,
1425
readonly balenaHost: string,
1526
) {
16-
this.apiHost = `https://api.${balenaHost}`.replace(/\/+$/, '');
17-
}
18-
19-
private async fetchWithRetry(
20-
path: string,
21-
args: RequestInit,
22-
): Promise<Response> {
23-
let attempts = 0;
24-
let currentBackoff = INITIAL_BACKOFF_MS;
25-
26-
while (attempts < MAX_RETRIES) {
27-
attempts++;
28-
try {
29-
const url = `${this.apiHost}${path}`;
30-
const headers: HeadersInit = {
31-
Authorization: `Bearer ${this.auth}`,
32-
...args.headers,
33-
};
34-
35-
info(`Attempt ${attempts}: Calling ${args.method ?? 'GET'} ${url}`);
36-
const response = await fetch(url, { ...args, headers });
37-
38-
if (response.ok) {
39-
return response;
40-
}
41-
42-
if (response.status === 429) {
43-
const retryAfterHeader = response.headers.get('Retry-After');
44-
let retryAfterSeconds = currentBackoff / 1000;
45-
46-
if (retryAfterHeader) {
47-
const parsedRetryAfter = parseInt(retryAfterHeader, 10);
48-
if (!isNaN(parsedRetryAfter)) {
49-
retryAfterSeconds = parsedRetryAfter;
50-
} else {
51-
const retryDate = Date.parse(retryAfterHeader);
52-
if (!isNaN(retryDate)) {
53-
retryAfterSeconds = Math.max(
54-
0,
55-
(retryDate - Date.now()) / 1000,
56-
);
57-
}
58-
}
59-
info(
60-
`Received 429. Retrying after ${retryAfterSeconds} seconds (from Retry-After header).`,
61-
);
62-
} else {
63-
info(
64-
`Received 429. Retrying after ${retryAfterSeconds} seconds (using exponential backoff).`,
65-
);
66-
}
67-
68-
if (attempts >= MAX_RETRIES) {
69-
info(`Max retries reached for 429 on ${url}.`);
70-
throw new Error(
71-
`Too many requests to ${url} after ${attempts} attempts. Last status: ${response.status}`,
72-
);
73-
}
74-
await sleep(retryAfterSeconds * 1000);
75-
currentBackoff = Math.min(currentBackoff * 2, 30000);
76-
continue;
77-
}
78-
79-
if (response.status >= 500 && response.status <= 599) {
80-
info(
81-
`Received server error ${response.status}. Retrying in ${currentBackoff / 1000}s... (Attempt ${attempts}/${MAX_RETRIES})`,
82-
);
83-
if (attempts >= MAX_RETRIES) {
84-
throw new Error(
85-
`Server error ${response.status} for ${args.method ?? 'GET'} ${url} after ${attempts} attempts. Response: ${await response.text()}`,
86-
);
87-
}
88-
await sleep(currentBackoff + Math.random() * 1000);
89-
currentBackoff *= 2;
90-
continue;
91-
}
92-
93-
return response;
94-
} catch (error: any) {
95-
// Handle network errors (e.g., timeouts, DNS resolution failures)
96-
info(
97-
`Network error or fetch exception during attempt ${attempts} for ${args.method ?? 'GET'} ${path}: ${error.message}`,
98-
);
99-
if (attempts >= MAX_RETRIES) {
100-
throw new Error(
101-
`Failed to fetch ${args.method ?? 'GET'} ${path} after ${attempts} attempts due to network error: ${error.message}`,
102-
);
103-
}
104-
await sleep(currentBackoff + Math.random() * 1000);
105-
currentBackoff *= 2;
106-
}
107-
}
108-
109-
throw new Error(
110-
`Failed to complete request to ${path} after ${MAX_RETRIES} attempts.`,
111-
);
112-
}
113-
114-
public async request(path: string, args: RequestInit = {}) {
115-
return await this.fetchWithRetry(path, {
116-
...args,
27+
this.request = ky.create({
28+
prefixUrl: `https://api.${balenaHost}`,
11729
headers: {
118-
'Content-Type': 'application/json',
119-
...args.headers,
30+
Authorization: `Bearer ${this.auth}`,
31+
},
32+
timeout: 60_000,
33+
retry: {
34+
limit: MAX_RETRIES,
35+
methods: ['get', 'post', 'put', 'delete', 'patch'],
36+
statusCodes: [429, 500, 502, 503, 504],
37+
afterStatusCodes: [429],
38+
delay: (attemptCount) => 0.5 * 2 ** (attemptCount - 1) * 1000,
12039
},
12140
});
12241
}
12342

124-
public async baseRequest(path: string, args: RequestInit = {}) {
125-
return await this.fetchWithRetry(path, args);
126-
}
127-
12843
public async whoami() {
129-
const res = await this.request('/actor/v1/whoami');
130-
if (res.ok) {
131-
return await res.json();
132-
}
133-
throw new Error('Not logged in');
44+
const res = await this.request.get('actor/v1/whoami');
45+
return await res.json();
13446
}
13547

136-
public async canAccessRlease(releaseId: number) {
137-
const res = await this.request(`/resin/release(${releaseId})/canAccess`, {
138-
method: 'POST',
139-
body: JSON.stringify({ action: 'update' }),
48+
public async canAccessRelease(releaseId: number) {
49+
await this.request.post(`resin/release(${releaseId})/canAccess`, {
50+
json: { action: 'update' },
14051
});
141-
142-
if (!res.ok || (await res.json())?.d?.[0]?.id == null) {
143-
throw new Error('You do not have necessary access to this release');
144-
}
14552
}
14653

147-
public async getReleaseAssetId(
148-
releaseId: number,
149-
assetKey: string,
150-
): Promise<number | undefined> {
151-
const res = await this.request(
152-
`/resin/release_asset(release=${releaseId},asset_key='${assetKey}')?$select=id`,
54+
public async getReleaseAssetId(releaseId: number, assetKey: string) {
55+
const res = await this.request.get<ODataID>(
56+
`resin/release_asset(release=${releaseId},asset_key='${assetKey}')`,
57+
{
58+
searchParams: { $select: 'id' },
59+
},
15360
);
15461

15562
const body = await res.json();
@@ -161,48 +68,44 @@ export class BalenaAPI {
16168
assetKey: string,
16269
overwrite: boolean,
16370
): Promise<number> {
164-
const create = await this.request('/resin/release_asset', {
165-
method: 'POST',
166-
body: JSON.stringify({
167-
asset_key: assetKey,
168-
release: releaseId,
169-
}),
170-
});
71+
try {
72+
const create = await this.request.post<{ id: number }>(
73+
'resin/release_asset',
74+
{
75+
json: {
76+
asset_key: assetKey,
77+
release: releaseId,
78+
},
79+
},
80+
);
17181

172-
if (!create.ok) {
173-
if (overwrite && create.status === 409) {
82+
return (await create.json()).id;
83+
} catch (e) {
84+
if (e instanceof HTTPError && overwrite && e.response.status === 409) {
17485
info(`Asset ${assetKey} already exists. Overwriting...`);
17586
return (await this.getReleaseAssetId(releaseId, assetKey))!;
17687
} else {
177-
throw new Error(await create.text());
88+
throw new Error('Conflict creating release asset', e.message);
17889
}
17990
}
180-
181-
return (await create.json()).id;
18291
}
18392

18493
public async beginMultipartUpload(
18594
releaseAssetId: number,
18695
metadata: FileMetadata,
18796
chunkSize: number,
188-
): Promise<{
189-
asset: {
190-
uuid: string;
191-
uploadParts: webresources.UploadPart[];
192-
};
193-
}> {
194-
const res = await this.request(
195-
`/resin/release_asset(${releaseAssetId})/beginUpload`,
97+
) {
98+
const res = await this.request.post<ReleaseAssetBeginUpload>(
99+
`resin/release_asset(${releaseAssetId})/beginUpload`,
196100
{
197-
method: 'POST',
198-
body: JSON.stringify({
101+
json: {
199102
asset: {
200103
filename: metadata.filename,
201104
content_type: metadata.contentType,
202105
size: metadata.size,
203106
chunk_size: chunkSize,
204107
},
205-
}),
108+
},
206109
},
207110
);
208111

@@ -213,24 +116,22 @@ export class BalenaAPI {
213116
releaseAssetId: number,
214117
uuid: string,
215118
providerCommitData: ProviderCommitPayload,
216-
): Promise<{ href: string }> {
217-
const res = await this.request(
218-
`/resin/release_asset(${releaseAssetId})/commitUpload`,
119+
) {
120+
const res = await this.request.post<{ href: string }>(
121+
`resin/release_asset(${releaseAssetId})/commitUpload`,
219122
{
220-
method: 'POST',
221-
body: JSON.stringify({ uuid, providerCommitData }),
123+
json: { uuid, providerCommitData },
222124
},
223125
);
224126

225127
return await res.json();
226128
}
227129

228130
public async cancelMultiPartUpload(releaseAssetId: number, uuid: string) {
229-
return await this.request(
230-
`/resin/release_asset(${releaseAssetId})/cancelUpload`,
131+
return await this.request.post(
132+
`resin/release_asset(${releaseAssetId})/cancelUpload`,
231133
{
232-
method: 'POST',
233-
body: JSON.stringify({ uuid }),
134+
json: { uuid },
234135
},
235136
);
236137
}

0 commit comments

Comments
 (0)