Skip to content

Commit cab02ac

Browse files
committed
fix(429): humanize unix reset times; fail-fast hard usage limits
1 parent 5ac1958 commit cab02ac

5 files changed

Lines changed: 101 additions & 19 deletions

File tree

src/pythinker_code/soul/pythinkersoul.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,25 @@ def classify_api_error(e: Exception) -> tuple[str, int | None]:
199199
return "other", None
200200

201201

202+
def _is_hard_usage_limit(exception: BaseException) -> bool:
203+
"""Whether a 429 is a subscription usage cap (resets in hours) rather than a
204+
transient RPM/TPM burst (clears in seconds).
205+
206+
Hard caps — e.g. ChatGPT Codex ``usage_limit_reached`` — should NOT be retried:
207+
the backoff just delays the inevitable failure. Detected from the parsed body
208+
when present, else from the stringified message (the streaming 429 often
209+
carries only the bare text)."""
210+
body = getattr(exception, "body", None)
211+
if isinstance(body, dict):
212+
err = cast(dict[str, object], body).get("error")
213+
if isinstance(err, dict):
214+
err_type = cast(dict[str, object], err).get("type")
215+
if str(err_type or "") == "usage_limit_reached":
216+
return True
217+
text = str(exception).lower()
218+
return "usage_limit_reached" in text or "usage limit" in text
219+
220+
202221
type StepStopReason = Literal["no_tool_calls", "tool_rejected", "stuck"]
203222

204223

