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';
|
||||
@@ -56,47 +57,56 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
|
||||
if (hasKorean && env?.OPENAI_API_KEY) {
|
||||
try {
|
||||
const translateRes = 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 translateRes = 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: `사용자의 검색어를 영문으로 번역하세요.
|
||||
- 외래어/기술용어는 원래 영문 표기로 변환 (예: 판골린→Pangolin, 도커→Docker)
|
||||
- 일반 한국어는 영문으로 번역
|
||||
- 검색에 최적화된 키워드로 변환
|
||||
- 번역된 검색어만 출력, 설명 없이`
|
||||
},
|
||||
{ role: 'user', content: query }
|
||||
],
|
||||
max_tokens: 100,
|
||||
temperature: 0.3,
|
||||
},
|
||||
{ role: 'user', content: query }
|
||||
],
|
||||
max_tokens: 100,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
{ maxRetries: 2 } // 번역은 중요하지 않으므로 재시도 2회로 제한
|
||||
);
|
||||
if (translateRes.ok) {
|
||||
const translateData = await translateRes.json() as any;
|
||||
translatedQuery = translateData.choices?.[0]?.message?.content?.trim() || query;
|
||||
console.log(`[search_web] 번역: "${query}" → "${translatedQuery}"`);
|
||||
}
|
||||
} catch {
|
||||
// 번역 실패 시 원본 사용
|
||||
} catch (error) {
|
||||
// 번역 실패 시 원본 사용 (RetryError 포함)
|
||||
if (error instanceof RetryError) {
|
||||
console.log(`[search_web] 번역 재시도 실패, 원본 사용: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(translatedQuery)}&count=5`,
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Subscription-Token': env.BRAVE_API_KEY,
|
||||
},
|
||||
}
|
||||
const response = await retryWithBackoff(
|
||||
() => fetch(
|
||||
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(translatedQuery)}&count=5`,
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Subscription-Token': env.BRAVE_API_KEY!, // Already checked above
|
||||
},
|
||||
}
|
||||
),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
if (!response.ok) {
|
||||
return `🔍 검색 오류: ${response.status}`;
|
||||
@@ -120,6 +130,10 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
|
||||
return `🔍 검색 결과: ${queryDisplay}\n\n${results}`;
|
||||
} catch (error) {
|
||||
console.error('[search_web] 오류:', error);
|
||||
if (error instanceof RetryError) {
|
||||
return `🔍 검색 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
return `검색 중 오류가 발생했습니다: ${String(error)}`;
|
||||
}
|
||||
}
|
||||
@@ -130,7 +144,10 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
||||
// Context7 REST API 직접 호출
|
||||
// 1. 라이브러리 검색
|
||||
const searchUrl = `https://context7.com/api/v2/libs/search?libraryName=${encodeURIComponent(library)}&query=${encodeURIComponent(query)}`;
|
||||
const searchResponse = await fetch(searchUrl);
|
||||
const searchResponse = await retryWithBackoff(
|
||||
() => fetch(searchUrl),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
const searchData = await searchResponse.json() as any;
|
||||
|
||||
if (!searchData.libraries?.length) {
|
||||
@@ -141,7 +158,10 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
||||
|
||||
// 2. 문서 조회
|
||||
const docsUrl = `https://context7.com/api/v2/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`;
|
||||
const docsResponse = await fetch(docsUrl);
|
||||
const docsResponse = await retryWithBackoff(
|
||||
() => fetch(docsUrl),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
const docsData = await docsResponse.json() as any;
|
||||
|
||||
if (docsData.error) {
|
||||
@@ -151,6 +171,10 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
||||
const content = docsData.context || docsData.content || JSON.stringify(docsData, null, 2);
|
||||
return `📚 ${library} 문서 (${query}):\n\n${content.slice(0, 1500)}`;
|
||||
} catch (error) {
|
||||
console.error('[lookup_docs] 오류:', error);
|
||||
if (error instanceof RetryError) {
|
||||
return `📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
return `📚 문서 조회 중 오류: ${String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user