Ctrl K
ComunidadGuías…

tests/test_llm_retry.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""暫時性錯誤要重試,永久性錯誤要立刻失敗。

一次消化要連續呼叫模型上百次,所以整批的可靠度等於最脆弱的那一次呼叫。
2026-08-30 有一批 105 次呼叫的重跑,死在第 4 次的一個 503,後面九十幾次
本來會成功的呼叫全都沒有發生。

反過來也要成立:金鑰錯了、模型名稱不存在,重試五次只是把一秒的失敗拖成
兩分鐘,還讓真正的原因埋在等待裡。
"""
from __future__ import annotations

import io
import sys
import unittest
import urllib.error
from pathlib import Path
from unittest import mock

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))

import _llm


def http_error(code: int) -> urllib.error.HTTPError:
    return urllib.error.HTTPError(
        "http://example.invalid", code, "boom", {}, io.BytesIO(b"{}")
    )


class _Response(io.BytesIO):
    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()
        return False


def ok() -> _Response:
    return _Response(b'{"choices": [{"message": {"content": "fine"}}]}')


class LlmRetryTest(unittest.TestCase):
    def setUp(self) -> None:
        # 只換掉 urlopen。整個 urllib 換成 Mock 的話,模組裡的
        # urllib.error.HTTPError 也會變成 Mock 屬性,except 子句就再也
        # 攔不到任何東西了。
        sleep = mock.patch("time.sleep")  # 不要真的等
        sleep.start()
        self.addCleanup(sleep.stop)

    def run_with(self, side_effect):
        with mock.patch.object(_llm.urllib.request, "urlopen",
                               side_effect=side_effect) as opener:
            try:
                result = _llm._post_with_retry(mock.Mock(), timeout=1)
            except Exception as err:  # noqa: BLE001 — 測試要檢查它拋什麼
                return opener.call_count, err
            return opener.call_count, result

    def test_a_transient_failure_is_waited_out(self) -> None:
        calls, result = self.run_with([http_error(503), ok()])
        self.assertEqual(calls, 2)
        self.assertEqual(result["choices"][0]["message"]["content"], "fine")

    def test_rate_limiting_is_waited_out_too(self) -> None:
        calls, result = self.run_with([http_error(429), http_error(429), ok()])
        self.assertEqual(calls, 3)
        self.assertIn("choices", result)

    def test_a_dropped_connection_is_retried(self) -> None:
        calls, result = self.run_with([urllib.error.URLError("reset"), ok()])
        self.assertEqual(calls, 2)
        self.assertIn("choices", result)

    def test_a_rejected_key_fails_at_once(self) -> None:
        # 重試五次只會把一秒的失敗拖成兩分鐘,而且把原因埋起來。
        calls, err = self.run_with([http_error(401)] * 5)
        self.assertEqual(calls, 1)
        self.assertIsInstance(err, RuntimeError)

    def test_an_unknown_model_fails_at_once(self) -> None:
        calls, err = self.run_with([http_error(400)] * 5)
        self.assertEqual(calls, 1)
        self.assertIsInstance(err, RuntimeError)

    def test_it_gives_up_eventually(self) -> None:
        calls, err = self.run_with([http_error(503)] * _llm.MAX_ATTEMPTS)
        self.assertEqual(calls, _llm.MAX_ATTEMPTS)
        self.assertIsInstance(err, RuntimeError)

    def test_interactive_direct_calls_do_not_retry_transient_errors(self):
        for url in ('https://example.invalid/v1', 'https://example.invalid/anthropic',
                    'https://generativelanguage.googleapis.com'):
            with self.subTest(url=url), \
                 mock.patch.object(_llm, '_runtime_settings', return_value={'LLM_API_URL': url, 'LLM_API_KEY': 'fixture'}), \
                 mock.patch.object(_llm.urllib.request, 'urlopen', side_effect=http_error(503)) as call, \
                 self.assertRaises(RuntimeError):
                try:
                    _llm._direct_api_call('s', 'u', model='fixture-model', attempts=1, timeout=1, max_tokens=32)
                finally:
                    self.assertEqual(call.call_count, 1)


if __name__ == "__main__":
    unittest.main()