feat(phase-5-2): 에러 복구 전략 구현

Phase 5-2 완료: 재시도 로직, 서킷 브레이커, 관리자 알림

생성된 파일:
- src/utils/retry.ts (지수 백오프 재시도)
- src/utils/circuit-breaker.ts (서킷 브레이커 패턴)
- src/services/notification.ts (관리자 알림)
- src/services/__test__/notification.test.ts (테스트 가이드)

수정된 파일:
- src/openai-service.ts (Circuit Breaker + Retry 적용)
- src/tools/search-tool.ts (4개 API 재시도)
- src/tools/domain-tool.ts (11개 API 재시도)
- CLAUDE.md (알림 시스템 문서 추가)

주요 기능:
- 지수 백오프: 1초 → 2초 → 4초 (Jitter ±20%)
- Circuit Breaker: 3회 실패 시 30초 차단 (OpenAI)
- 재시도: 총 15개 외부 API 호출에 적용
- 알림: 3가지 유형 (Circuit Breaker, Retry, API Error)
- Rate Limiting: 같은 알림 1시간 1회

검증:
-  TypeScript 컴파일 성공
-  Wrangler 로컬 빌드 성공
-  프로덕션 배포 완료 (Version: c4a1a8e9)
This commit is contained in:
kappa
2026-01-19 16:30:54 +09:00
parent 9b633ea38b
commit 58d8bbffc6
8 changed files with 1091 additions and 159 deletions

View File

