mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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]>
108 lines
4.0 KiB
Python
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")
|