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:
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user