Files
python-sdk/tests/test_retry.py
T
zeekayandHanzo Dev a2652a6979 Stop a rate-limited call from sleeping for hours
urllib3 sleeps whatever Retry-After says, clamped at 6 h in 2.7, and retries three times. api.hanzo.ai sends ~23,000 s for an exhausted quota, so one GET held its caller for 18 h. Client sleeps through at most 60 s; past that the answer returns at once, as ApiException with the header for generated operations and as Fault.retry_after for the six.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-11 13:44:15 -07:00

108 lines
4.0 KiB
Python

"""A server that asks for a long wait gets an answer back, not a sleeping caller.
api.hanzo.ai answers an exhausted quota with 429 and Retry-After set to the
seconds until the quota resets, about 23,000. urllib3 sleeps for whatever
Retry-After says and retries three times, so a call left to its defaults holds
its caller for most of a day. The client sleeps through a wait of at most
:data:`hanzoai.client.WAIT` seconds; past that the answer reaches the caller at
once, with the wait on it.
A local server answers over a real socket. `time.sleep` is recorded rather than
slept, so a call that would block for hours fails here in milliseconds.
"""
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from hanzoai import Fault, Client
from hanzoai.answer import value
from hanzoai.cloud import AiApi
from hanzoai.cloud.exceptions import ApiException
class Minted:
"""A credential that already holds its token, so no call goes to IAM."""
def token(self):
return "tok"
def invalidate(self):
pass
@pytest.fixture
def slept(monkeypatch):
"""Every sleep urllib3 asks for, in order, and none of them taken."""
asked = []
monkeypatch.setattr("urllib3.util.retry.time.sleep", asked.append)
return asked
@pytest.fixture
def server():
"""A server on a free port: the base URL, the staged answers, the paths it saw."""
staged, seen = [], []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
seen.append(self.path)
status, headers, body = staged.pop(0) if staged else (500, {}, {"title": "nothing staged"})
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
for name, v in headers.items():
self.send_header(name, v)
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
try:
yield "http://127.0.0.1:{0}".format(httpd.server_address[1]), staged, seen
finally:
httpd.shutdown()
httpd.server_close()
def limited(n, after, request="req_1"):
return [(429, {"Retry-After": after, "x-request-id": request}, {"title": "Too Many Requests"})] * n
def test_a_long_wait_raises_at_once(server, slept):
"""23,000 seconds is not waited: one request, no sleep, and the 429 carries the header."""
base, staged, seen = server
staged.extend(limited(4, "23000"))
with pytest.raises(Exception) as raised:
AiApi(Client(base=base, credential=Minted())).get_models()
assert (slept, len(seen)) == ([], 1)
assert isinstance(raised.value, ApiException)
assert (raised.value.status, raised.value.headers["Retry-After"]) == (429, "23000")
def test_a_short_wait_is_slept_and_retried(server, slept):
"""A wait under the ceiling is honoured as before: three sleeps, four requests, the API's error."""
base, staged, seen = server
staged.extend([(503, {"Retry-After": "1"}, {"title": "Service Unavailable"})] * 4)
with pytest.raises(Exception) as raised:
AiApi(Client(base=base, credential=Minted())).get_models()
assert (slept, len(seen)) == ([1, 1, 1], 4)
assert isinstance(raised.value, ApiException) and raised.value.status == 503
def test_the_fault_carries_the_wait(server, slept):
"""A capability reads the same answer through `send`, and its fault says how long to wait."""
base, staged, seen = server
staged.extend(limited(4, "23000", request="req_9"))
with pytest.raises(Exception) as raised:
value(Client(base=base, credential=Minted()).send("GET", "/v1/budget"))
assert (slept, len(seen)) == ([], 1)
assert isinstance(raised.value, Fault)
assert (raised.value.status, raised.value.retry_after, raised.value.request) == (429, 23000, "req_9")