@@ -1,4 +1,5 @@
import type { Env } from '../types';
import { retryWithBackoff, RetryError } from '../utils/retry';
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
@@ -165,9 +166,12 @@ async function callNamecheapApi(
switch (funcName) {
case 'list_domains': {
const result = await fetch(`${apiUrl}/domains?page=${funcArgs.page || 1}&page_size=${funcArgs.page_size || 100}`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()) as any[];
const result = await retryWithBackoff(
() => fetch(`${apiUrl}/domains?page=${funcArgs.page || 1}&page_size=${funcArgs.page_size || 100}`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()),
{ maxRetries: 3 }
) as any[];
// MM/DD/YYYY → YYYY-MM-DD 변환 (Namecheap은 미국 형식 사용)
const convertDate = (date: string) => {
const [month, day, year] = date.split('/');
@@ -185,9 +189,12 @@ async function callNamecheapApi(
}
case 'get_domain_info': {
// 목록 API에서 더 많은 정보 조회 (단일 API는 정보 부족)
const domains = await fetch(`${apiUrl}/domains?page=1&page_size=100`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()) as any[];
const domains = await retryWithBackoff(
() => fetch(`${apiUrl}/domains?page=1&page_size=100`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()),
{ maxRetries: 3 }
) as any[];
const domainInfo = domains.find((d: any) => d.name === funcArgs.domain);
if (!domainInfo) {
return { error: `도메인을 찾을 수 없습니다: ${funcArgs.domain}` };
@@ -209,9 +216,12 @@ async function callNamecheapApi(
};
}
case 'get_nameservers':
return fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json());
return retryWithBackoff(
() => fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()),
{ maxRetries: 3 }
);
case 'set_nameservers': {
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
method: 'PUT',
@@ -268,32 +278,48 @@ async function callNamecheapApi(
return data;
}
case 'get_balance':
return fetch(`${apiUrl}/account/balance`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json());
return retryWithBackoff(
() => fetch(`${apiUrl}/account/balance`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()),
{ maxRetries: 3 }
);
case 'get_price': {
const tld = funcArgs.tld?.replace(/^\./, ''); // .com → com
return fetch(`${apiUrl}/prices/${tld}`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json());
return retryWithBackoff(
() => fetch(`${apiUrl}/prices/${tld}`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()),
{ maxRetries: 3 }
);
}
case 'get_all_prices': {
return fetch(`${apiUrl}/prices`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json());
return retryWithBackoff(
() => fetch(`${apiUrl}/prices`, {
headers: { 'X-API-Key': apiKey },
}).then(r => r.json()),
{ maxRetries: 3 }
);
}
case 'check_domains': {
return fetch(`${apiUrl}/domains/check`, {
method: 'POST',
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ domains: funcArgs.domains }),
}).then(r => r.json());
// POST but idempotent (read-only check)
return retryWithBackoff(
() => fetch(`${apiUrl}/domains/check`, {
method: 'POST',
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ domains: funcArgs.domains }),
}).then(r => r.json()),
{ maxRetries: 3 }
);
}
case 'whois_lookup': {
// 자체 WHOIS API 서버 사용 (모든 TLD 지원)
const domain = funcArgs.domain;
try {
const whoisRes = await fetch(`https://whois-api-kappa-inoutercoms-projects.vercel.app/api/whois/${domain}`);
const whoisRes = await retryWithBackoff(
() => fetch(`https://whois-api-kappa-inoutercoms-projects.vercel.app/api/whois/${domain}`),
{ maxRetries: 3 }
);
if (!whoisRes.ok) {
return { error: `WHOIS 조회 실패: HTTP ${whoisRes.status}` };
}
@@ -323,6 +349,10 @@ async function callNamecheapApi(
query_time_ms: whois.query_time_ms,
};
} catch (error) {
console.error('[whois_lookup] 오류:', error);
if (error instanceof RetryError) {
return { error: 'WHOIS 조회 서비스에 일시적으로 접근할 수 없습니다.' };
}
return { error: `WHOIS 조회 오류: ${String(error)}` };
}
}
@@ -746,18 +776,19 @@ export async function executeSuggestDomains(args: { keywords: string }, env?: En
const excludeList = [...checkedDomains].slice(-30).join(', ');
// Step 1: GPT에게 도메인 아이디어 생성 요청
const ideaResponse = await fetch(OPENAI_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: `당신은 도메인 이름 전문가입니다. 주어진 키워드/비즈니스 설명을 바탕으로 창의적이고 기억하기 쉬운 도메인 이름을 제안합니다.
const ideaResponse = await retryWithBackoff(
() => fetch(OPENAI_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: `당신은 도메인 이름 전문가입니다. 주어진 키워드/비즈니스 설명을 바탕으로 창의적이고 기억하기 쉬운 도메인 이름을 제안합니다.
규칙:
- 정확히 15개의 도메인 이름을 제안하세요
@@ -769,16 +800,18 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
예시 응답:
["coffeenest.com", "brewlab.io", "beanspot.co"]`
},
{
role: 'user',
content: `키워드: ${keywords}`
}
],
max_tokens: 500,
temperature: 0.9,
},
{
role: 'user',
content: `키워드: ${keywords}`
}
],
max_tokens: 500,
temperature: 0.9,
}),
}),
});
{ maxRetries: 2 } // 도메인 추천은 중요도가 낮으므로 재시도 2회
);
if (!ideaResponse.ok) {
if (availableDomains.length > 0) break; // 이미 찾은 게 있으면 그것으로 진행
@@ -804,14 +837,17 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
newDomains.forEach(d => checkedDomains.add(d.toLowerCase()));
// Step 2: 가용성 확인
const checkResponse = await fetch(`${namecheapApiUrl}/domains/check`, {
method: 'POST',
headers: {
'X-API-Key': env.NAMECHEAP_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ domains: newDomains }),
});
const checkResponse = await retryWithBackoff(
() => fetch(`${namecheapApiUrl}/domains/check`, {
method: 'POST',
headers: {
'X-API-Key': env.NAMECHEAP_API_KEY!, // Already checked above
'Content-Type': 'application/json',
},
body: JSON.stringify({ domains: newDomains }),
}),
{ maxRetries: 3 }
);
if (!checkResponse.ok) continue;
@@ -845,9 +881,12 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
}
// 캐시 미스 시 API 호출
const priceRes = await fetch(`${namecheapApiUrl}/prices/${tld}`, {
headers: { 'X-API-Key': env.NAMECHEAP_API_KEY },
});
const priceRes = await retryWithBackoff(
() => fetch(`${namecheapApiUrl}/prices/${tld}`, {
headers: { 'X-API-Key': env.NAMECHEAP_API_KEY! }, // Already checked above
}),
{ maxRetries: 3 }
);
if (priceRes.ok) {
const priceData = await priceRes.json() as { krw?: number };
tldPrices[tld] = priceData.krw || 0;
@@ -877,6 +916,9 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
return response;
} catch (error) {
console.error('[suggestDomains] 오류:', error);
if (error instanceof RetryError) {
return `🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
}
return `🚫 도메인 추천 중 오류가 발생했습니다: ${String(error)}`;
}
}