Files
telegram-bot-workers/src/openai-service.ts
kappa 58d8bbffc6 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)
2026-01-19 16:30:54 +09:00

211 lines
6.7 KiB
TypeScript

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;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
interface OpenAIResponse {
choices: {
message: OpenAIMessage;
finish_reason: string;
}[];
}
// OpenAI API 호출 (retry + circuit breaker 적용)
async function callOpenAI(
apiKey: string,
messages: OpenAIMessage[],
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
): Promise<OpenAIResponse> {
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();
},
{
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
}
);
}
// 메인 응답 생성 함수
export async function generateOpenAIResponse(
env: Env,
userMessage: string,
systemPrompt: string,
recentContext: { role: 'user' | 'assistant'; content: string }[],
telegramUserId?: string,
db?: D1Database
): Promise<string> {
if (!env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY not configured');
}
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
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 },
];
// 동적 도구 선택
const selectedTools = selectToolsForMessage(userMessage);
// 첫 번째 호출
let response = await callOpenAI(apiKey, messages, selectedTools);
let assistantMessage = response.choices[0].message;
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));
// Function Calling 처리 (최대 3회 반복)
let iterations = 0;
while (assistantMessage.tool_calls && iterations < 3) {
iterations++;
// 도구 호출 결과 수집
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;
}
const finalResponse = assistantMessage.content || '응답을 생성할 수 없습니다.';
return finalResponse;
});
} catch (error) {
// 에러 처리
if (error instanceof CircuitBreakerError) {
console.error('[OpenAI] Circuit breaker open:', error.message);
return '죄송합니다. 일시적으로 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.';
}
if (error instanceof RetryError) {
console.error('[OpenAI] All retry attempts failed:', error.message);
return '죄송합니다. AI 응답 생성에 실패했습니다. 잠시 후 다시 시도해주세요.';
}
// 기타 에러
console.error('[OpenAI] Unexpected error:', error);
return '죄송합니다. 예상치 못한 오류가 발생했습니다.';
}
}
// 프로필 생성용 (도구 없이)
export async function generateProfileWithOpenAI(
env: Env,
prompt: string
): Promise<string> {
if (!env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY not configured');
}
const apiKey = env.OPENAI_API_KEY; // TypeScript 타입 안정성을 위해 변수 저장
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 '프로필 생성 실패: 예상치 못한 오류';
}
}