Skip to content

Commit 351e073

Browse files
committed
fix: address PR review comments - retry logic, Telegram ok:false handling, and cleanup
- _retry_transient: only retry HTTPError on 5xx and 429 status codes; permanent 4xx responses now re-raise immediately instead of burning retries on errors that won't recover. - _edit_status / _answer_callback / _edit_message_remove_keyboard: use new _check_telegram_ok helper that raises _TelegramAPIError when Telegram returns HTTP 200 with {ok: false}, so application-level failures surface and stop being silently swallowed. - _edit_status: tighten retries=1/backoff=0.5 (was the default 2/1.0) so the dispatch hot path can't block ~17s waiting on a flaky edit. - tests: drop unused 'import time as _time'; expand _retry_transient coverage for 5xx/429 retry + 4xx no-retry; add coverage for _check_telegram_ok on ok:true and ok:false bodies.
1 parent 939da9a commit 351e073

2 files changed

Lines changed: 119 additions & 13 deletions

File tree

src/telegram_bot.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -604,17 +604,52 @@ def _send_status(cfg: Config, text: str) -> int | None:
604604
return None
605605

606606

607+
class _TelegramAPIError(Exception):
608+
"""Telegram returned HTTP 200 with ok:false (application-level error)."""
609+
610+
611+
def _check_telegram_ok(r: requests.Response) -> None:
612+
"""Raise on transport errors AND on Telegram ok=false bodies.
613+
614+
Telegram returns HTTP 200 with ``{ok: false, error_code, description}``
615+
for application-level failures (e.g. "message to edit not found"), so
616+
``raise_for_status()`` alone leaves those silent.
617+
"""
618+
r.raise_for_status()
619+
try:
620+
body = r.json()
621+
except ValueError:
622+
return
623+
if isinstance(body, dict) and body.get("ok") is False:
624+
raise _TelegramAPIError(
625+
f"Telegram ok=false: {body.get('error_code')} {body.get('description')}"
626+
)
627+
628+
607629
def _retry_transient(fn, *, retries: int = 2, backoff: float = 1.0):
608-
"""Call *fn*; retry up to *retries* times on transient network errors."""
630+
"""Call *fn*; retry up to *retries* times on transient network errors.
631+
632+
HTTPError is only retried for 5xx and 429; other 4xx responses are
633+
permanent client errors and are re-raised immediately.
634+
"""
609635
for attempt in range(1 + retries):
610636
try:
611637
return fn()
612-
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, requests.exceptions.HTTPError) as e:
638+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
613639
if attempt < retries:
614640
log.warning("Transient Telegram error (attempt %d/%d): %s", attempt + 1, retries + 1, e)
615641
time.sleep(backoff)
616642
else:
617643
raise
644+
except requests.exceptions.HTTPError as e:
645+
status = getattr(e.response, "status_code", None)
646+
if status is None or (status < 500 and status != 429):
647+
raise
648+
if attempt < retries:
649+
log.warning("Transient Telegram HTTP %s (attempt %d/%d): %s", status, attempt + 1, retries + 1, e)
650+
time.sleep(backoff)
651+
else:
652+
raise
618653

619654

620655
def _edit_status(cfg: Config, msg_id: int, text: str) -> None:
@@ -626,8 +661,10 @@ def _do():
626661
json={"chat_id": cfg.telegram_chat_id, "message_id": msg_id, "text": text},
627662
timeout=5,
628663
)
629-
r.raise_for_status()
630-
_retry_transient(_do)
664+
_check_telegram_ok(r)
665+
# Tight bounds: this runs on the dispatch hot path, so cap total
666+
# latency well below the 30s long-poll cadence.
667+
_retry_transient(_do, retries=1, backoff=0.5)
631668
except Exception as e: # noqa: BLE001
632669
log.warning("edit_status failed (non-fatal): %s", e)
633670

@@ -702,7 +739,7 @@ def _do():
702739
json={"callback_query_id": callback_id, "text": text},
703740
timeout=10,
704741
)
705-
r.raise_for_status()
742+
_check_telegram_ok(r)
706743
_retry_transient(_do, retries=1)
707744
except Exception as e: # noqa: BLE001
708745
log.error("answerCallbackQuery failed: %s", e)
@@ -732,7 +769,7 @@ def _do():
732769
},
733770
timeout=10,
734771
)
735-
r.raise_for_status()
772+
_check_telegram_ok(r)
736773
_retry_transient(_do)
737774
except Exception as e: # noqa: BLE001
738775
log.error("editMessageText failed: %s", e)

