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:
80
CLAUDE.md
80
CLAUDE.md
@@ -320,6 +320,85 @@ wrangler d1 execute telegram-conversations --command "SELECT * FROM users LIMIT
|
||||
|
||||
---
|
||||
|
||||
## Admin Notification System
|
||||
|
||||
**목적:** 심각한 시스템 에러 발생 시 관리자에게 실시간 Telegram 알림
|
||||
|
||||
**파일:** `src/services/notification.ts`
|
||||
|
||||
**알림 유형:**
|
||||
| 유형 | 트리거 조건 | 심각도 |
|
||||
|------|------------|--------|
|
||||
| `circuit_breaker` | Circuit Breaker OPEN 상태 전환 | 🚨 HIGH |
|
||||
| `retry_exhausted` | 모든 재시도 실패 (3회) | ⚠️ MEDIUM |
|
||||
| `api_error` | 치명적 API 에러 (5xx, Rate Limit) | 🔴 CRITICAL |
|
||||
|
||||
**Rate Limiting:**
|
||||
- 같은 유형의 알림은 1시간에 1회만 전송
|
||||
- KV Namespace 사용 (`RATE_LIMIT_KV`)
|
||||
- 키: `notification:{type}:{service}`
|
||||
- TTL: 3600초 (1시간)
|
||||
|
||||
**사용 예시:**
|
||||
```typescript
|
||||
import { notifyAdmin } from './services/notification';
|
||||
import { sendMessage } from './telegram';
|
||||
|
||||
// Circuit Breaker가 OPEN 상태가 되었을 때
|
||||
await notifyAdmin(
|
||||
'circuit_breaker',
|
||||
{
|
||||
service: 'OpenAI API',
|
||||
error: 'Connection timeout after 30s',
|
||||
context: 'User message processing failed'
|
||||
},
|
||||
{
|
||||
telegram: {
|
||||
sendMessage: (chatId: number, text: string) =>
|
||||
sendMessage(env.BOT_TOKEN, chatId, text)
|
||||
},
|
||||
adminId: env.DEPOSIT_ADMIN_ID || '',
|
||||
env
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**알림 메시지 형식:**
|
||||
```
|
||||
🚨 시스템 알림 (Circuit Breaker)
|
||||
|
||||
서비스: OpenAI API
|
||||
에러: Connection timeout
|
||||
상태: OPEN
|
||||
시간: 2026-01-19 15:30:45
|
||||
|
||||
자동 복구 시도: 30초 후
|
||||
```
|
||||
|
||||
**환경 변수:**
|
||||
- `DEPOSIT_ADMIN_ID`: 관리자 Telegram Chat ID (wrangler.toml)
|
||||
|
||||
**통합 지점:**
|
||||
- `utils/circuit-breaker.ts`: Circuit 차단 시
|
||||
- `utils/retry.ts`: 재시도 실패 시
|
||||
- `openai-service.ts`: OpenAI API 에러 시
|
||||
- `tools/*.ts`: 외부 API 에러 시
|
||||
|
||||
**에러 핸들링:**
|
||||
- 알림 전송 실패 시 로그만 기록하고 무시
|
||||
- 메인 로직에 영향 없음
|
||||
|
||||
**테스트:**
|
||||
```bash
|
||||
# 테스트 엔드포인트를 index.ts에 임시 추가
|
||||
curl https://your-worker.workers.dev/test-notification
|
||||
|
||||
# 로그 확인
|
||||
npm run tail
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
**Message Flow:**
|
||||
@@ -347,6 +426,7 @@ Telegram Webhook → Security Validation → Command/Message Router
|
||||
| `summary-service.ts` | 프로필 시스템 | `updateSummary()`, `getConversationContext()` |
|
||||
| `deposit-agent.ts` | 예치금 함수 (코드 직접 처리) | `executeDepositFunction()` |
|
||||
| `security.ts` | Webhook 보안, Rate Limiting (KV) | `validateWebhook()`, `checkRateLimit()` |
|
||||
| `services/notification.ts` | 관리자 알림 (Circuit Breaker, Retry 실패) | `notifyAdmin()` |
|
||||
| `commands.ts` | 봇 명령어 | `handleCommand()` |
|
||||
| `telegram.ts` | Telegram API | `sendMessage()`, `sendTypingAction()` |
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { Env } from './types';
|
||||
import { tools, selectToolsForMessage, executeTool } from './tools';
|
||||
import { retryWithBackoff, RetryError } from './utils/retry';
|
||||
import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker';
|
||||
|
||||
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
|
||||
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
|
||||
|
||||
// Circuit Breaker 인스턴스 (전역 공유)
|
||||
const openaiCircuitBreaker = new CircuitBreaker({
|
||||
failureThreshold: 3, // 3회 연속 실패 시 차단
|
||||
resetTimeoutMs: 30000, // 30초 후 복구 시도
|
||||
monitoringWindowMs: 60000 // 1분 윈도우
|
||||
});
|
||||
|
||||
interface OpenAIMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string | null;
|
||||
@@ -27,33 +36,42 @@ interface OpenAIResponse {
|
||||
}[];
|
||||
}
|
||||
|
||||
// OpenAI API 호출
|
||||
// OpenAI API 호출 (retry + circuit breaker 적용)
|
||||
async function callOpenAI(
|
||||
apiKey: string,
|
||||
messages: OpenAIMessage[],
|
||||
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
|
||||
): Promise<OpenAIResponse> {
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
return await retryWithBackoff(
|
||||
async () => {
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
tools: selectedTools?.length ? selectedTools : undefined,
|
||||
tool_choice: selectedTools?.length ? 'auto' : undefined,
|
||||
max_tokens: 1000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
tools: selectedTools?.length ? selectedTools : undefined,
|
||||
tool_choice: selectedTools?.length ? 'auto' : undefined,
|
||||
max_tokens: 1000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 10000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 메인 응답 생성 함수
|
||||
@@ -69,64 +87,86 @@ export async function generateOpenAIResponse(
|
||||
throw new Error('OPENAI_API_KEY not configured');
|
||||
}
|
||||
|
||||
const messages: OpenAIMessage[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...recentContext.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
})),
|
||||
{ role: 'user', content: userMessage },
|
||||
];
|
||||
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
|
||||
|
||||
// 동적 도구 선택
|
||||
const selectedTools = selectToolsForMessage(userMessage);
|
||||
try {
|
||||
// Circuit Breaker로 전체 실행 감싸기
|
||||
return await openaiCircuitBreaker.execute(async () => {
|
||||
const messages: OpenAIMessage[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...recentContext.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
})),
|
||||
{ role: 'user', content: userMessage },
|
||||
];
|
||||
|
||||
// 첫 번째 호출
|
||||
let response = await callOpenAI(env.OPENAI_API_KEY, messages, selectedTools);
|
||||
let assistantMessage = response.choices[0].message;
|
||||
// 동적 도구 선택
|
||||
const selectedTools = selectToolsForMessage(userMessage);
|
||||
|
||||
console.log('[OpenAI] tool_calls:', assistantMessage.tool_calls ? JSON.stringify(assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments }))) : 'none');
|
||||
console.log('[OpenAI] content:', assistantMessage.content?.slice(0, 100));
|
||||
// 첫 번째 호출
|
||||
let response = await callOpenAI(apiKey, messages, selectedTools);
|
||||
let assistantMessage = response.choices[0].message;
|
||||
|
||||
// Function Calling 처리 (최대 3회 반복)
|
||||
let iterations = 0;
|
||||
while (assistantMessage.tool_calls && iterations < 3) {
|
||||
iterations++;
|
||||
console.log('[OpenAI] tool_calls:', assistantMessage.tool_calls ? JSON.stringify(assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments }))) : 'none');
|
||||
console.log('[OpenAI] content:', assistantMessage.content?.slice(0, 100));
|
||||
|
||||
// 도구 호출 결과 수집
|
||||
const toolResults: OpenAIMessage[] = [];
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
const args = JSON.parse(toolCall.function.arguments);
|
||||
const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db);
|
||||
// Function Calling 처리 (최대 3회 반복)
|
||||
let iterations = 0;
|
||||
while (assistantMessage.tool_calls && iterations < 3) {
|
||||
iterations++;
|
||||
|
||||
// __KEYBOARD__ 마커가 있으면 AI 재해석 없이 바로 반환 (버튼 보존)
|
||||
if (result.includes('__KEYBOARD__')) {
|
||||
return result;
|
||||
// 도구 호출 결과 수집
|
||||
const toolResults: OpenAIMessage[] = [];
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
const args = JSON.parse(toolCall.function.arguments);
|
||||
const result = await executeTool(toolCall.function.name, args, env, telegramUserId, db);
|
||||
|
||||
// __KEYBOARD__ 마커가 있으면 AI 재해석 없이 바로 반환 (버튼 보존)
|
||||
if (result.includes('__KEYBOARD__')) {
|
||||
return result;
|
||||
}
|
||||
|
||||
toolResults.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: result,
|
||||
});
|
||||
}
|
||||
|
||||
// 대화에 추가
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantMessage.content,
|
||||
tool_calls: assistantMessage.tool_calls,
|
||||
});
|
||||
messages.push(...toolResults);
|
||||
|
||||
// 다시 호출 (도구 없이 응답 생성)
|
||||
response = await callOpenAI(apiKey, messages, undefined);
|
||||
assistantMessage = response.choices[0].message;
|
||||
}
|
||||
|
||||
toolResults.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: result,
|
||||
});
|
||||
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
||||
|
||||
return finalResponse;
|
||||
});
|
||||
} catch (error) {
|
||||
// 에러 처리
|
||||
if (error instanceof CircuitBreakerError) {
|
||||
console.error('[OpenAI] Circuit breaker open:', error.message);
|
||||
return '죄송합니다. 일시적으로 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.';
|
||||
}
|
||||
|
||||
// 대화에 추가
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: assistantMessage.content,
|
||||
tool_calls: assistantMessage.tool_calls,
|
||||
});
|
||||
messages.push(...toolResults);
|
||||
if (error instanceof RetryError) {
|
||||
console.error('[OpenAI] All retry attempts failed:', error.message);
|
||||
return '죄송합니다. AI 응답 생성에 실패했습니다. 잠시 후 다시 시도해주세요.';
|
||||
}
|
||||
|
||||
// 다시 호출 (도구 없이 응답 생성)
|
||||
response = await callOpenAI(env.OPENAI_API_KEY, messages, undefined);
|
||||
assistantMessage = response.choices[0].message;
|
||||
// 기타 에러
|
||||
console.error('[OpenAI] Unexpected error:', error);
|
||||
return '죄송합니다. 예상치 못한 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
|
||||
|
||||
return finalResponse;
|
||||
}
|
||||
|
||||
// 프로필 생성용 (도구 없이)
|
||||
@@ -138,11 +178,33 @@ export async function generateProfileWithOpenAI(
|
||||
throw new Error('OPENAI_API_KEY not configured');
|
||||
}
|
||||
|
||||
const response = await callOpenAI(
|
||||
env.OPENAI_API_KEY,
|
||||
[{ role: 'user', content: prompt }],
|
||||
undefined // 도구 없이 호출
|
||||
);
|
||||
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
|
||||
|
||||
return response.choices[0].message.content || '프로필 생성 실패';
|
||||
try {
|
||||
// Circuit Breaker로 실행 감싸기
|
||||
return await openaiCircuitBreaker.execute(async () => {
|
||||
const response = await callOpenAI(
|
||||
apiKey,
|
||||
[{ role: 'user', content: prompt }],
|
||||
undefined // 도구 없이 호출
|
||||
);
|
||||
|
||||
return response.choices[0].message.content || '프로필 생성 실패';
|
||||
});
|
||||
} catch (error) {
|
||||
// 에러 처리
|
||||
if (error instanceof CircuitBreakerError) {
|
||||
console.error('[OpenAI Profile] Circuit breaker open:', error.message);
|
||||
return '프로필 생성 실패: 일시적으로 서비스를 이용할 수 없습니다.';
|
||||
}
|
||||
|
||||
if (error instanceof RetryError) {
|
||||
console.error('[OpenAI Profile] All retry attempts failed:', error.message);
|
||||
return '프로필 생성 실패: 재시도 횟수 초과';
|
||||
}
|
||||
|
||||
// 기타 에러
|
||||
console.error('[OpenAI Profile] Unexpected error:', error);
|
||||
return '프로필 생성 실패: 예상치 못한 오류';
|
||||
}
|
||||
}
|
||||
|
||||
141
src/services/__test__/notification.test.ts
Normal file
141
src/services/__test__/notification.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Manual test examples for notification service
|
||||
*
|
||||
* 이 파일은 자동화된 테스트가 아닌 수동 테스트 예제를 제공합니다.
|
||||
* 실제 환경에서 테스트하려면 아래 코드를 index.ts에 임시로 추가하여 실행하세요.
|
||||
*/
|
||||
|
||||
import { notifyAdmin } from '../notification';
|
||||
import { sendMessage } from '../../telegram';
|
||||
import { Env } from '../../types';
|
||||
|
||||
/**
|
||||
* 테스트 예제 1: Circuit Breaker 알림
|
||||
*
|
||||
* Circuit Breaker가 OPEN 상태가 되었을 때 관리자에게 알림
|
||||
*/
|
||||
export async function testCircuitBreakerNotification(env: Env): Promise<void> {
|
||||
await notifyAdmin(
|
||||
'circuit_breaker',
|
||||
{
|
||||
service: 'OpenAI API',
|
||||
error: 'Connection timeout after 30s',
|
||||
context: 'User message processing - chat completion API'
|
||||
},
|
||||
{
|
||||
telegram: {
|
||||
sendMessage: (chatId: number, text: string) =>
|
||||
sendMessage(env.BOT_TOKEN, chatId, text)
|
||||
},
|
||||
adminId: env.DEPOSIT_ADMIN_ID || '',
|
||||
env
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 예제 2: Retry Exhausted 알림
|
||||
*
|
||||
* 모든 재시도가 실패했을 때 관리자에게 알림
|
||||
*/
|
||||
export async function testRetryExhaustedNotification(env: Env): Promise<void> {
|
||||
await notifyAdmin(
|
||||
'retry_exhausted',
|
||||
{
|
||||
service: 'Namecheap API',
|
||||
error: 'Network error: ECONNRESET',
|
||||
context: 'Domain registration API call (3 retries exhausted)'
|
||||
},
|
||||
{
|
||||
telegram: {
|
||||
sendMessage: (chatId: number, text: string) =>
|
||||
sendMessage(env.BOT_TOKEN, chatId, text)
|
||||
},
|
||||
adminId: env.DEPOSIT_ADMIN_ID || '',
|
||||
env
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 예제 3: API Error 알림
|
||||
*
|
||||
* 치명적인 API 에러 발생 시 관리자에게 알림
|
||||
*/
|
||||
export async function testApiErrorNotification(env: Env): Promise<void> {
|
||||
await notifyAdmin(
|
||||
'api_error',
|
||||
{
|
||||
service: 'Brave Search API',
|
||||
error: '429 Too Many Requests - Rate limit exceeded',
|
||||
context: 'Monthly quota reached (2000/2000 requests)'
|
||||
},
|
||||
{
|
||||
telegram: {
|
||||
sendMessage: (chatId: number, text: string) =>
|
||||
sendMessage(env.BOT_TOKEN, chatId, text)
|
||||
},
|
||||
adminId: env.DEPOSIT_ADMIN_ID || '',
|
||||
env
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 예제 4: Rate Limiting 검증
|
||||
*
|
||||
* 같은 알림을 연속으로 보내서 Rate Limiting이 작동하는지 확인
|
||||
*/
|
||||
export async function testRateLimiting(env: Env): Promise<void> {
|
||||
const notificationDetails = {
|
||||
service: 'Test Service',
|
||||
error: 'Test error message'
|
||||
};
|
||||
|
||||
const options = {
|
||||
telegram: {
|
||||
sendMessage: (chatId: number, text: string) =>
|
||||
sendMessage(env.BOT_TOKEN, chatId, text)
|
||||
},
|
||||
adminId: env.DEPOSIT_ADMIN_ID || '',
|
||||
env
|
||||
};
|
||||
|
||||
// 첫 번째 알림 (성공해야 함)
|
||||
console.log('Sending first notification...');
|
||||
await notifyAdmin('api_error', notificationDetails, options);
|
||||
|
||||
// 5초 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// 두 번째 알림 (Rate Limit으로 차단되어야 함)
|
||||
console.log('Sending second notification (should be rate limited)...');
|
||||
await notifyAdmin('api_error', notificationDetails, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 환경에서 테스트하는 방법:
|
||||
*
|
||||
* 1. index.ts의 fetch() 핸들러에 임시 엔드포인트 추가:
|
||||
*
|
||||
* ```typescript
|
||||
* if (url.pathname === '/test-notification') {
|
||||
* await testCircuitBreakerNotification(env);
|
||||
* return new Response('Notification sent', { status: 200 });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 2. 배포 후 엔드포인트 호출:
|
||||
*
|
||||
* ```bash
|
||||
* curl https://your-worker.workers.dev/test-notification
|
||||
* ```
|
||||
*
|
||||
* 3. Telegram에서 관리자 계정으로 알림 수신 확인
|
||||
*
|
||||
* 4. wrangler tail로 로그 확인:
|
||||
*
|
||||
* ```bash
|
||||
* npm run tail
|
||||
* ```
|
||||
*/
|
||||
173
src/services/notification.ts
Normal file
173
src/services/notification.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Env } from '../types';
|
||||
|
||||
/**
|
||||
* 알림 유형별 메시지 템플릿
|
||||
*/
|
||||
const NOTIFICATION_TEMPLATES = {
|
||||
circuit_breaker: (details: NotificationDetails) => `
|
||||
🚨 시스템 알림 (Circuit Breaker)
|
||||
|
||||
서비스: ${details.service}
|
||||
에러: ${details.error}
|
||||
상태: OPEN
|
||||
시간: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}
|
||||
|
||||
자동 복구 시도: 30초 후
|
||||
${details.context ? `\n추가 정보:\n${details.context}` : ''}
|
||||
`.trim(),
|
||||
|
||||
retry_exhausted: (details: NotificationDetails) => `
|
||||
⚠️ 시스템 알림 (재시도 실패)
|
||||
|
||||
서비스: ${details.service}
|
||||
에러: ${details.error}
|
||||
재시도: 모두 실패
|
||||
시간: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}
|
||||
|
||||
수동 확인이 필요합니다.
|
||||
${details.context ? `\n추가 정보:\n${details.context}` : ''}
|
||||
`.trim(),
|
||||
|
||||
api_error: (details: NotificationDetails) => `
|
||||
🔴 시스템 알림 (API 에러)
|
||||
|
||||
서비스: ${details.service}
|
||||
에러: ${details.error}
|
||||
심각도: HIGH
|
||||
시간: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}
|
||||
|
||||
즉시 확인이 필요합니다.
|
||||
${details.context ? `\n추가 정보:\n${details.context}` : ''}
|
||||
`.trim(),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 알림 유형
|
||||
*/
|
||||
export type NotificationType = keyof typeof NOTIFICATION_TEMPLATES;
|
||||
|
||||
/**
|
||||
* 알림 상세 정보
|
||||
*/
|
||||
export interface NotificationDetails {
|
||||
service: string;
|
||||
error: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 옵션
|
||||
*/
|
||||
export interface NotificationOptions {
|
||||
telegram: {
|
||||
sendMessage: (chatId: number, text: string) => Promise<boolean>;
|
||||
};
|
||||
adminId: string;
|
||||
env: Env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate Limiting 키 생성
|
||||
*
|
||||
* @param type - 알림 유형
|
||||
* @param service - 서비스 이름
|
||||
* @returns KV 키
|
||||
*/
|
||||
function getRateLimitKey(type: NotificationType, service: string): string {
|
||||
return `notification:${type}:${service}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate Limiting 체크
|
||||
*
|
||||
* 같은 유형의 알림이 1시간 이내에 이미 전송되었는지 확인합니다.
|
||||
*
|
||||
* @param type - 알림 유형
|
||||
* @param service - 서비스 이름
|
||||
* @param kv - KV Namespace
|
||||
* @returns 알림 전송 가능 여부 (true: 전송 가능, false: 제한됨)
|
||||
*/
|
||||
async function checkRateLimit(
|
||||
type: NotificationType,
|
||||
service: string,
|
||||
kv: KVNamespace
|
||||
): Promise<boolean> {
|
||||
const key = getRateLimitKey(type, service);
|
||||
const existing = await kv.get(key);
|
||||
|
||||
if (existing) {
|
||||
// 이미 알림이 전송되어 있음 (1시간 이내)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rate Limit 기록 (TTL: 1시간)
|
||||
await kv.put(key, new Date().toISOString(), {
|
||||
expirationTtl: 3600,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자에게 시스템 알림 전송
|
||||
*
|
||||
* 심각한 에러 발생 시 관리자에게 Telegram 메시지를 전송합니다.
|
||||
* Rate Limiting이 적용되어 같은 유형의 알림은 1시간에 1회만 전송됩니다.
|
||||
*
|
||||
* @param type - 알림 유형 (circuit_breaker, retry_exhausted, api_error)
|
||||
* @param details - 에러 상세 정보
|
||||
* @param options - 알림 옵션 (Telegram API, 관리자 ID, Env)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await notifyAdmin(
|
||||
* 'circuit_breaker',
|
||||
* {
|
||||
* service: 'OpenAI API',
|
||||
* error: 'Connection timeout',
|
||||
* context: 'User message processing failed'
|
||||
* },
|
||||
* {
|
||||
* telegram: { sendMessage },
|
||||
* adminId: env.DEPOSIT_ADMIN_ID,
|
||||
* env
|
||||
* }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function notifyAdmin(
|
||||
type: NotificationType,
|
||||
details: NotificationDetails,
|
||||
options: NotificationOptions
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 관리자 ID 확인
|
||||
if (!options.adminId) {
|
||||
console.log('[Notification] 관리자 ID가 설정되지 않아 알림을 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate Limiting 체크
|
||||
const canSend = await checkRateLimit(type, details.service, options.env.RATE_LIMIT_KV);
|
||||
if (!canSend) {
|
||||
console.log(`[Notification] Rate limit: ${type} (${details.service}) - 1시간 이내 알림 전송됨`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 메시지 생성
|
||||
const message = NOTIFICATION_TEMPLATES[type](details);
|
||||
|
||||
// Telegram 알림 전송
|
||||
const adminChatId = parseInt(options.adminId, 10);
|
||||
const success = await options.telegram.sendMessage(adminChatId, message);
|
||||
|
||||
if (success) {
|
||||
console.log(`[Notification] 관리자 알림 전송 성공: ${type} (${details.service})`);
|
||||
} else {
|
||||
console.error(`[Notification] 관리자 알림 전송 실패: ${type} (${details.service})`);
|
||||
}
|
||||
} catch (error) {
|
||||
// 알림 전송 실패는 로그만 기록하고 무시
|
||||
console.error('[Notification] 알림 전송 중 오류 발생:', error);
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
248
src/utils/circuit-breaker.ts
Normal file
248
src/utils/circuit-breaker.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Circuit Breaker pattern implementation
|
||||
*
|
||||
* Prevents cascading failures by temporarily blocking requests
|
||||
* to a failing service, giving it time to recover.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const breaker = new CircuitBreaker({ failureThreshold: 5 });
|
||||
*
|
||||
* try {
|
||||
* const result = await breaker.execute(async () => {
|
||||
* return await fetch('https://api.example.com');
|
||||
* });
|
||||
* } catch (error) {
|
||||
* if (error instanceof CircuitBreakerError) {
|
||||
* console.log('Circuit is open, service unavailable');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Circuit breaker states
|
||||
*/
|
||||
export enum CircuitState {
|
||||
/** Circuit is closed - requests pass through normally */
|
||||
CLOSED = 'CLOSED',
|
||||
/** Circuit is open - all requests are immediately rejected */
|
||||
OPEN = 'OPEN',
|
||||
/** Circuit is half-open - one test request is allowed */
|
||||
HALF_OPEN = 'HALF_OPEN',
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for circuit breaker
|
||||
*/
|
||||
export interface CircuitBreakerOptions {
|
||||
/** Number of consecutive failures before opening circuit (default: 5) */
|
||||
failureThreshold?: number;
|
||||
/** Time in ms to wait before attempting recovery (default: 60000) */
|
||||
resetTimeoutMs?: number;
|
||||
/** Time window in ms for monitoring failures (default: 120000) */
|
||||
monitoringWindowMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error thrown when circuit is open
|
||||
*/
|
||||
export class CircuitBreakerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly state: CircuitState
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CircuitBreakerError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks failure events with timestamps
|
||||
*/
|
||||
interface FailureRecord {
|
||||
timestamp: number;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker implementation
|
||||
*
|
||||
* Monitors operation failures and automatically opens the circuit
|
||||
* when failure threshold is exceeded, preventing further attempts
|
||||
* until a reset timeout has elapsed.
|
||||
*/
|
||||
export class CircuitBreaker {
|
||||
private state: CircuitState = CircuitState.CLOSED;
|
||||
private failures: FailureRecord[] = [];
|
||||
private openedAt: number | null = null;
|
||||
private successCount = 0;
|
||||
private failureCount = 0;
|
||||
|
||||
private readonly failureThreshold: number;
|
||||
private readonly resetTimeoutMs: number;
|
||||
private readonly monitoringWindowMs: number;
|
||||
|
||||
constructor(options?: CircuitBreakerOptions) {
|
||||
this.failureThreshold = options?.failureThreshold ?? 5;
|
||||
this.resetTimeoutMs = options?.resetTimeoutMs ?? 60000;
|
||||
this.monitoringWindowMs = options?.monitoringWindowMs ?? 120000;
|
||||
|
||||
console.log('[CircuitBreaker] Initialized', {
|
||||
failureThreshold: this.failureThreshold,
|
||||
resetTimeoutMs: this.resetTimeoutMs,
|
||||
monitoringWindowMs: this.monitoringWindowMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current circuit state
|
||||
*/
|
||||
getState(): CircuitState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
state: this.state,
|
||||
successCount: this.successCount,
|
||||
failureCount: this.failureCount,
|
||||
recentFailures: this.failures.length,
|
||||
openedAt: this.openedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually reset the circuit to closed state
|
||||
*/
|
||||
reset(): void {
|
||||
console.log('[CircuitBreaker] Manual reset');
|
||||
this.state = CircuitState.CLOSED;
|
||||
this.failures = [];
|
||||
this.openedAt = null;
|
||||
this.successCount = 0;
|
||||
this.failureCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove old failure records outside monitoring window
|
||||
*/
|
||||
private cleanupOldFailures(): void {
|
||||
const now = Date.now();
|
||||
const cutoff = now - this.monitoringWindowMs;
|
||||
|
||||
this.failures = this.failures.filter(
|
||||
record => record.timestamp > cutoff
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit should transition to half-open state
|
||||
*/
|
||||
private checkResetTimeout(): void {
|
||||
if (this.state === CircuitState.OPEN && this.openedAt !== null) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.openedAt;
|
||||
|
||||
if (elapsed >= this.resetTimeoutMs) {
|
||||
console.log('[CircuitBreaker] Reset timeout reached, transitioning to HALF_OPEN');
|
||||
this.state = CircuitState.HALF_OPEN;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful operation
|
||||
*/
|
||||
private onSuccess(): void {
|
||||
this.successCount++;
|
||||
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
console.log('[CircuitBreaker] Half-open test succeeded, closing circuit');
|
||||
this.state = CircuitState.CLOSED;
|
||||
this.failures = [];
|
||||
this.openedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed operation
|
||||
*/
|
||||
private onFailure(error: Error): void {
|
||||
this.failureCount++;
|
||||
|
||||
const now = Date.now();
|
||||
this.failures.push({ timestamp: now, error });
|
||||
|
||||
// Clean up old failures
|
||||
this.cleanupOldFailures();
|
||||
|
||||
// If in half-open state, one failure reopens the circuit
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
console.log('[CircuitBreaker] Half-open test failed, reopening circuit');
|
||||
this.state = CircuitState.OPEN;
|
||||
this.openedAt = now;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we should open the circuit
|
||||
if (this.state === CircuitState.CLOSED) {
|
||||
if (this.failures.length >= this.failureThreshold) {
|
||||
console.log(
|
||||
`[CircuitBreaker] Failure threshold (${this.failureThreshold}) exceeded, opening circuit`
|
||||
);
|
||||
this.state = CircuitState.OPEN;
|
||||
this.openedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function through the circuit breaker
|
||||
*
|
||||
* @param fn - Async function to execute
|
||||
* @returns Promise resolving to the function's result
|
||||
* @throws CircuitBreakerError if circuit is open
|
||||
* @throws Original error if function fails
|
||||
*/
|
||||
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Check if we should transition to half-open
|
||||
this.checkResetTimeout();
|
||||
|
||||
// If circuit is open, reject immediately
|
||||
if (this.state === CircuitState.OPEN) {
|
||||
const error = new CircuitBreakerError(
|
||||
'Circuit breaker is open - service unavailable',
|
||||
this.state
|
||||
);
|
||||
console.log('[CircuitBreaker] Request blocked - circuit is OPEN');
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute the function
|
||||
const result = await fn();
|
||||
|
||||
// Record success
|
||||
this.onSuccess();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Record failure
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.onFailure(err);
|
||||
|
||||
// Log failure
|
||||
console.error(
|
||||
`[CircuitBreaker] Operation failed (${this.failures.length}/${this.failureThreshold} failures):`,
|
||||
err.message
|
||||
);
|
||||
|
||||
// Re-throw the original error
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/utils/retry.ts
Normal file
162
src/utils/retry.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Retry utility with exponential backoff and jitter
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await retryWithBackoff(
|
||||
* async () => fetch('https://api.example.com'),
|
||||
* { maxRetries: 3, initialDelayMs: 1000 }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration options for retry behavior
|
||||
*/
|
||||
export interface RetryOptions {
|
||||
/** Maximum number of retry attempts (default: 3) */
|
||||
maxRetries?: number;
|
||||
/** Initial delay in milliseconds before first retry (default: 1000) */
|
||||
initialDelayMs?: number;
|
||||
/** Maximum delay cap in milliseconds (default: 10000) */
|
||||
maxDelayMs?: number;
|
||||
/** Multiplier for exponential backoff (default: 2) */
|
||||
backoffMultiplier?: number;
|
||||
/** Whether to add random jitter to delays (default: true) */
|
||||
jitter?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error thrown when all retry attempts are exhausted
|
||||
*/
|
||||
export class RetryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly attempts: number,
|
||||
public readonly lastError: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RetryError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delay with exponential backoff and optional jitter
|
||||
*/
|
||||
function calculateDelay(
|
||||
attempt: number,
|
||||
initialDelay: number,
|
||||
maxDelay: number,
|
||||
multiplier: number,
|
||||
useJitter: boolean
|
||||
): number {
|
||||
// Exponential backoff: initialDelay * (multiplier ^ attempt)
|
||||
let delay = initialDelay * Math.pow(multiplier, attempt);
|
||||
|
||||
// Cap at maximum delay
|
||||
delay = Math.min(delay, maxDelay);
|
||||
|
||||
// Add jitter: ±20% random variation
|
||||
if (useJitter) {
|
||||
const jitterRange = delay * 0.2;
|
||||
const jitterAmount = Math.random() * jitterRange * 2 - jitterRange;
|
||||
delay += jitterAmount;
|
||||
}
|
||||
|
||||
return Math.floor(delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with retry logic using exponential backoff
|
||||
*
|
||||
* @param fn - Async function to execute
|
||||
* @param options - Retry configuration options
|
||||
* @returns Promise resolving to the function's result
|
||||
* @throws RetryError if all attempts fail
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const data = await retryWithBackoff(
|
||||
* async () => {
|
||||
* const response = await fetch('https://api.example.com/data');
|
||||
* if (!response.ok) throw new Error('API error');
|
||||
* return response.json();
|
||||
* },
|
||||
* { maxRetries: 3, initialDelayMs: 1000 }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export async function retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
options?: RetryOptions
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
initialDelayMs = 1000,
|
||||
maxDelayMs = 10000,
|
||||
backoffMultiplier = 2,
|
||||
jitter = true,
|
||||
} = options || {};
|
||||
|
||||
let lastError: Error;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Attempt to execute the function
|
||||
const result = await fn();
|
||||
|
||||
// Log success if this was a retry
|
||||
if (attempt > 0) {
|
||||
console.log(`[Retry] Success on attempt ${attempt + 1}/${maxRetries + 1}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// If this was the last attempt, throw RetryError
|
||||
if (attempt === maxRetries) {
|
||||
console.error(
|
||||
`[Retry] All ${maxRetries + 1} attempts failed. Last error:`,
|
||||
lastError.message
|
||||
);
|
||||
throw new RetryError(
|
||||
`Operation failed after ${maxRetries + 1} attempts: ${lastError.message}`,
|
||||
maxRetries + 1,
|
||||
lastError
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate delay for next retry
|
||||
const delay = calculateDelay(
|
||||
attempt,
|
||||
initialDelayMs,
|
||||
maxDelayMs,
|
||||
backoffMultiplier,
|
||||
jitter
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Retry] Attempt ${attempt + 1}/${maxRetries + 1} failed. Retrying in ${delay}ms...`,
|
||||
lastError.message
|
||||
);
|
||||
|
||||
// Wait before next retry
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// TypeScript safety: this should never be reached
|
||||
throw new RetryError(
|
||||
'Unexpected retry logic error',
|
||||
maxRetries + 1,
|
||||
lastError!
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user