@@ -2087,7 +2106,14 @@ def _is_retryable_error(exception: BaseException) -> bool:
20872106
return not bool(getattr(exception, "_pythinker_recovery_exhausted", False))
20882107
if isinstance(exception, APIEmptyResponseError):
20892108
return True
2090-
return isinstance(exception, APIStatusError) and exception.status_code in (
2109+
if not isinstance(exception, APIStatusError):
2110+
return False
2111+
if exception.status_code == 429 and _is_hard_usage_limit(exception):
2112+
# A subscription usage cap (e.g. ChatGPT Codex `usage_limit_reached`)
2113+
# resets in hours, not seconds — retrying with backoff only adds
2114+
# latency before the inevitable failure. Surface it immediately.
2115+
return False
2116+
return exception.status_code in (
20912117
429, # Too Many Requests
20922118
500, # Internal Server Error
20932119
502, # Bad Gateway

src/pythinker_code/ui/shell/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ async def _codex_usage_windows(soul: Soul) -> list[str]:
510510

511511
report = await asyncio.wait_for(
512512
OpenAIChatGPTAdapter().fetch(provider, runtime.oauth),
513-
timeout=6.0,
513+
timeout=3.0,
514514
)
515515
except Exception:
516516
logger.debug("Codex usage lookup for 429 message failed", exc_info=True)

src/pythinker_code/ui/shell/usage_adapters/openai_chatgpt.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -207,25 +207,44 @@ def _label_for_codex_window(seconds: int) -> str:
207207

208208

209209
def _codex_reset_hint(win_map: Mapping[str, Any]) -> str | None:
210-
# Current shape: `resets_at` is a unix-seconds timestamp.
211-
# Older shape: `reset_at` is an ISO-8601 string.
212-
resets_at_unix = win_map.get("resets_at")
213-
if isinstance(resets_at_unix, int | float):
210+
# The reset time arrives under `resets_at` or `reset_at`, and as a
211+
# unix-seconds timestamp (number or numeric string) or an ISO-8601 string.
212+
# Always humanize it ("resets in 2h 14m") rather than printing a raw value.
213+
for key in ("resets_at", "reset_at"):
214+
raw = win_map.get(key)
215+
if raw is None:
216+
continue
217+
dt = _coerce_reset_datetime(raw)
218+
if dt is not None:
219+
return _format_reset_delta(dt, win_map)
220+
if isinstance(raw, str) and raw.strip():
221+
return f"resets at {raw.strip()}"
222+
return None
223+
224+
225+
def _coerce_reset_datetime(raw: object) -> datetime | None:
226+
"""Parse a reset timestamp that may be unix seconds (number or numeric
227+
string) or an ISO-8601 string."""
228+
if isinstance(raw, bool):
229+
return None
230+
if isinstance(raw, int | float):
214231
try:
215-
dt = datetime.fromtimestamp(float(resets_at_unix), tz=UTC)
232+
return datetime.fromtimestamp(float(raw), tz=UTC)
216233
except (OverflowError, OSError, ValueError):
217234
return None
218-
return _format_reset_delta(dt, win_map)
219-
220-
reset_at = win_map.get("reset_at")
221-
if reset_at is None:
222-
return None
223-
reset_at_str = str(reset_at)
224-
try:
225-
dt = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00"))
226-
except (TypeError, ValueError):
227-
return f"resets at {reset_at_str}"
228-
return _format_reset_delta(dt, win_map)
235+
if isinstance(raw, str):
236+
candidate = raw.strip()
237+
if not candidate:
238+
return None
239+
try: # numeric string -> unix seconds
240+
return datetime.fromtimestamp(float(candidate), tz=UTC)
241+
except (OverflowError, OSError, ValueError):
242+
pass
243+
try: # ISO-8601
244+
return datetime.fromisoformat(candidate.replace("Z", "+00:00"))
245+
except (TypeError, ValueError):
246+
return None
247+
return None
229248

230249

231250
def _format_reset_delta(dt: datetime, win_map: Mapping[str, Any]) -> str:

tests/core/test_pythinkersoul_think_only.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from __future__ import annotations
1010

1111
import pytest
12-
from pythinker_core.chat_provider import APIEmptyResponseError
12+
from pythinker_core.chat_provider import APIEmptyResponseError, APIStatusError
1313

1414
from pythinker_code.soul.pythinkersoul import PythinkerSoul
1515

@@ -18,3 +18,21 @@
1818
async def test_think_only_error_is_retryable() -> None:
1919
"""APIEmptyResponseError from think-only responses should be retryable."""
2020
assert PythinkerSoul._is_retryable_error(APIEmptyResponseError("only thinking content"))
21+
22+
23+
def test_hard_usage_limit_429_is_not_retryable() -> None:
24+
"""A subscription usage cap (resets in hours) must NOT be retried — retrying
25+
only adds backoff latency before the inevitable failure. Covers both the bare
26+
streaming text and the structured-body shape."""
27+
bare = APIStatusError(429, "Usage limit reached", body=None)
28+
structured = APIStatusError(
29+
429, "Error code: 429", body={"error": {"type": "usage_limit_reached"}}
30+
)
31+
assert PythinkerSoul._is_retryable_error(bare) is False
32+
assert PythinkerSoul._is_retryable_error(structured) is False
33+
34+
35+
def test_transient_429_and_5xx_remain_retryable() -> None:
36+
"""A transient RPM/TPM burst (clears in seconds) and server 5xx still retry."""
37+
assert PythinkerSoul._is_retryable_error(APIStatusError(429, "rate limit exceeded")) is True
38+
assert PythinkerSoul._is_retryable_error(APIStatusError(503, "service unavailable")) is True

tests/ui/usage_adapters/test_openai_chatgpt.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ def test_parse_codex_usage_two_windows() -> None:
2929
assert report.limits[0].unit == "%"
3030

3131

32+
def test_parse_codex_usage_humanizes_unix_reset_at() -> None:
33+
"""The live wham/usage payload sends `reset_at` as a unix timestamp number;
34+
it must be humanized ("resets in …"), not dumped as a raw integer."""
35+
payload = {
36+
"rate_limit": {
37+
"primary_window": {
38+
"percent_left": 99,
39+
"limit_window_seconds": 18000,
40+
"reset_at": 4102444800, # far-future unix seconds
41+
},
42+
}
43+
}
44+
report = parse_codex_usage_payload(payload)
45+
assert report.summary is not None
46+
hint = report.summary.reset_hint or ""
47+
assert "resets in" in hint
48+
assert "4102444800" not in hint # raw timestamp must not leak
49+
50+
3251
def test_parse_codex_usage_handles_alternative_keys() -> None:
3352
payload = {
3453
"rate_limits": {

0 commit comments

Comments
 (0)