tests/test_telegram_bot.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -824,7 +824,6 @@ def test_retry_transient_retries_on_timeout(monkeypatch):
824824

825825
def test_retry_transient_retries_on_connection_error(monkeypatch):
826826
"""_retry_transient retries on ConnectionError."""
827-
import time as _time
828827
from unittest.mock import MagicMock
829828
from src.telegram_bot import _retry_transient
830829

@@ -857,18 +856,88 @@ def test_retry_transient_raises_after_exhaustion(monkeypatch):
857856
assert fn.call_count == 2
858857

859858

860-
def test_retry_transient_retries_on_http_error(monkeypatch):
861-
"""_retry_transient retries on HTTPError from raise_for_status()."""
859+
def _http_error(status: int) -> requests.exceptions.HTTPError:
860+
"""Build an HTTPError with a response carrying the given status code."""
861+
resp = requests.Response()
862+
resp.status_code = status
863+
return requests.exceptions.HTTPError(f"{status} error", response=resp)
864+
865+
866+
def test_retry_transient_retries_on_5xx(monkeypatch):
867+
"""_retry_transient retries when the HTTPError carries a 5xx status."""
862868
from unittest.mock import MagicMock
863869
from src.telegram_bot import _retry_transient
864870

865-
fn = MagicMock(side_effect=[
866-
requests.exceptions.HTTPError("500 Server Error"),
867-
"ok",
868-
])
871+
fn = MagicMock(side_effect=[_http_error(500), "ok"])
872+
import src.telegram_bot as _tb
873+
monkeypatch.setattr(_tb.time, "sleep", MagicMock())
874+
875+
result = _retry_transient(fn, retries=1)
876+
assert result == "ok"
877+
assert fn.call_count == 2
878+
879+
880+
def test_retry_transient_retries_on_429(monkeypatch):
881+
"""Rate-limit responses are transient — retry them."""
882+
from unittest.mock import MagicMock
883+
from src.telegram_bot import _retry_transient
884+
885+
fn = MagicMock(side_effect=[_http_error(429), "ok"])
869886
import src.telegram_bot as _tb
870887
monkeypatch.setattr(_tb.time, "sleep", MagicMock())
871888

872889
result = _retry_transient(fn, retries=1)
873890
assert result == "ok"
874891
assert fn.call_count == 2
892+
893+
894+
def test_retry_transient_does_not_retry_4xx(monkeypatch):
895+
"""4xx client errors (except 429) are permanent — re-raise immediately."""
896+
from unittest.mock import MagicMock
897+
from src.telegram_bot import _retry_transient
898+
899+
fn = MagicMock(side_effect=_http_error(400))
900+
import src.telegram_bot as _tb
901+
sleep_mock = MagicMock()
902+
monkeypatch.setattr(_tb.time, "sleep", sleep_mock)
903+
904+
with pytest.raises(requests.exceptions.HTTPError):
905+
_retry_transient(fn, retries=2)
906+
# No retries attempted, no sleep.
907+
assert fn.call_count == 1
908+
sleep_mock.assert_not_called()
909+
910+
911+
def test_retry_transient_does_not_retry_404(monkeypatch):
912+
from unittest.mock import MagicMock
913+
from src.telegram_bot import _retry_transient
914+
915+
fn = MagicMock(side_effect=_http_error(404))
916+
import src.telegram_bot as _tb
917+
monkeypatch.setattr(_tb.time, "sleep", MagicMock())
918+
919+
with pytest.raises(requests.exceptions.HTTPError):
920+
_retry_transient(fn, retries=2)
921+
assert fn.call_count == 1
922+
923+
924+
def test_check_telegram_ok_raises_on_ok_false():
925+
"""Telegram returning HTTP 200 with ok:false must raise so the failure
926+
surfaces — raise_for_status alone doesn't catch this case."""
927+
from src.telegram_bot import _check_telegram_ok, _TelegramAPIError
928+
929+
r = requests.Response()
930+
r.status_code = 200
931+
r._content = b'{"ok": false, "error_code": 400, "description": "message to edit not found"}'
932+
with pytest.raises(_TelegramAPIError, match="message to edit not found"):
933+
_check_telegram_ok(r)
934+
935+
936+
def test_check_telegram_ok_passes_on_ok_true():
937+
from src.telegram_bot import _check_telegram_ok
938+
939+
r = requests.Response()
940+
r.status_code = 200
941+
r._content = b'{"ok": true, "result": {}}'
942+
# No exception.
943+
_check_telegram_ok(r)

0 commit comments

Comments
 (0)