From 4c0c841b4e5334c448672ea3390c1380d0a97656 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Wed, 29 Jul 2026 13:36:10 +0200 Subject: [PATCH] fix(storage): make GCS auth token handling resilient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avatars/files intermittently froze for ~80s ("Waiting for server response") then 500'd. Root cause: getAccessToken() stalls when a token refresh coincides with a network/DNS blip to Google, and the code amplified it — no single-flight (an avatar-heavy page fans out dozens of concurrent token fetches), no stale-token fallback, and a 20s x 4 = 80s retry budget on every request. - Single-flight refresh: concurrent callers share one in-flight refresh. - Background refresh ahead of expiry so user requests don't block on the auth round-trip; the cached token keeps serving meanwhile. - Stale-token fallback: if a refresh fails, serve the still-held token instead of failing the request. - Shorter bounded auth timeout/retries (10s x 2) so worst case is seconds. Tunable via STORAGE_GCS_AUTH_TIMEOUT_MS and STORAGE_GCS_AUTH_MAX_RETRIES. --- deno/storage/src/core/store/gcs.ts | 47 ++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/deno/storage/src/core/store/gcs.ts b/deno/storage/src/core/store/gcs.ts index 8ff8adb9..e17ea964 100644 --- a/deno/storage/src/core/store/gcs.ts +++ b/deno/storage/src/core/store/gcs.ts @@ -33,6 +33,10 @@ const GCS_BUFFER_LIMIT_BYTES = getEnvInt( "STORAGE_GCS_BUFFER_LIMIT_BYTES", 64 * 1024 * 1024, ); +const GCS_AUTH_TIMEOUT_MS = getEnvInt("STORAGE_GCS_AUTH_TIMEOUT_MS", 10_000); +const GCS_AUTH_MAX_RETRIES = getEnvInt("STORAGE_GCS_AUTH_MAX_RETRIES", 2); +const TOKEN_REFRESH_AFTER_MS = 45 * 60 * 1000; +const TOKEN_MAX_AGE_MS = 58 * 60 * 1000; const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]); @@ -118,7 +122,9 @@ class Gcs { accessToken: string | null = null; - accessTokenExpires = 0; + private tokenFetchedAt = 0; + + private refreshPromise: Promise | null = null; auth = new GoogleAuth({ scopes: "https://www.googleapis.com/auth/cloud-platform", @@ -136,22 +142,45 @@ class Gcs { this.bucketName = config.bucket; } - async getAccessToken() { - if (!this.accessToken || Date.now() > this.accessTokenExpires) { - this.accessToken = await this.fetchAccessToken(); - this.accessTokenExpires = Date.now() + 50 * 60 * 1000; + async getAccessToken(): Promise { + const age = Date.now() - this.tokenFetchedAt; + if (this.accessToken && age < TOKEN_MAX_AGE_MS) { + if (age >= TOKEN_REFRESH_AFTER_MS) { + this.refresh().catch(() => {}); + } + return this.accessToken; + } + try { + return await this.refresh(); + } catch (err) { + if (this.accessToken) return this.accessToken; + throw err; + } + } + + private refresh(): Promise { + if (!this.refreshPromise) { + this.refreshPromise = this.fetchAccessToken() + .then((token) => { + this.accessToken = token; + this.tokenFetchedAt = Date.now(); + return token; + }) + .finally(() => { + this.refreshPromise = null; + }); } - return this.accessToken; + return this.refreshPromise; } private async fetchAccessToken(): Promise { let lastError: unknown; - for (let attempt = 0; attempt <= GCS_MAX_RETRIES; attempt++) { + for (let attempt = 0; attempt <= GCS_AUTH_MAX_RETRIES; attempt++) { if (attempt > 0) await backoff(attempt - 1); try { const token = await withTimeout( this.auth.getAccessToken(), - GCS_TIMEOUT_MS, + GCS_AUTH_TIMEOUT_MS, "auth token", ); return token ?? null; @@ -161,7 +190,7 @@ class Gcs { console.warn( `[storage][gcs] auth token fetch failed (${reason}), attempt ${ attempt + 1 - }/${GCS_MAX_RETRIES + 1}`, + }/${GCS_AUTH_MAX_RETRIES + 1}`, ); } }