Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/classbot/server/store/supabase-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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(),
"학급 조회 실패",
Expand Down
55 changes: 55 additions & 0 deletions apps/classbot/server/store/supabase-store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading