feat: unify transient provider retries
This commit is contained in:
parent
2608c953ee
commit
5219bc4588
@ -50,6 +50,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
and avoids unnecessary duplicate assignments when distinct catalogue voices are available.
|
and avoids unnecessary duplicate assignments when distinct catalogue voices are available.
|
||||||
- **Attribution and Timeout Safety Coverage.** Added regressions preventing phantom narrator speakers, preserving source
|
- **Attribution and Timeout Safety Coverage.** Added regressions preventing phantom narrator speakers, preserving source
|
||||||
prose after malformed LLM JSON, clamping caller timeouts, and bounding upstream error text.
|
prose after malformed LLM JSON, clamping caller timeouts, and bounding upstream error text.
|
||||||
|
- **Unified Provider Retries.** TTS and LLM provider requests now retry bounded connection, timeout, and transient HTTP
|
||||||
|
failures with exponential backoff while closing discarded responses.
|
||||||
- **4 GiB Container Validation.** Verified the current image serves the application under a hard 4 GiB cgroup limit
|
- **4 GiB Container Validation.** Verified the current image serves the application under a hard 4 GiB cgroup limit
|
||||||
with no OOM kill; the probe recorded approximately 50.6 MiB idle memory and 0.09% CPU.
|
with no OOM kill; the probe recorded approximately 50.6 MiB idle memory and 0.09% CPU.
|
||||||
- **Profile Completeness Coverage.** Added browser regression coverage for empty, partial, and fully populated
|
- **Profile Completeness Coverage.** Added browser regression coverage for empty, partial, and fully populated
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@ -66,7 +66,7 @@ the first block before investing in architecture or polish.
|
|||||||
- [ ] **L — Add background jobs and bounded queues.** Image generation already has the full interactive job path,
|
- [ ] **L — Add background jobs and bounded queues.** Image generation already has the full interactive job path,
|
||||||
and transcription, voice design, and casting now have optional ID/status/cancel/timeout paths while preserving
|
and transcription, voice design, and casting now have optional ID/status/cancel/timeout paths while preserving
|
||||||
synchronous callers. Job metadata now persists in SQLite and marks interrupted work after restart; durable binary
|
synchronous callers. Job metadata now persists in SQLite and marks interrupted work after restart; durable binary
|
||||||
result storage and a unified retry policy still need to be added.
|
result storage remains; outbound TTS and LLM calls now share a bounded transient-failure retry policy.
|
||||||
- [ ] **L — Run the Book 2 generalization regression.** Added a neutral multi-speaker fixture and parser regression;
|
- [ ] **L — Run the Book 2 generalization regression.** Added a neutral multi-speaker fixture and parser regression;
|
||||||
a real Book 2 auto-cast/attribution output still needs to be captured and reviewed to confirm rules are not overfit
|
a real Book 2 auto-cast/attribution output still needs to be captured and reviewed to confirm rules are not overfit
|
||||||
to *Der Sturm*.
|
to *Der Sturm*.
|
||||||
|
|||||||
@ -18,6 +18,7 @@ from core.constants import (
|
|||||||
_KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _FISHSPEECH_DEFAULT,
|
_KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _FISHSPEECH_DEFAULT,
|
||||||
_TTS_STREAM_DEFAULT,
|
_TTS_STREAM_DEFAULT,
|
||||||
)
|
)
|
||||||
|
from core.retry import request_with_retries
|
||||||
from core.database import state_get, state_put, state_updated
|
from core.database import state_get, state_put, state_updated
|
||||||
|
|
||||||
# ── Settings key whitelist ────────────────────────────────────────────────────
|
# ── Settings key whitelist ────────────────────────────────────────────────────
|
||||||
@ -211,7 +212,7 @@ def _strip_tts_extra_params(payload: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwargs) -> requests.Response:
|
def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwargs) -> requests.Response:
|
||||||
resp = requests.post(endpoint, json=payload, headers=headers, **kwargs)
|
resp = request_with_retries("POST", endpoint, json=payload, headers=headers, **kwargs)
|
||||||
fallback = _strip_tts_extra_params(payload)
|
fallback = _strip_tts_extra_params(payload)
|
||||||
if resp.status_code in {400, 404, 415, 422} and fallback != payload:
|
if resp.status_code in {400, 404, 415, 422} and fallback != payload:
|
||||||
try:
|
try:
|
||||||
@ -220,7 +221,7 @@ def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwarg
|
|||||||
pass
|
pass
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger("uvicorn.error").warning("TTS backend rejected extra generation params; retrying without them")
|
logging.getLogger("uvicorn.error").warning("TTS backend rejected extra generation params; retrying without them")
|
||||||
return requests.post(endpoint, json=fallback, headers=headers, **kwargs)
|
return request_with_retries("POST", endpoint, json=fallback, headers=headers, **kwargs)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
40
core/retry.py
Normal file
40
core/retry.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""Small, bounded retry primitives shared by outbound provider requests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
_RETRYABLE_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
||||||
|
|
||||||
|
|
||||||
|
def retry_delay(attempt: int, *, base: float = 0.5, cap: float = 8.0) -> float:
|
||||||
|
"""Return bounded exponential backoff for the zero-based retry attempt."""
|
||||||
|
return min(float(cap), max(0.0, float(base)) * (2 ** max(0, int(attempt))))
|
||||||
|
|
||||||
|
|
||||||
|
def request_with_retries(method: str, url: str, *, attempts: int = 3,
|
||||||
|
sleep: Callable[[float], None] | None = None, **kwargs):
|
||||||
|
"""Make a request, retrying only transient transport/upstream failures.
|
||||||
|
|
||||||
|
The final response or exception is returned to the caller unchanged. Any
|
||||||
|
response discarded before a retry is closed so streaming sockets cannot
|
||||||
|
accumulate while a provider is cold or rate-limiting.
|
||||||
|
"""
|
||||||
|
attempts = max(1, min(int(attempts), 5))
|
||||||
|
sleep = sleep or time.sleep
|
||||||
|
last_response = None
|
||||||
|
for attempt in range(attempts):
|
||||||
|
try:
|
||||||
|
response = requests.request(method, url, **kwargs)
|
||||||
|
last_response = response
|
||||||
|
if response.status_code not in _RETRYABLE_STATUS_CODES or attempt >= attempts - 1:
|
||||||
|
return response
|
||||||
|
response.close()
|
||||||
|
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||||
|
if attempt >= attempts - 1:
|
||||||
|
raise
|
||||||
|
if attempt < attempts - 1:
|
||||||
|
sleep(retry_delay(attempt))
|
||||||
|
return last_response
|
||||||
@ -34,6 +34,7 @@ from core.voice import (
|
|||||||
)
|
)
|
||||||
from core.voice_index import rebuild_voice_index
|
from core.voice_index import rebuild_voice_index
|
||||||
from core.database import background_job_create, background_job_get, background_job_update
|
from core.database import background_job_create, background_job_get, background_job_update
|
||||||
|
from core.retry import request_with_retries
|
||||||
from core.tts_helpers import _preview_request_audio
|
from core.tts_helpers import _preview_request_audio
|
||||||
from routes.stt import _transcribe_audio, _clean_stt_backend
|
from routes.stt import _transcribe_audio, _clean_stt_backend
|
||||||
|
|
||||||
@ -340,7 +341,7 @@ def _post_llm_chat_completion(llm_url: str, payload: dict, headers: dict, timeou
|
|||||||
last_exc = None
|
last_exc = None
|
||||||
for url in _llm_chat_completion_urls(llm_url):
|
for url in _llm_chat_completion_urls(llm_url):
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, json=payload, headers=headers, timeout=timeout)
|
resp = request_with_retries("POST", url, json=payload, headers=headers, timeout=timeout, attempts=2)
|
||||||
last_resp = resp
|
last_resp = resp
|
||||||
if resp.status_code in (404, 405) and url != _llm_chat_completion_urls(llm_url)[-1]:
|
if resp.status_code in (404, 405) and url != _llm_chat_completion_urls(llm_url)[-1]:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -59,6 +59,25 @@ class SecurityValidationTests(unittest.TestCase):
|
|||||||
self.assertEqual(_transcription_text_from_response(ResponseStub({"segments": [{"text": " one "}, {"text": "two"}]})), "one two")
|
self.assertEqual(_transcription_text_from_response(ResponseStub({"segments": [{"text": " one "}, {"text": "two"}]})), "one two")
|
||||||
self.assertEqual(_transcription_text_from_response(ResponseStub(" plain text ")), "plain text")
|
self.assertEqual(_transcription_text_from_response(ResponseStub(" plain text ")), "plain text")
|
||||||
|
|
||||||
|
def test_provider_retry_policy_is_bounded_and_closes_discarded_responses(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
from core.retry import request_with_retries, retry_delay
|
||||||
|
class ResponseStub:
|
||||||
|
def __init__(self, status):
|
||||||
|
self.status_code, self.closed = status, False
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
first, second, final = ResponseStub(503), ResponseStub(429), ResponseStub(200)
|
||||||
|
with patch("core.retry.requests.request", side_effect=[first, second, final]) as request, \
|
||||||
|
patch("core.retry.time.sleep") as sleep:
|
||||||
|
result = request_with_retries("POST", "http://provider", attempts=3)
|
||||||
|
self.assertIs(result, final)
|
||||||
|
self.assertTrue(first.closed)
|
||||||
|
self.assertTrue(second.closed)
|
||||||
|
self.assertEqual(request.call_count, 3)
|
||||||
|
self.assertEqual(sleep.call_count, 2)
|
||||||
|
self.assertLessEqual(retry_delay(20), 8.0)
|
||||||
|
|
||||||
def test_request_size_middleware_rejects_oversized_json(self):
|
def test_request_size_middleware_rejects_oversized_json(self):
|
||||||
sent = []
|
sent = []
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user