feat(translation): pooled Interpreter session + manual sweep resumes after drain
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s

- interpreter_client: shared requests.Session with a connect-only retry
  (connect=2, no status retries — we map 429/5xx ourselves) so a proxy reload
  is smoothed and the keep-alive connection is pooled across the sweep.
- translate_posts: on an interrupt (drain), re-enqueue after the Retry-After
  hint / default backoff instead of waiting for the daily beat; self-terminates
  via the health gate. Steady-state one-chunk-per-run on success is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:10:05 -04:00
parent f8105046dc
commit 9eae636047
4 changed files with 72 additions and 18 deletions
+20 -2
View File
@@ -12,6 +12,8 @@ from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class InterpreterUnavailable(Exception):
@@ -57,13 +59,29 @@ def _parse_retry_after(resp) -> float | None:
return max(0.0, (when - datetime.now(UTC)).total_seconds())
# A shared session pools the keep-alive connection across the per-post sweep
# calls, and retries CONNECT failures only (connect=2, short backoff) — smoothing
# the instant a reverse proxy reloads. Status codes are deliberately NOT retried
# (status=0, raise_on_status=False): translate() maps 429/5xx → InterpreterUnavailable
# itself, and letting urllib3 retry a draining 503 would defeat the Retry-After
# backoff we honour upstream.
_retry = Retry(
total=None, connect=2, read=0, redirect=0, status=0,
backoff_factor=0.3, raise_on_status=False,
)
session = requests.Session()
_adapter = HTTPAdapter(max_retries=_retry)
session.mount("http://", _adapter)
session.mount("https://", _adapter)
def health(base_url: str, *, timeout: float = 5.0) -> bool:
"""True iff the Interpreter LLM engine is up. Any error (unset URL, network,
non-200, engine down) → False, so the sweep just no-ops rather than raising."""
if not base_url:
return False
try:
r = requests.get(_url(base_url, "/v1/health"), timeout=timeout)
r = session.get(_url(base_url, "/v1/health"), timeout=timeout)
except requests.RequestException:
return False
if r.status_code != 200:
@@ -94,7 +112,7 @@ def translate(
return {"translations": [], "detected_lang": None,
"engine": None, "engine_version": None}
try:
r = requests.post(
r = session.post(
_url(base_url, "/v1/translate"),
json={"q": list(texts), "source": source,
"target": target, "engine": "auto"},
+15 -4
View File
@@ -70,10 +70,21 @@ def translate_posts() -> str:
return ready
base_url, target = ready
posts = _select_untranslated(session, None, _MAX_POSTS_PER_RUN)
# Steady-state daily sweep: one chunk per run. On interruption the
# untranslated rows stay NULL and the next daily beat (or a fresh
# "Translate now") resumes them — no self-chase needed here.
status, translated, _retry = _translate_batch(session, posts, base_url, target)
status, translated, retry_after = _translate_batch(
session, posts, base_url, target,
)
# Steady-state daily sweep: one chunk per run on success (the beat drives
# the rest — no tail-chase). But if Interpreter drained mid-chunk, don't
# make a manual "Translate now" wait a whole day — re-enqueue after its
# Retry-After hint (or the default backoff). Self-terminating: once the
# service is down the health gate returns "interpreter unavailable" early.
if status == "interrupted" and _count_untranslated(session, None):
delay = _interrupt_backoff(retry_after)
translate_posts.apply_async((), {}, countdown=delay)
log.info(
"translate_posts: interrupted (service draining) → retry in %ss",
delay,
)
return _summary(status, translated, len(posts))