부제: 에러 메시지가 진짜 원인을 가리고 있을 때

LLM 호출이 가끔 실패하는데, 로그에는 이런 메시지만 남았다.
'ClientResponse' object is not subscriptable
이 메시지로는 아무것도 알 수 없었다. 쿼터가 초과된 건지(429), 서버가 죽은 건지(503), 타임아웃인지(504), 모델 글리치인지 판별이 안 됐다. 진짜 원인이 이 메시지 뒤에 가려져 있었다.
증상
추적해 보니 SDK 안에서 일어난 일이었다.
google-genai의 응답 객체에는 본문을 파싱하는 json 프로퍼티가 있는데, 스트리밍 경로에서 내부 값이 리스트가 아닐 때 [0] 인덱싱을 시도하다 TypeError를 던졌다. 문제는 이 TypeError가 터지는 위치다.
상위 라이브러리(langchain)는 에러 응답을 해석하려고 그 json을 읽는데, 바로 거기서 TypeError가 나 버린다. 그래서 원래 전달돼야 할 APIError(code·status·message 같은 진짜 정보)는 이 엉뚱한 TypeError에 덮여서 호출자까지 오지 못했다.
즉, 에러를 해석하려는 코드가 에러 때문에 또 죽으면서, 정작 원인 코드를 잃어버린 상황이었다.
정리한 방식
두 군데를 손봤다.
1. SDK의 문제 지점을 기동 시 패치 리스트가 아닌 경우엔 인덱싱을 시도하지 말고 None을 돌려주도록 그 프로퍼티를 갈아끼웠다. 그러면 상위 라이브러리의 try/except가 정상적으로 fallback을 타고, 원본 APIError가 호출자까지 그대로 전파된다. 서버가 켜질 때 한 번 적용하고, 로그로 적용 여부를 남겼다.
# startup 시 1회 적용 (의사 코드)
def apply_sdk_patches():
# 응답 본문이 list가 아니면 [0] 인덱싱 대신 None 반환
HttpResponse.json = _safe_json
logger.info("SDK patches applied")
2. 잡은 김에 진짜 에러를 구조화해서 로깅 LLM 호출을 감싼 자리에서 APIError를 잡아 code·status·message·details를 남기고 다시 던졌다(re-raise). 다음에 같은 일이 생기면 status code가 바로 로그에 보인다.
try:
response = await llm.ainvoke(messages, config=config)
except Exception as e:
_log_llm_api_error(e, attempt=attempt) # code/status/message 구조화 기록
raise
결과
APIError(429, RESOURCE_EXHAUSTED)를 주입해서 확인했다. 이제 로그에 이렇게 찍힌다.
Gemini APIError surfaced code=429 status=RESOURCE_EXHAUSTED
가짜 TypeError 대신 진짜 status code가 보이고, 호출자까지 정상적으로 전파된다.
정리
- 에러를 해석하는 경로 자체가 또 다른 에러를 던지면, 원인이 통째로 가려질 수 있다
- 라이브러리 버그라도, 그 지점만 기동 시 패치해서 우회할 수 있다
- 잡은 에러는 그냥 삼키지 말고 code·status를 구조화해 남기고 다시 던진다
- "원인 불명"으로 보이는 에러는 메시지를 의심하기 전에, 그 메시지가 어디서 만들어졌는지부터 본다
에러 메시지를 그대로 믿지 않고 어디서 만들어졌는지 따라가 보니, 원인은 처음부터 거기 있었다.
'개발 > AI' 카테고리의 다른 글
| [메모리] 문맥 전환 감지 임베딩 유사도 한계 매턴 검색 회귀 (0) | 2026.06.25 |
|---|---|
| [Gemini] thought signature 보존 환각 도구 호출 방지 (0) | 2026.06.25 |
| [Prompt] OOC, Temporal Blindness (0) | 2026.06.16 |
| [BM25] 사용 예제 (3) | 2024.11.05 |
| [KoBERT] ChromaDB 사용 예제 (0) | 2024.11.05 |