원인 대부분이 **Ollama HTTP 응답(429/5xx/타임아웃/빈 응답/비JSON)**에서 생깁니다. 지금 코드에서도 재시도는 있지만,
- 재시도 범위·백오프가 약하고, 2) 429/5xx/네트워크 에러를 구분하지 못하며, 3) 연속 실패 시 “브레이커(circuit breaker)”가 없어 계속 두드리는 문제가 있어요.
아래처럼 딱 5곳에 방어 로직을 넣으면 안정성이 확 올라갑니다.
1) __init__에 requests.Session + 자동 재시도 어댑터
- 위치:
MultilingualDictionaryBuilder.__init__끝부분 (현재self._ollama_healthcheck()아래 아무데나) - 이유: TCP 커넥션 재활용, 지터 포함 재시도, 429/5xx 자동 백오프
import random
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# __init__ 안에 추가
self.http = requests.Session()
retries = Retry(
total=5,
connect=5,
read=5,
backoff_factor=0.5, # 0.5, 1.0, 2.0 ... 지터는 ask_ollama에서 추가
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["POST"]
)
self.http.mount("http://", HTTPAdapter(max_retries=retries))
self.http.mount("https://", HTTPAdapter(max_retries=retries))
# 서킷 브레이커 상태값
self._fail_count = 0
self._breaker_open_until = 0.0
self._breaker_threshold = 8 # 연속 8회 실패 시 열기
self._breaker_cooldown = 60.0 # 60초 냉각
2) ask_ollama()를 “등급별 재시도 + 지터 + 브레이커”로 교체
- 위치: 기존
ask_ollama전면 교체 - 이유: 4xx(클라 오류) 즉시 실패, 429/5xx는 지수백오프, 비JSON/빈 응답 처리, 연속 실패 시 쿨다운
def ask_ollama(self, prompt: str, timeout=45, retries=3, temperature: float = 0.2) -> str:
if self.shutdown_requested:
return ""
# breaker open?
now = time.time()
if now < self._breaker_open_until:
log(f"Ollama breaker open, skip request for {int(self._breaker_open_until - now)}s", "WARN")
return ""
self._take_token()
payload = {
"model": self.model,
"prompt": prompt,
"stream": False,
"options": {"temperature": temperature, "top_p": 0.9, "repeat_penalty": 1.1},
}
base_sleep = 1.0
for attempt in range(1, retries + 1):
try:
r = self.http.post(self.base_url, json=payload, timeout=timeout)
status = r.status_code
if 200 <= status < 300:
# 비JSON/빈 응답 방어
try:
data = r.json()
except Exception:
self._fail_count += 1
log("Ollama returned non-JSON body", "WARN")
raise requests.exceptions.RetryError("non-json")
# Ollama 에러 필드 방어
if isinstance(data, dict) and data.get("error"):
err = data.get("error")
self._fail_count += 1
log(f"Ollama error field: {err}", "WARN")
raise requests.exceptions.RetryError(str(err))
resp = data.get("response", "")
if not resp or not resp.strip():
self._fail_count += 1
log("Ollama empty response", "WARN")
raise requests.exceptions.RetryError("empty-response")
# 성공 경로
self._fail_count = 0
return resp
# 상태코드별 처리
if status == 429 or status in (500, 502, 503, 504):
# 재시도 대상: 지수백오프 + 지터
sleep = base_sleep * (2 ** (attempt - 1))
sleep += random.uniform(0, 0.5)
log(f"Ollama HTTP {status} attempt {attempt}/{retries}, sleep {sleep:.1f}s", "WARN")
time.sleep(sleep)
continue
elif 400 <= status < 500:
# 클라이언트 오류는 즉시 중단
self._fail_count += 1
log(f"Ollama client error {status}: {r.text[:200]}", "WARN")
return ""
except requests.exceptions.Timeout:
self._fail_count += 1
sleep = base_sleep * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
log(f"Ollama timeout {attempt}/{retries}, sleep {sleep:.1f}s", "WARN")
time.sleep(sleep)
except requests.exceptions.ConnectionError as e:
self._fail_count += 1
sleep = base_sleep * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
log(f"Ollama connection error {attempt}/{retries}: {e}, sleep {sleep:.1f}s", "WARN")
time.sleep(sleep)
except Exception as e:
self._fail_count += 1
log(f"Ollama unexpected error {attempt}/{retries}: {e}", "WARN")
time.sleep(0.5)
# 모든 재시도 실패 → 브레이커 open
if self._fail_count >= self._breaker_threshold:
self._breaker_open_until = time.time() + self._breaker_cooldown
log(f"Opening breaker for {int(self._breaker_cooldown)}s (fail_count={self._fail_count})", "WARN")
return ""
3) JSON 파싱·스키마 방어 (llm_validate_and_translate/_extract_json)
- 위치:
llm_validate_and_translate초반 파싱부 - 이유: 모델이 코드블록/설명 텍스트를 섞어 보내는 경우가 많아 “첫 번째 {…}”만 잡으면 실패할 수 있음. 안전 가드 추가.
def _extract_json(self, text: str) -> str:
if not text:
return "{}"
# 코드블록 제거
text = re.sub(r"^```(?:json)?\s*|```$", "", text.strip(), flags=re.MULTILINE)
# 가장 긴 중괄호 블록 탐색
candidates = re.findall(r"\{.*?\}", text, flags=re.S)
if not candidates:
return "{}"
# 길이가 가장 긴 블록을 선택 (부분 JSON 회피)
return max(candidates, key=len)
그리고 llm_validate_and_translate에서 파싱 실패 시 이유 태그를 더 구체화:
resp = self.ask_ollama(prompt, timeout=40, temperature=temperature)
if not resp:
return {"accept": False, "reasons": ["ollama_no_response"], "confidence": 0.0, "rarity": 5}
try:
j = json.loads(self._extract_json(resp))
except Exception:
return {"accept": False, "reasons": ["invalid_json_parse"], "confidence": 0.0, "rarity": 5}
4) 번역 경로 폴백 (translate_content)
- 위치:
translate_content - 이유: 번역 호출도 LLM 의존이라 실패 시 빈 문자열 반환 → 후단에서 탈락 확률↑. 실패하면 원문 그대로라도 채워 저장(나중에 일괄 번역 가능).
t = self.ask_ollama(prompt, timeout=30, temperature=0.2).strip()
if not t:
# 폴백: 원문 유지 + 태그
return f"[UNTRANSLATED:{self.target_language}] {english_text}"
return t
5) 실행 루프 방어 (run_prefix / run)
- 위치:
run_prefix단어 처리 루프 &run의 prefix 루프 - 이유: 브레이커가 열렸거나 연속 실패가 많은 경우 즉시 저장 후 빠져나오기. 진전 없는 무한 재시도 방지.
# run_prefix 내부, for w in cands: 바로 아래
if time.time() < self._breaker_open_until:
log("Breaker open during run_prefix, early exit & save", "WARN")
break
# run 내부, prefix 처리 루프 안쪽 try 블록 직후나 except 직후
if time.time() < self._breaker_open_until:
payload = self._make_payload(entries, mode, batch, done, failed, start)
self.save_progress(payload)
log("Breaker open in run(), progress saved, pausing current cycle", "WARN")
# 현재 prefix는 failed 로 남기고 빠져나감
break
추가 팁 (코드 바깥 설정/운용)
- 타임아웃 분리:
connect=5s / read=40s같이 분리하고 싶다면self.http.post(..., timeout=(5, timeout))형태 사용. - 모델별 rate-limit:
--rate값을 모델·GPU 여유에 맞춰 60~120/min으로 조절. - 프롬프트 길이 제한: 프롬프트가 길어질수록 5xx 빈도가 올라갑니다. 설명문·규칙을 짧게 유지.
- 진행 저장 주기:
--save-every를 5~10으로(이미 기본 10) 유지하면 장애 시 손실 최소화.
요약: 어디에 무엇을 넣나?
__init__:requests.Session+ Retry 어댑터, 브레이커 상태값ask_ollama: 4xx/5xx/429/타임아웃 구분, 지수백오프+지터, 빈/비JSON 응답 처리, 서킷 브레이커_extract_json&llm_validate_and_translate: 코드블록 제거·가장 긴{…}파싱, 실패 사유 태깅translate_content: 실패 시 원문 폴백 태그run_prefix/run: 브레이커 열린 상태에서 즉시 저장·탈출
이렇게만 넣어도 “어느 순간부터 계속 에러” 구간에서 무한 재시도→쿨다운→진행 저장의 안전 루틴으로 바뀌어서 중단 없이 다시 이어갈 수 있습니다.
답글 남기기