From a1c00e9a7ef4df6a92b20eb028f11561a4e268a6 Mon Sep 17 00:00:00 2001 From: fakeminjun7321 Date: Thu, 16 Jul 2026 15:28:51 +0900 Subject: [PATCH] fix: retry transient Supabase startup failures --- apps/classbot/server/store/supabase-store.js | 18 ++++++ .../server/store/supabase-store.test.js | 55 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/apps/classbot/server/store/supabase-store.js b/apps/classbot/server/store/supabase-store.js index dc603d4..a3c9cf6 100644 --- a/apps/classbot/server/store/supabase-store.js +++ b/apps/classbot/server/store/supabase-store.js @@ -28,6 +28,10 @@ function fetchWithTimeout(url, options = {}) { return fetch(url, { ...options, signal }); } +function isTransientSupabaseConnectionError(error) { + return /fetch failed|network|econnreset|enotfound|eai_again|und_err/i.test(String(error?.message || error)); +} + const FILE_BUCKET = "classbot-files"; const FILE_MIME_TYPES = ["application/pdf", "image/jpeg", "image/png", "image/webp", "image/gif"]; const FILE_EXTENSIONS = new Map([ @@ -106,9 +110,23 @@ export class SupabaseStore { global: { fetch: fetchWithTimeout }, }); this.classroom = null; + this.initializationRetryDelays = config.supabaseInitializationRetryDelaysMs || [1_000, 2_500, 5_000]; } async initialize() { + for (let attempt = 0; ; attempt += 1) { + try { + await this.initializeOnce(); + return; + } catch (error) { + const delay = this.initializationRetryDelays[attempt]; + if (delay === undefined || !isTransientSupabaseConnectionError(error)) throw error; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + async initializeOnce() { const existing = unwrap( await this.client.from("classbot_classes").select("*").eq("code", this.config.classCode).maybeSingle(), "학급 조회 실패", diff --git a/apps/classbot/server/store/supabase-store.test.js b/apps/classbot/server/store/supabase-store.test.js index 6c29e1d..14215fb 100644 --- a/apps/classbot/server/store/supabase-store.test.js +++ b/apps/classbot/server/store/supabase-store.test.js @@ -151,3 +151,58 @@ test("카카오 개인 일정 pending은 학급·구성원·만료 시각과 함 options: { onConflict: "member_id" }, }]); }); + +test("Supabase 초기 연결의 일시적 fetch 실패는 짧게 재시도한다", async () => { + let attempts = 0; + const store = Object.create(SupabaseStore.prototype); + store.config = { + classCode: "2-4", + className: "2학년 4반", + timezone: "Asia/Seoul", + }; + store.classroom = null; + store.initializationRetryDelays = [0, 0]; + store.client = { + from(table) { + assert.equal(table, "classbot_classes"); + const query = { + select() { return query; }, + eq() { return query; }, + async maybeSingle() { + attempts += 1; + if (attempts < 3) return { data: null, error: { message: "TypeError: fetch failed" } }; + return { data: { id: "class-private", code: "2-4" }, error: null }; + }, + }; + return query; + }, + }; + + await store.initialize(); + assert.equal(attempts, 3); + assert.deepEqual(store.classroom, { id: "class-private", code: "2-4" }); +}); + +test("Supabase 스키마 오류는 연결 재시도 대상으로 숨기지 않는다", async () => { + let attempts = 0; + const store = Object.create(SupabaseStore.prototype); + store.config = { classCode: "2-4" }; + store.classroom = null; + store.initializationRetryDelays = [0, 0]; + store.client = { + from() { + const query = { + select() { return query; }, + eq() { return query; }, + async maybeSingle() { + attempts += 1; + return { data: null, error: { message: "relation classbot_classes does not exist" } }; + }, + }; + return query; + }, + }; + + await assert.rejects(store.initialize(), /relation classbot_classes does not exist/); + assert.equal(attempts, 1); +});