11import { info } from '@actions/core' ;
22import type { FileMetadata } from './uploadManager.js' ;
3- import { sleep } from './uploadManager.js' ;
43import type { webResourceHandler as webresources } from '@balena/pinejs' ;
54import 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
1021export 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