fix: add comprehensive error handling for P1 critical issues
P1-1: Callback query error handling - Add try-catch around domain registration and server order - Send user-friendly error messages on failure - Use answerCallbackQuery to acknowledge button clicks - Add structured logging with createLogger P1-2: Queue DLQ monitoring - Add admin notification when server provisioning fails - Update order status to 'failed' in database - Include detailed context in notifications - Apply rate limiting (1 notification per hour) P1-3: Email handler error recovery - Add admin notification when SMS parsing fails - Include email preview in notifications - Mask email addresses for privacy - Add structured logging with emailLogger Also add 'failed' status to ServerOrder type. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
48
src/index.ts
48
src/index.ts
@@ -9,6 +9,7 @@ import { reconcileDeposits, formatReconciliationReport } from './utils/reconcili
|
||||
import { handleProvisionQueue, handleProvisionDLQ } from './server-provision';
|
||||
import { timingSafeEqual } from './security';
|
||||
import { createLogger } from './utils/logger';
|
||||
import { notifyAdmin } from './services/notification';
|
||||
|
||||
const logger = createLogger('worker');
|
||||
|
||||
@@ -101,23 +102,50 @@ Documentation: https://github.com/your-repo
|
||||
|
||||
// Email 핸들러 (SMS → 메일 → 파싱)
|
||||
async email(message: EmailMessage, env: Env): Promise<void> {
|
||||
const emailLogger = createLogger('email-handler');
|
||||
|
||||
try {
|
||||
// 이메일 본문 읽기
|
||||
const rawEmail = await new Response(message.raw).text();
|
||||
|
||||
// 이메일 주소 마스킹
|
||||
const maskedFrom = message.from.replace(/@.+/, '@****');
|
||||
logger.info('이메일 수신', { from: maskedFrom, size: message.rawSize });
|
||||
emailLogger.info('이메일 수신', { from: maskedFrom, size: message.rawSize });
|
||||
|
||||
// SMS 내용 파싱
|
||||
const notification = await parseBankSMS(rawEmail, env);
|
||||
if (!notification) {
|
||||
logger.info('은행 SMS 파싱 실패');
|
||||
return;
|
||||
// Structured logging with context
|
||||
emailLogger.warn('SMS 파싱 실패', {
|
||||
from: maskedFrom,
|
||||
subject: message.headers.get('subject') || 'N/A',
|
||||
preview: rawEmail.substring(0, 200).replace(/\s+/g, ' '),
|
||||
size: message.rawSize
|
||||
});
|
||||
|
||||
// Admin notification for manual review
|
||||
await notifyAdmin(
|
||||
'api_error',
|
||||
{
|
||||
service: 'Email Handler',
|
||||
error: 'SMS parsing failed',
|
||||
context: `From: ${maskedFrom}\nSubject: ${message.headers.get('subject') || 'N/A'}\nPreview: ${rawEmail.substring(0, 150).replace(/\s+/g, ' ')}...`
|
||||
},
|
||||
{
|
||||
telegram: {
|
||||
sendMessage: (chatId: number, text: string) =>
|
||||
sendMessage(env.BOT_TOKEN, chatId, text)
|
||||
},
|
||||
adminId: env.DEPOSIT_ADMIN_ID || '',
|
||||
env
|
||||
}
|
||||
);
|
||||
|
||||
return; // Don't throw - email routing expects success
|
||||
}
|
||||
|
||||
// 파싱 결과 마스킹 로깅
|
||||
logger.info('SMS 파싱 결과', {
|
||||
emailLogger.info('SMS 파싱 결과', {
|
||||
bankName: notification.bankName,
|
||||
depositorName: notification.depositorName
|
||||
? notification.depositorName.slice(0, 2) + '***'
|
||||
@@ -144,16 +172,16 @@ Documentation: https://github.com/your-repo
|
||||
).run();
|
||||
|
||||
const notificationId = insertResult.meta.last_row_id;
|
||||
logger.info('알림 저장 완료', { notificationId });
|
||||
emailLogger.info('알림 저장 완료', { notificationId });
|
||||
|
||||
// 자동 매칭 시도
|
||||
const matched = await matchPendingDeposit(env.DB, notificationId, notification);
|
||||
|
||||
// 매칭 결과 로깅 (민감 정보 마스킹)
|
||||
if (matched) {
|
||||
logger.info('자동 매칭 성공', { transactionId: matched.transactionId });
|
||||
emailLogger.info('자동 매칭 성공', { transactionId: matched.transactionId });
|
||||
} else {
|
||||
logger.info('매칭되는 거래 없음');
|
||||
emailLogger.info('매칭되는 거래 없음');
|
||||
}
|
||||
|
||||
// 매칭 성공 시 사용자에게 알림
|
||||
@@ -196,7 +224,11 @@ Documentation: https://github.com/your-repo
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('이메일 처리 오류', error as Error);
|
||||
emailLogger.error('이메일 처리 오류', error as Error, {
|
||||
from: message.from.replace(/@.+/, '@****'),
|
||||
size: message.rawSize
|
||||
});
|
||||
// Don't rethrow - email routing expects success response
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { answerCallbackQuery, editMessageText } from '../../telegram';
|
||||
import { answerCallbackQuery, editMessageText, sendMessage } from '../../telegram';
|
||||
import { UserService } from '../../services/user-service';
|
||||
import { executeDomainRegister } from '../../domain-register';
|
||||
import { createLogger } from '../../utils/logger';
|
||||
import type { Env, TelegramUpdate } from '../../types';
|
||||
|
||||
const logger = createLogger('callback-handler');
|
||||
|
||||
/**
|
||||
* 도메인 형식 검증 정규식
|
||||
* - 최소 2글자 이상
|
||||
@@ -75,6 +78,7 @@ export async function handleCallbackQuery(
|
||||
`⏳ <b>${domain}</b> 등록 처리 중...`
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price);
|
||||
|
||||
if (result.success) {
|
||||
@@ -108,6 +112,35 @@ ${result.error}
|
||||
다시 시도하시려면 도메인 등록을 요청해주세요.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('도메인 등록 처리 실패', error as Error, {
|
||||
domain,
|
||||
price,
|
||||
userId: user.id,
|
||||
telegramUserId
|
||||
});
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '❌ 처리 중 오류 발생' });
|
||||
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`❌ <b>처리 중 오류 발생</b>
|
||||
|
||||
도메인 등록 처리 중 예상치 못한 오류가 발생했습니다.
|
||||
잠시 후 다시 시도해주세요.
|
||||
|
||||
문제가 계속되면 관리자에게 문의해주세요.`
|
||||
);
|
||||
|
||||
// Fallback: send as new message if editMessageText fails
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
'❌ 도메인 등록 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
||||
).catch(e => logger.error('Fallback message send failed', e as Error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,6 +189,7 @@ ${result.error}
|
||||
'⏳ 서버 주문 처리 중...'
|
||||
);
|
||||
|
||||
try {
|
||||
// 세션 조회
|
||||
const { getServerSession, deleteServerSession } = await import('../../server-agent');
|
||||
|
||||
@@ -259,6 +293,34 @@ ${result.error}
|
||||
|
||||
// 세션 삭제
|
||||
await deleteServerSession(env.DB, telegramUserId);
|
||||
} catch (error) {
|
||||
logger.error('서버 주문 처리 실패', error as Error, {
|
||||
index,
|
||||
userId: user.id,
|
||||
telegramUserId
|
||||
});
|
||||
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '❌ 처리 중 오류 발생' });
|
||||
|
||||
await editMessageText(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
messageId,
|
||||
`❌ <b>처리 중 오류 발생</b>
|
||||
|
||||
서버 주문 처리 중 예상치 못한 오류가 발생했습니다.
|
||||
잠시 후 다시 시도해주세요.
|
||||
|
||||
문제가 계속되면 관리자에게 문의해주세요.`
|
||||
);
|
||||
|
||||
// Fallback: send as new message if editMessageText fails
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
'❌ 서버 주문 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
||||
).catch(e => logger.error('Fallback message send failed', e as Error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Env, ProvisionMessage, MessageBatch, ServerOrder, ProvisionRespons
|
||||
import { createLogger } from './utils/logger';
|
||||
import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock';
|
||||
import { sendMessage } from './telegram';
|
||||
import { notifyAdmin as notifyAdminSystem } from './services/notification';
|
||||
|
||||
const logger = createLogger('server-provision');
|
||||
|
||||
@@ -471,37 +472,73 @@ export async function handleProvisionQueue(
|
||||
nextAttempt: message.attempts + 1
|
||||
});
|
||||
} else {
|
||||
// 최대 재시도 초과 - 주문 삭제 (잔액 차감 안 됐으므로)
|
||||
await deleteServerOrder(env.DB, order_id);
|
||||
// 최대 재시도 초과 - DB에 실패 상태 기록 후 주문 삭제 (잔액 차감 안 됐으므로)
|
||||
const errorMessage = error instanceof Error ? error.message : 'API 호출 실패';
|
||||
|
||||
// DB에 실패 상태 기록 (삭제 전)
|
||||
await updateOrderStatus(env.DB, order_id, 'failed', {
|
||||
error_message: `${errorMessage} (max retries: ${message.attempts})`
|
||||
});
|
||||
|
||||
// 관리자 알림 (Rate Limiting 적용)
|
||||
await notifyAdminSystem(
|
||||
'retry_exhausted',
|
||||
{
|
||||
service: 'Server Provisioning',
|
||||
error: errorMessage,
|
||||
context: `Order #${order_id} failed after ${message.attempts} attempts\nUser: ${telegram_user_id}\n\n주문 삭제됨 (잔액 차감 안 됨)`
|
||||
},
|
||||
{
|
||||
telegram: {
|
||||
sendMessage: (chatId: number, text: string) => sendMessage(env.BOT_TOKEN, chatId, text)
|
||||
},
|
||||
adminId: env.DEPOSIT_ADMIN_ID || '',
|
||||
env
|
||||
}
|
||||
);
|
||||
|
||||
// 사용자 알림
|
||||
await notifyUser(
|
||||
env.BOT_TOKEN,
|
||||
telegram_user_id,
|
||||
`❌ 서버 프로비저닝 실패\n\n사유: ${error instanceof Error ? error.message : 'API 호출 실패'}\n\n잔액 차감은 이루어지지 않았습니다.\n다시 시도해주세요.`
|
||||
`❌ 서버 프로비저닝 실패\n\n사유: ${errorMessage}\n\n잔액 차감은 이루어지지 않았습니다.\n다시 시도해주세요.`
|
||||
);
|
||||
|
||||
// 주문 삭제
|
||||
await deleteServerOrder(env.DB, order_id);
|
||||
|
||||
message.ack();
|
||||
logger.warn('최대 재시도 초과 - 주문 삭제', {
|
||||
orderId: order_id,
|
||||
attempts: message.attempts
|
||||
attempts: message.attempts,
|
||||
errorMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('프로비저닝 처리 중 예외 발생', error as Error, {
|
||||
orderId: message.body.order_id,
|
||||
userId: message.body.user_id,
|
||||
telegramUserId: message.body.telegram_user_id,
|
||||
attempts: message.attempts
|
||||
});
|
||||
|
||||
// 예상치 못한 에러 - 재시도
|
||||
if (message.attempts < 3) {
|
||||
message.retry();
|
||||
logger.info('예외 발생으로 재시도 예약', {
|
||||
orderId: message.body.order_id,
|
||||
nextAttempt: message.attempts + 1
|
||||
});
|
||||
} else {
|
||||
// DLQ로 이동
|
||||
// DLQ로 이동 (handleProvisionDLQ에서 관리자 알림 처리)
|
||||
message.ack();
|
||||
logger.warn('최대 재시도 초과 - DLQ로 이동', {
|
||||
orderId: message.body.order_id,
|
||||
attempts: message.attempts
|
||||
userId: message.body.user_id,
|
||||
telegramUserId: message.body.telegram_user_id,
|
||||
attempts: message.attempts,
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -520,34 +557,47 @@ export async function handleProvisionDLQ(
|
||||
});
|
||||
|
||||
for (const message of batch.messages) {
|
||||
try {
|
||||
const { order_id, user_id, telegram_user_id } = message.body;
|
||||
|
||||
try {
|
||||
logger.error('DLQ 메시지 처리', new Error('Provisioning failed permanently'), {
|
||||
orderId: order_id,
|
||||
userId: user_id,
|
||||
telegramUserId: telegram_user_id,
|
||||
attempts: message.attempts
|
||||
attempts: message.attempts,
|
||||
timestamp: message.body.timestamp,
|
||||
retryCount: message.body.retry_count
|
||||
});
|
||||
|
||||
// 1. DB에 실패 상태 기록
|
||||
// 1. 주문 정보 조회 (상세 에러 정보 수집)
|
||||
const order = await getServerOrder(env.DB, order_id);
|
||||
const errorContext = order
|
||||
? `Spec: ${order.spec_id}, Region: ${order.region}, Provider: ${order.provider}, Price: ${order.price_paid}`
|
||||
: `Order not found in DB`;
|
||||
|
||||
// 2. DB에 실패 상태 기록
|
||||
await updateOrderStatus(env.DB, order_id, 'failed', {
|
||||
error_message: 'Provisioning failed after maximum retries (moved to DLQ)'
|
||||
error_message: `Provisioning failed after ${message.attempts} attempts (moved to DLQ at ${new Date().toISOString()})`
|
||||
});
|
||||
|
||||
// 2. 관리자에게 알림
|
||||
const adminMessage = `🚨 서버 프로비저닝 영구 실패 (DLQ)
|
||||
// 3. 관리자에게 Rate Limiting이 적용된 알림 전송
|
||||
await notifyAdminSystem(
|
||||
'api_error',
|
||||
{
|
||||
service: 'Server Provisioning',
|
||||
error: `Order #${order_id} failed after ${message.attempts} retries`,
|
||||
context: `User: ${telegram_user_id}\n${errorContext}\nTimestamp: ${new Date(message.body.timestamp).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}\n\n수동 개입 필요 - 환불 및 사용자 안내 필요`
|
||||
},
|
||||
{
|
||||
telegram: {
|
||||
sendMessage: (chatId: number, text: string) => sendMessage(env.BOT_TOKEN, chatId, text)
|
||||
},
|
||||
adminId: env.DEPOSIT_ADMIN_ID || '',
|
||||
env
|
||||
}
|
||||
);
|
||||
|
||||
주문 ID: ${order_id}
|
||||
사용자 ID: ${user_id}
|
||||
Telegram ID: ${telegram_user_id}
|
||||
재시도 횟수: ${message.attempts}
|
||||
|
||||
수동 개입 필요 - 환불 및 사용자 안내 필요`;
|
||||
|
||||
await notifyAdmin(env.BOT_TOKEN, env.DEPOSIT_ADMIN_ID, adminMessage);
|
||||
|
||||
// 3. 사용자에게 안내
|
||||
// 4. 사용자에게 안내
|
||||
const userMessage = `❌ 서버 프로비저닝 실패
|
||||
|
||||
주문 #${order_id}
|
||||
@@ -560,12 +610,25 @@ Telegram ID: ${telegram_user_id}
|
||||
await notifyUser(env.BOT_TOKEN, telegram_user_id, userMessage);
|
||||
|
||||
message.ack();
|
||||
logger.info('DLQ 메시지 처리 완료', { orderId: order_id });
|
||||
} catch (error) {
|
||||
logger.error('DLQ 처리 중 오류', error as Error, {
|
||||
orderId: message.body.order_id
|
||||
orderId: order_id,
|
||||
userId: user_id,
|
||||
telegramUserId: telegram_user_id
|
||||
});
|
||||
|
||||
// DLQ 처리 실패는 심각한 문제이므로 수동 개입 필요
|
||||
// DLQ 처리 실패는 심각한 문제이므로 긴급 알림 (Rate Limiting 무시)
|
||||
try {
|
||||
await notifyAdmin(
|
||||
env.BOT_TOKEN,
|
||||
env.DEPOSIT_ADMIN_ID,
|
||||
`🚨 긴급: DLQ 처리 실패\n\n주문 #${order_id}\n사용자: ${telegram_user_id}\n\n수동 처리 필수!`
|
||||
);
|
||||
} catch (notifyError) {
|
||||
logger.error('긴급 알림 전송 실패', notifyError as Error);
|
||||
}
|
||||
|
||||
// 메시지는 ack()하여 무한 루프 방지
|
||||
message.ack();
|
||||
}
|
||||
|
||||
@@ -641,7 +641,7 @@ export interface ServerOrder {
|
||||
region: string;
|
||||
label?: string;
|
||||
price_paid: number;
|
||||
status: 'pending' | 'provisioning' | 'active' | 'terminated';
|
||||
status: 'pending' | 'provisioning' | 'active' | 'terminated' | 'failed';
|
||||
provider: 'linode' | 'vultr' | 'anvil';
|
||||
instance_id?: string;
|
||||
ip_address?: string;
|
||||
|
||||
Reference in New Issue
Block a user