3838import { readFileSync , statSync } from "node:fs" ;
3939import { basename } from "node:path" ;
4040
41+ const MAX_RETRIES = 20 ;
42+ const ATTEMPT_TIMEOUT_MS = 600_000 ;
43+ const RETRY_BACKOFF_CAP_MS = 10_000 ;
44+
45+ function retryDelayMs ( failedAttempt ) {
46+ return Math . min ( 1000 * 2 ** ( failedAttempt - 1 ) , RETRY_BACKOFF_CAP_MS ) ;
47+ }
48+
4149/**
4250 * Resolve the FC release channel context; null when the channel is disabled.
4351 * Reuses the shared FC_TRIGGER_URL (the same function already serves
@@ -78,7 +86,10 @@ async function fetchOidcToken(audience) {
7886 const url = audience
7987 ? `${ requestUrl } ${ separator } audience=${ encodeURIComponent ( audience ) } `
8088 : requestUrl ;
81- const res = await fetch ( url , { headers : { Authorization : `bearer ${ requestToken } ` } } ) ;
89+ const res = await fetch ( url , {
90+ headers : { Authorization : `bearer ${ requestToken } ` } ,
91+ signal : AbortSignal . timeout ( ATTEMPT_TIMEOUT_MS ) ,
92+ } ) ;
8293 if ( ! res . ok ) {
8394 throw new Error ( `GitHub OIDC token request failed: HTTP ${ res . status } ` ) ;
8495 }
@@ -90,21 +101,22 @@ async function fetchOidcToken(audience) {
90101}
91102
92103/**
93- * Call an FC release action with the OIDC token. Retries transient network
94- * failures; throws on HTTP errors and on `success: false` responses
95- * (FC returns structured errors, e.g. OIDC verification failures).
104+ * Call an FC release action with a fresh OIDC token for every attempt. Retries
105+ * transient network failures; throws on HTTP errors and on `success: false`
106+ * responses (FC returns structured errors, e.g. OIDC verification failures).
96107 */
97- async function fcCall ( ctx , action , payload , token , attempts = 3 ) {
108+ async function fcCall ( ctx , action , payload , maxRetries = MAX_RETRIES ) {
98109 for ( let attempt = 1 ; ; attempt ++ ) {
99110 try {
111+ const token = await fetchOidcToken ( ctx . audience ) ;
100112 const res = await fetch ( `${ ctx . triggerUrl } /${ action } ` , {
101113 method : "POST" ,
102114 headers : {
103115 "Content-Type" : "application/json" ,
104116 Authorization : `Bearer ${ token } ` ,
105117 } ,
106118 body : JSON . stringify ( payload ) ,
107- signal : AbortSignal . timeout ( 120_000 ) ,
119+ signal : AbortSignal . timeout ( ATTEMPT_TIMEOUT_MS ) ,
108120 } ) ;
109121 const body = await res . json ( ) . catch ( ( ) => null ) ;
110122 if ( ! res . ok || ! body ?. success ) {
@@ -119,10 +131,10 @@ async function fcCall(ctx, action, payload, token, attempts = 3) {
119131 // raw network failures, which surface as TypeError "fetch failed".
120132 const isNetworkError =
121133 error instanceof TypeError || / f e t c h f a i l e d | t i m e o u t / i. test ( error . message ) ;
122- if ( attempt >= attempts || ! isNetworkError ) throw error ;
123- const delay = 1000 * 2 ** ( attempt - 1 ) ;
134+ if ( attempt > maxRetries || ! isNetworkError ) throw error ;
135+ const delay = retryDelayMs ( attempt ) ;
124136 process . stdout . write (
125- ` [fc] retry ${ attempt } /${ attempts - 1 } for ${ action } in ${ delay } ms (${ error . message } )\n` ,
137+ ` [fc] retry ${ attempt } /${ maxRetries } for ${ action } in ${ delay } ms (${ error . message } )\n` ,
126138 ) ;
127139 await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
128140 }
@@ -155,7 +167,7 @@ async function runWithConcurrency(tasks, limit) {
155167}
156168
157169/** PUT a local file to a presigned URL with exponential-backoff retries. */
158- async function putWithRetry ( { putUrl, contentType, body } , attempts = 3 ) {
170+ async function putWithRetry ( { putUrl, contentType, body } , maxRetries = MAX_RETRIES ) {
159171 for ( let attempt = 1 ; ; attempt ++ ) {
160172 try {
161173 // Only Content-Type was signed by FC; sending extra canonical headers
@@ -164,18 +176,18 @@ async function putWithRetry({ putUrl, contentType, body }, attempts = 3) {
164176 method : "PUT" ,
165177 headers : { "Content-Type" : contentType } ,
166178 body,
167- signal : AbortSignal . timeout ( 600_000 ) ,
179+ signal : AbortSignal . timeout ( ATTEMPT_TIMEOUT_MS ) ,
168180 } ) ;
169181 if ( ! res . ok ) {
170182 const text = await res . text ( ) . catch ( ( ) => "" ) ;
171183 throw new Error ( `HTTP ${ res . status } ${ text . slice ( 0 , 200 ) } ` ) ;
172184 }
173185 return ;
174186 } catch ( error ) {
175- if ( attempt >= attempts ) throw error ;
176- const delay = 1000 * 2 ** ( attempt - 1 ) ;
187+ if ( attempt > maxRetries ) throw error ;
188+ const delay = retryDelayMs ( attempt ) ;
177189 process . stdout . write (
178- ` [oss] retry ${ attempt } /${ attempts - 1 } in ${ delay } ms (${ error . message } )\n` ,
190+ ` [oss] retry ${ attempt } /${ maxRetries } in ${ delay } ms (${ error . message } )\n` ,
179191 ) ;
180192 await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
181193 }
@@ -194,20 +206,14 @@ async function putWithRetry({ putUrl, contentType, body }, attempts = 3) {
194206 * }} params
195207 */
196208async function uploadViaFc ( { ctx, prefix, jobs, label } ) {
197- const token = await fetchOidcToken ( ctx . audience ) ;
198- const prepare = await fcCall (
199- ctx ,
200- "release-prepare" ,
201- {
202- files : jobs . map ( ( job ) => ( {
203- prefix,
204- tag : job . tag ,
205- name : job . name ,
206- contentType : contentTypeFor ( job . name ) ,
207- } ) ) ,
208- } ,
209- token ,
210- ) ;
209+ const prepare = await fcCall ( ctx , "release-prepare" , {
210+ files : jobs . map ( ( job ) => ( {
211+ prefix,
212+ tag : job . tag ,
213+ name : job . name ,
214+ contentType : contentTypeFor ( job . name ) ,
215+ } ) ) ,
216+ } ) ;
211217 const uploads = prepare . uploads ?? [ ] ;
212218 if ( uploads . length !== jobs . length ) {
213219 throw new Error (
@@ -248,14 +254,9 @@ async function uploadViaFc({ ctx, prefix, jobs, label }) {
248254
249255 // HEAD byte-size reconciliation now happens FC-side (it holds the only OSS
250256 // credentials); the runner reports local sizes as ground truth.
251- await fcCall (
252- ctx ,
253- "release-finalize" ,
254- {
255- files : jobs . map ( ( job , index ) => ( { key : uploads [ index ] . key , size : statSync ( job . path ) . size } ) ) ,
256- } ,
257- token ,
258- ) ;
257+ await fcCall ( ctx , "release-finalize" , {
258+ files : jobs . map ( ( job , index ) => ( { key : uploads [ index ] . key , size : statSync ( job . path ) . size } ) ) ,
259+ } ) ;
259260 process . stdout . write (
260261 `${ label } reconcile ok: ${ jobs . length } /${ jobs . length } object(s) verified by FC\n` ,
261262 ) ;
@@ -330,13 +331,10 @@ export async function maintainReleaseManifest({ tag, channelJsonPath = null, dry
330331 }
331332
332333 const body = JSON . parse ( readFileSync ( channelJsonPath , "utf-8" ) ) ;
333- const token = await fetchOidcToken ( ctx . audience ) ;
334- const result = await fcCall (
335- ctx ,
336- "release-finalize" ,
337- { files : [ ] , manifest : { tag, body } } ,
338- token ,
339- ) ;
334+ const result = await fcCall ( ctx , "release-finalize" , {
335+ files : [ ] ,
336+ manifest : { tag, body } ,
337+ } ) ;
340338 if ( result . manifestUpdated ) {
341339 process . stdout . write ( `manifest.json → latest=${ result . latest ?? tag } \n` ) ;
342340 process . stdout . write ( `latest.json → ${ result . latest ?? tag } \n` ) ;
0 commit comments