one credential, one subject: the scope rides the key, not the argument

An operator holds one key and serves N customers. Until now every call it made
on a customer's behalf was indistinguishable from every other, and nothing in
the SDK could tell them apart.

`Client.as_(subject)` binds a client to one of them. IAM mints a short-lived
token for that subject — POST hanzo.id/v1/iam/tokens/issue?id=…, on IAM's own
host, the subject in the query, the grant read off the key — and the scoped
client sends it on every call, holds it until it nears expiry, and re-mints once
on a 401. No operation takes a user id, so there is none to pass wrongly.

The other half is the answer a policy holds for a human. It arrives as 202, and
202 is a 2xx: a caller who only checks for a raise reads a queued call as one
that completed and returned nothing, because no operation declares a schema for
it. `Client` raises `Held` instead, carrying the approval that gates it, and
`result()` hands the same outcome back as a value whose two arms share no member
— an `Approval` has no `value`, a `Done` has no `id`. Discrimination is on the
body, not the status: the dozen long-running operations whose 202 means
"accepted" carry their own schema and pass straight through.

`Client` is the generated `ApiClient` plus those two behaviours, so all 196
generated API classes take it unchanged and there is one client, not two.

25 tests against a stub with `RESTClientObject`'s signature — the only thing the
mint and the client ever call — pin the mint URL and query, the camelCase
response, the cache and its skew, the re-mint on 401, and that a hold raises.
This commit is contained in:
zooqueen
2026-08-21 07:27:15 -07:00
parent 95cf0799f9
commit 402d03fd97
7 changed files with 865 additions and 5 deletions
+61 -3
View File
@@ -37,9 +37,10 @@ https://api.hanzo.ai serves 112 models, no credential required
```python
import os
from hanzoai.cloud import ApiClient, Configuration, KeysApi
from hanzoai import Client
from hanzoai.cloud import Configuration, KeysApi
client = ApiClient(Configuration(
client = Client(Configuration(
host="https://api.hanzo.ai",
access_token=os.environ["HANZO_API_KEY"],
))
@@ -50,7 +51,9 @@ with client as api:
```
Every route follows that shape: one `*Api` class per tag, one method per
operation, typed models in and out.
operation, typed models in and out. `Client` is the generated `ApiClient` plus
the two behaviours below — scoping and the held result — so it goes anywhere an
`ApiClient` goes.
## Auth
@@ -82,6 +85,61 @@ hand-written `hanzoai.zap` and `hanzoai.config` read `HANZO_ZAP_ENDPOINT` and
is where `HANZO_API_KEY` and `HANZO_BASE_URL` get resolved — one place, for all
six flows.
## Tenants
If you are building on top of Hanzo, you hold one key and your customers hold
none. `as_` binds a client to one of them:
```python
from hanzoai import Client
from hanzoai.cloud import Configuration, MemoryApi
hanzo = Client(Configuration(access_token=os.environ["HANZO_API_KEY"]))
memory = MemoryApi(hanzo.as_("user_42")) # a subject id, or your own externalId
```
IAM mints a short-lived token bound to that subject — `POST
https://hanzo.id/v1/iam/tokens/issue?id=user_42`, on IAM's own host, reading the
grant off your key — and the scoped client sends it on every call, keeps it
until it nears expiry, and re-mints once on a 401. No operation takes a user id,
so there is none to pass wrongly and none to forget. `Client(..., issuer=...)`
points the mint at a private estate.
The spelling is `as_` rather than `as` only because `as` is a keyword; the
trailing underscore is what PEP 8 prescribes, and it keeps the platform's one
word for this from growing a synonym in Python.
### Held calls
A call your policy holds for a human decision answers `202` with the approval —
`{"status": "held", "id", "clause", "reason"}`, the same shape
`GET /v1/approvals/{id}` returns. A 202 is a 2xx, so a client that only checks
for a raise reads a queued call as a completed one. This one raises:
```python
from hanzoai import Held, is_held, result, unwrap
try:
fact = memory.post_memory_remember(body)
except Held as held:
print(held.approval.id, held.approval.clause)
```
Or take the outcome as a value, when handling the hold is the normal path:
```python
r = result(memory.post_memory_remember, body)
if is_held(r):
print(r.id, r.clause, r.reason) # queued — nothing ran
else:
fact = unwrap(r) # raises Held rather than return a hold
```
`Approval` has no `value` and `Done` has no `id`, so there is no member you can
read off the wrong one. The dozen long-running operations whose `202` means
"accepted, working on it" carry their own schema and pass straight through — the
body is the discriminator, not the status code.
## Examples
`examples/` carries one directory per flow. Each is a whole path through one part
+32 -1
View File
@@ -60,4 +60,35 @@ except ImportError: # pragma: no cover - hanzoai[zap] not installed
# service and which document a symbol came from.
from . import cloud as cloud
__all__ = ["cloud", "__version__"]
# The tenant fabric — hand-written, and the only names this package adds beside
# `cloud`. They are here because they belong to no one operation: `Client` is
# how an operator credential is scoped to one customer, and `Result` is what an
# approval-gated call answers with. Nothing generated is re-exported alongside
# them; the namespace still says which document a symbol came from.
from .client import Client as Client
from .grant import Grant as Grant
from .result import (
Done as Done,
Held as Held,
Result as Result,
Approval as Approval,
is_done as is_done,
is_held as is_held,
result as result,
unwrap as unwrap,
)
__all__ = [
"cloud",
"__version__",
"Client",
"Grant",
"Approval",
"Done",
"Held",
"Result",
"is_done",
"is_held",
"result",
"unwrap",
]
+126
View File
@@ -0,0 +1,126 @@
"""The client, and the one call that scopes it to a tenant.
from hanzoai import Client
from hanzoai.cloud import Configuration, MemoryApi
hanzo = Client(Configuration(access_token="sk-...")) # the operator
memory = MemoryApi(hanzo.as_("user_42")) # one customer
`Client` is the generated :class:`~hanzoai.cloud.api_client.ApiClient` plus two
behaviours the generator has no way to know about — token scoping and the held
result — so every one of the generated API classes takes it unchanged.
"""
from __future__ import annotations
import copy
from typing import Any, Dict, Optional
from hanzoai.grant import ISSUER, Grant
from hanzoai.result import Held, Approval
from hanzoai.cloud.api_client import ApiClient
from hanzoai.cloud.exceptions import ApiException
from hanzoai.cloud.configuration import Configuration
__all__ = ["Client"]
class Client(ApiClient):
"""An API client, optionally bound to one subject.
Construct it with an operator credential and call :meth:`as_` per customer.
The returned client mints a subject-bound token from IAM, sends it on every
call, and re-mints once on a 401 before giving up — so a rotated or expired
token costs a round trip, not an error the caller has to handle.
:param issuer: where IAM answers, when it is not the public issuer.
:param grant: the mint this client sends for. `None` on an operator client.
A client that has one writes the live token into its configuration on
the way into every request, so it needs a configuration of its own —
which is what :meth:`as_` hands it.
"""
def __init__(
self,
configuration: Optional[Configuration] = None,
header_name: Optional[str] = None,
header_value: Optional[str] = None,
cookie: Optional[str] = None,
*,
issuer: str = ISSUER,
grant: Optional[Grant] = None,
) -> None:
super().__init__(configuration, header_name, header_value, cookie)
self.issuer = issuer
self.grant = grant
def as_(self, subject: str) -> "Client":
"""Returns a client scoped to `subject` — a subject id or an externalId.
Spelled with the trailing underscore PEP 8 prescribes for a name that
collides with a keyword, so the platform's one word for this survives
into Python instead of growing a synonym here.
The credential is the scope: no operation on the returned client takes a
user id, so there is none to pass wrongly and none to forget.
"""
key = self.configuration.access_token
if not key:
raise ApiException(
status=0,
reason="no operator credential to scope: set Configuration.access_token",
)
configuration = copy.deepcopy(self.configuration)
# The operator key never rides a scoped client. The minted token replaces
# it on the way into every request.
configuration.access_token = None
return Client(
configuration,
issuer=self.issuer,
grant=Grant(key, subject, self.issuer, self.rest_client),
)
# Where the token joins the request. The generated serializer reads
# `configuration.access_token` through `auth_settings()`, so refreshing it
# here puts the live token on every operation without touching one of them.
def param_serialize(self, *args: Any, **kwargs: Any) -> Any:
if self.grant is not None:
self.configuration.access_token = self.grant.token()
return super().param_serialize(*args, **kwargs)
def call_api(
self,
method: str,
url: str,
header_params: Optional[Dict[str, str]] = None,
body: Optional[Any] = None,
post_params: Optional[Any] = None,
_request_timeout: Optional[Any] = None,
) -> Any:
response = super().call_api(method, url, header_params, body, post_params, _request_timeout)
if response.status == 401 and self.grant is not None:
self.grant.invalidate()
header_params = dict(header_params or {})
header_params["Authorization"] = "Bearer {0}".format(self.grant.token())
response = super().call_api(method, url, header_params, body, post_params, _request_timeout)
return response
def response_deserialize(self, response_data: Any, response_types_map: Optional[Any] = None) -> Any:
"""A held call raises rather than returning — it produced no value.
The hold arrives as 202 with ``status: "held"``. No operation declares a
schema for that, so the generated deserializer answers it with `None`
and the call reads as one that succeeded and returned nothing. The
dozen operations whose 202 means "accepted, working on it" carry their
own schema and pass straight through.
Wrap a call in :func:`hanzoai.result` to handle the hold as a value.
"""
if response_data.status == 202:
body = response_data.data
if isinstance(body, (bytes, bytearray)):
body = body.decode("utf-8", "replace")
approval = Approval.held(body)
if approval is not None:
raise Held(approval, body=body)
return super().response_deserialize(response_data, response_types_map)
+121
View File
@@ -0,0 +1,121 @@
"""Minting the token that lets one operator credential act as one subject.
An operator holds a single key. Every call it makes on behalf of a customer has
to be bound to that customer and nothing else. IAM does the binding: it reads
the grant off the key and issues a short-lived token for the subject named in
the request, so the credential carries the scope and no method has to take a
user id.
The mint answers on IAM's own host — `api.hanzo.ai` 404s it — and the path
carries the `iam` segment. The subject rides as the `id` query; there is no
body, because the grant is already on the key.
"""
from __future__ import annotations
import json
import time
from typing import Any, Optional
from urllib.parse import urlencode
from hanzoai.cloud.exceptions import ApiException
__all__ = ["Grant", "ISSUER"]
#: Where IAM answers. Override for a private estate.
ISSUER = "https://hanzo.id"
#: IAM's canonical mint. The platform API does not serve it.
PATH = "/v1/iam/tokens/issue"
#: Re-mint this many seconds early so a request never rides an about-to-die token.
SKEW = 30.0
#: Lifetime assumed when IAM states none.
TTL = 300.0
class Grant:
"""A cached, subject-bound token minted from an operator credential.
Holds the token until it nears expiry and drops it on request, so a 401
costs one extra mint rather than a failed call. Not thread-safe by design:
the worst a race costs is a duplicate mint.
:param key: the operator credential IAM reads the grant off.
:param subject: a subject id, or the ``externalId`` the operator filed the
member under.
:param issuer: where IAM answers.
:param transport: anything shaped like
:class:`hanzoai.cloud.rest.RESTClientObject` — the client passes its own
so the mint reuses one connection pool and the SDK's TLS settings.
"""
def __init__(
self,
key: str,
subject: str,
issuer: str = ISSUER,
transport: Optional[Any] = None,
) -> None:
self.key = key
self.subject = subject
self.issuer = issuer.rstrip("/")
self._transport = transport
self._token: Optional[str] = None
self._expires = 0.0
@property
def url(self) -> str:
"""The mint, subject and all."""
return "{0}{1}?{2}".format(self.issuer, PATH, urlencode({"id": self.subject}))
def token(self) -> str:
"""The live token, minting one if the cached token is gone or near expiry."""
if self._token is not None and self._expires - time.monotonic() > SKEW:
return self._token
return self._mint()
def invalidate(self) -> None:
"""Drops the cached token so the next read mints a fresh one."""
self._token = None
self._expires = 0.0
def _mint(self) -> str:
response = self.transport.request(
"POST",
self.url,
headers={
"Authorization": "Bearer {0}".format(self.key),
"Accept": "application/json",
},
)
response.read()
body = response.data.decode("utf-8", "replace") if response.data else ""
if not 200 <= response.status <= 299:
raise ApiException.from_response(http_resp=response, body=body, data=None)
# IAM answers camelCase.
payload = json.loads(body) if body else {}
token = payload.get("accessToken") if isinstance(payload, dict) else None
if not isinstance(token, str) or not token:
raise ApiException(
status=response.status,
reason="IAM issued no token for {0}".format(self.subject),
body=body,
)
expires_in = payload.get("expiresIn")
seconds = float(expires_in) if isinstance(expires_in, (int, float)) else TTL
self._token = token
self._expires = time.monotonic() + seconds
return token
@property
def transport(self) -> Any:
if self._transport is None:
from hanzoai.cloud.rest import RESTClientObject
from hanzoai.cloud.configuration import Configuration
self._transport = RESTClientObject(Configuration(host=self.issuer))
return self._transport
+151
View File
@@ -0,0 +1,151 @@
"""The outcome of an approval-gated call.
Some calls are sensitive enough that the platform stops them for a human
decision instead of running them. That answer arrives as HTTP 202 carrying the
approval verbatim — the same field names `GET /v1/approvals/{id}` returns, so
there is one shape to learn.
A 202 is a 2xx, which is exactly the trap: a client that only checks "did it
throw" reads a queued call as a completed one. Nothing here lets that happen.
Through :class:`hanzoai.Client` a held call raises :class:`Held`, which names
the approval; :func:`result` turns that raise into a value you must destructure
before you can read anything out of it.
from hanzoai import is_held, result, unwrap
r = result(memory.post_memory_remember, body)
if is_held(r):
print(r.id, r.clause, r.reason) # queued, nothing ran
else:
fact = unwrap(r) # raises Held if it was not done
"""
from __future__ import annotations
import json
from typing import Any, Union, Generic, TypeVar, Callable, Optional
from dataclasses import dataclass
from typing_extensions import Literal, TypeGuard
from hanzoai.cloud.exceptions import ApiException
__all__ = [
"Approval",
"Done",
"Held",
"Result",
"is_done",
"is_held",
"result",
"unwrap",
]
T = TypeVar("T")
@dataclass(frozen=True)
class Approval:
"""A call the platform stopped for a human decision.
`id` is the handle to poll or resolve by, `clause` names the policy clause
that held the call, and `reason` says why. `status` is always ``"held"``.
"""
id: str
clause: str
reason: str
status: Literal["held"] = "held"
@classmethod
def held(cls, body: Any) -> Optional["Approval"]:
"""Reads a body as a hold, or returns `None` when it is not one.
The body is the discriminator, not the status code: 202 also means
"accepted, working on it" on a dozen long-running operations, and those
carry their own schema. Only ``status: "held"`` is an approval.
Serves a 202 and `GET /v1/approvals/{id}` alike — the field names are
the same, and a resolved approval no longer says `held`. The server
omits an empty field, so each one reads as ``""`` rather than `None`.
"""
if isinstance(body, (bytes, bytearray)):
body = body.decode("utf-8", "replace")
if isinstance(body, str):
try:
body = json.loads(body) if body else None
except ValueError:
return None
if not isinstance(body, dict) or body.get("status") != "held":
return None
return cls(
id=_text(body.get("id")),
clause=_text(body.get("clause")),
reason=_text(body.get("reason")),
)
@dataclass(frozen=True)
class Done(Generic[T]):
"""A call that ran. `value` is what it returned."""
value: T
status: Literal["done"] = "done"
#: What an approval-gated call produced. The two arms share no member: an
#: `Approval` has no `value` and a `Done` has no `id`, so reading either one
#: without checking `status` first fails at the attribute rather than quietly
#: handing back a half-answer.
Result = Union[Done[T], Approval]
class Held(ApiException):
"""Raised for a call the platform held. Carries the approval that gates it.
It is an :class:`ApiException` because a held call is an HTTP outcome that
produced no value: `status` is 202 and `body` is the response verbatim. A
caller who catches `ApiException` at the edge already treats it as "no
result", which is the safe direction; catch `Held` to treat it as "queued".
"""
def __init__(self, approval: Approval, *, body: Optional[str] = None) -> None:
super().__init__(
status=202,
reason="held on {0}: {1}".format(approval.clause, approval.reason),
body=body,
)
self.approval = approval
def is_held(r: Result[T]) -> TypeGuard[Approval]:
"""True when the call was held. The explicit check."""
return r.status == "held"
def is_done(r: Result[T]) -> TypeGuard[Done[T]]:
"""True when the call ran."""
return r.status == "done"
def unwrap(r: Result[T]) -> T:
"""Returns the value of a `Done`, or raises :class:`Held` naming the approval."""
if isinstance(r, Approval):
raise Held(r)
return r.value
def result(call: Callable[..., T], *args: Any, **kwargs: Any) -> Result[T]:
"""Runs an approval-gated call and returns its outcome as a value.
Every operation on a :class:`hanzoai.Client` raises :class:`Held` when the
platform holds it. Wrap the call here when you want to handle the hold
rather than let it propagate.
"""
try:
return Done(call(*args, **kwargs))
except Held as held:
return held.approval
def _text(v: Any) -> str:
return v if isinstance(v, str) else ""
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzoai"
version = "3.2.13"
version = "3.2.15"
description = "The official Python library for the Hanzo API"
dynamic = ["readme"]
license = "Apache-2.0"
+373
View File
@@ -0,0 +1,373 @@
"""The tenant fabric: one operator credential, N isolated customers.
Nothing here touches the network. The stub below has the same `request()`
signature as :class:`hanzoai.cloud.rest.RESTClientObject`, which is the only
thing the client and the mint ever call, so these exercise the real code path
and only the socket is missing.
"""
import json
import pytest
from hanzoai import (
Done,
Held,
Grant,
Client,
Approval,
result,
unwrap,
is_done,
is_held,
)
from hanzoai.cloud import KeysApi, Configuration
from hanzoai.cloud.exceptions import ApiException
class Reply:
"""Shaped like `hanzoai.cloud.rest.RESTResponse`, without a socket."""
def __init__(self, status, payload=None, headers=None):
self.status = status
self.reason = "stub"
if isinstance(payload, (bytes, bytearray)):
self.data = bytes(payload)
elif payload is None:
self.data = b""
else:
self.data = json.dumps(payload).encode()
self.headers = headers or {"content-type": "application/json"}
def read(self):
return self.data
def getheaders(self):
return self.headers
def getheader(self, name, default=None):
return self.headers.get(name, default)
class Transport:
"""Answers each request with the next reply, and records what it was asked."""
def __init__(self, *replies):
self.replies = list(replies)
self.calls = []
def request(self, method, url, headers=None, body=None, post_params=None, _request_timeout=None):
self.calls.append((method, url, dict(headers or {}), body))
assert self.replies, "the client made more requests than the test staged"
return self.replies.pop(0)
def issued(token, expires_in=600):
"""IAM's answer, in IAM's casing."""
return Reply(200, {"accessToken": token, "expiresIn": expires_in})
# --------------------------------------------------------------------------
# The mint
# --------------------------------------------------------------------------
def test_the_mint_is_iam_s_own_host_and_the_subject_is_a_query():
"""`POST https://hanzo.id/v1/iam/tokens/issue?id=<subject>`, no body.
Three things this pins, each of which fails silently if it drifts: the host
is IAM's, not the platform API's, which 404s this path; the path carries the
`iam` segment; and the subject rides as the `id` query, because IAM reads
the grant off the key and there is nothing to put in a body.
"""
transport = Transport(issued("tok-user-42"))
grant = Grant("hk-operator", "user_42", transport=transport)
assert grant.token() == "tok-user-42"
method, url, headers, body = transport.calls[0]
assert method == "POST"
assert url == "https://hanzo.id/v1/iam/tokens/issue?id=user_42"
assert headers["Authorization"] == "Bearer hk-operator"
assert body is None
def test_the_subject_is_url_encoded():
"""An externalId is whatever the operator filed the member under."""
transport = Transport(issued("tok"))
Grant("hk-operator", "acct/42 x&y", transport=transport).token()
_, url, _, _ = transport.calls[0]
assert url == "https://hanzo.id/v1/iam/tokens/issue?id=acct%2F42+x%26y"
def test_the_issuer_is_overridable_for_a_private_estate():
transport = Transport(issued("tok"))
Grant("hk-operator", "user_42", issuer="https://id.acme.internal/", transport=transport).token()
_, url, _, _ = transport.calls[0]
assert url == "https://id.acme.internal/v1/iam/tokens/issue?id=user_42"
def test_the_response_is_camel_case():
"""`accessToken` / `expiresIn`. snake_case here reads as no token at all."""
transport = Transport(Reply(200, {"access_token": "wrong", "expires_in": 600}))
grant = Grant("hk-operator", "user_42", transport=transport)
with pytest.raises(ApiException) as caught:
grant.token()
assert "user_42" in str(caught.value)
def test_the_token_is_cached_to_expiry():
"""One mint serves every call until the token nears expiry."""
transport = Transport(issued("tok-1"))
grant = Grant("hk-operator", "user_42", transport=transport)
assert [grant.token() for _ in range(5)] == ["tok-1"] * 5
assert len(transport.calls) == 1
def test_a_token_inside_the_skew_is_not_reused():
"""A token about to die is replaced before it rides a request, not after.
`expiresIn: 10` is inside the 30s skew, so it is spent on arrival.
"""
transport = Transport(issued("tok-1", expires_in=10), issued("tok-2", expires_in=600))
grant = Grant("hk-operator", "user_42", transport=transport)
assert grant.token() == "tok-1"
assert grant.token() == "tok-2"
assert len(transport.calls) == 2
def test_invalidate_forces_the_next_read_to_mint():
transport = Transport(issued("tok-1"), issued("tok-2"))
grant = Grant("hk-operator", "user_42", transport=transport)
assert grant.token() == "tok-1"
grant.invalidate()
assert grant.token() == "tok-2"
def test_a_refused_mint_is_a_typed_error():
"""Non-2xx carries the status and the body, never a bare string."""
transport = Transport(Reply(403, {"error": "no act grant on this key"}))
grant = Grant("hk-operator", "user_42", transport=transport)
with pytest.raises(ApiException) as caught:
grant.token()
assert caught.value.status == 403
assert "no act grant" in caught.value.body
# --------------------------------------------------------------------------
# The scope
# --------------------------------------------------------------------------
def operator(transport):
"""An operator client whose pool — and so whose mint — is the stub."""
client = Client(Configuration(host="https://api.hanzo.ai", access_token="hk-operator"))
client.rest_client = transport
return client
def test_as_puts_the_minted_token_on_the_request():
"""End to end: serialize a real operation off a scoped client.
The generated serializer reads the credential through
`Configuration.auth_settings()`, so this asserts the whole path — mint,
cache, `auth_settings`, `_apply_auth_params` — and not a field we set.
"""
transport = Transport(issued("tok-user-42"))
client = operator(transport).as_("user_42")
_, _, headers, _, _ = KeysApi(client)._get_keys_serialize(
_request_auth=None, _content_type=None, _headers=None, _host_index=0
)
assert headers["Authorization"] == "Bearer tok-user-42"
def test_the_operator_key_never_rides_a_scoped_client():
transport = Transport(issued("tok-user-42"))
client = operator(transport).as_("user_42")
assert client.grant.subject == "user_42"
_, _, headers, _, _ = KeysApi(client)._get_keys_serialize(
_request_auth=None, _content_type=None, _headers=None, _host_index=0
)
assert "hk-operator" not in headers["Authorization"]
def test_scoping_does_not_disturb_the_operator():
"""Two subjects and the operator hold three credentials, not one shared slot."""
transport = Transport(issued("tok-a"), issued("tok-b"))
boss = operator(transport)
a, b = boss.as_("user_a"), boss.as_("user_b")
assert a.grant.token() == "tok-a"
assert b.grant.token() == "tok-b"
assert boss.configuration.access_token == "hk-operator"
assert a.configuration is not b.configuration
def test_scoping_without_a_credential_refuses():
with pytest.raises(ApiException):
Client(Configuration(host="https://api.hanzo.ai")).as_("user_42")
def test_a_401_re_mints_once_and_retries():
"""A rotated token costs a round trip, not an error the caller handles."""
mint = Transport(issued("tok-stale"), issued("tok-fresh"))
calls = Transport(Reply(401, {"error": "expired"}), Reply(200, {"ok": True}))
client = Client(
Configuration(host="https://api.hanzo.ai"),
grant=Grant("hk-operator", "user_42", transport=mint),
)
client.rest_client = calls
# What `param_serialize` did on the way in: the request rides the token the
# grant was holding.
staged = client.grant.token()
assert staged == "tok-stale"
response = client.call_api(
"GET",
"https://api.hanzo.ai/v1/keys",
{"Authorization": "Bearer " + staged},
)
assert response.status == 200
assert len(mint.calls) == 2, "the stale token was not dropped"
assert calls.calls[0][2]["Authorization"] == "Bearer tok-stale"
assert calls.calls[1][2]["Authorization"] == "Bearer tok-fresh"
def test_a_401_is_retried_once_and_then_stands():
"""Two 401s are an answer, not a loop."""
mint = Transport(issued("tok-1"), issued("tok-2"))
calls = Transport(Reply(401, {"error": "expired"}), Reply(401, {"error": "expired"}))
client = Client(
Configuration(host="https://api.hanzo.ai"),
grant=Grant("hk-operator", "user_42", transport=mint),
)
client.rest_client = calls
assert client.call_api("GET", "https://api.hanzo.ai/v1/keys", {}).status == 401
assert len(calls.calls) == 2
def test_an_unscoped_client_does_not_re_mint():
"""No grant, nothing to re-mint: the 401 is the caller's to deal with."""
calls = Transport(Reply(401, {"error": "bad key"}))
client = Client(Configuration(host="https://api.hanzo.ai", access_token="hk-operator"))
client.rest_client = calls
assert client.call_api("GET", "https://api.hanzo.ai/v1/keys", {}).status == 401
assert len(calls.calls) == 1
# --------------------------------------------------------------------------
# The held result
# --------------------------------------------------------------------------
HELD = {
"status": "held",
"id": "apr_7f3",
"clause": "memory.remember",
"reason": "writes to a customer profile need review",
}
def deserialize(reply, types=None):
client = Client(Configuration(host="https://api.hanzo.ai", access_token="hk-operator"))
return client.response_deserialize(reply, types or {"200": "object"})
def test_a_held_call_raises_rather_than_returning_nothing():
"""No operation declares a schema for the hold, so the generated
deserializer answers it with `None` — a queued call reading as a call that
succeeded and returned nothing. This is the whole point of the fabric."""
with pytest.raises(Held) as caught:
deserialize(Reply(202, HELD))
held = caught.value
assert held.approval == Approval(
id="apr_7f3", clause="memory.remember", reason="writes to a customer profile need review"
)
assert held.status == 202
assert "memory.remember" in str(held)
def test_a_202_that_is_not_a_hold_passes_through():
"""A dozen long-running operations answer 202 with their own schema.
The body is the discriminator, not the status code. Blanket-raising on 202
would break every deploy and build call in the document.
"""
response = deserialize(Reply(202, {"id": "dep_1", "status": "queued"}), {"202": "object"})
assert response.status_code == 202
assert response.data == {"id": "dep_1", "status": "queued"}
def test_result_turns_the_raise_into_a_value():
def call():
raise Held(Approval.held(HELD), body=json.dumps(HELD))
r = result(call)
assert is_held(r)
assert not is_done(r)
assert (r.id, r.clause, r.reason) == ("apr_7f3", "memory.remember", "writes to a customer profile need review")
def test_result_carries_a_completed_call():
r = result(lambda: {"id": "card_1"})
assert is_done(r)
assert unwrap(r) == {"id": "card_1"}
def test_unwrapping_a_hold_raises_and_names_the_approval():
"""The one thing a caller must not be able to do is read a hold as a value."""
with pytest.raises(Held) as caught:
unwrap(Approval.held(HELD))
assert caught.value.approval.id == "apr_7f3"
def test_the_two_arms_share_no_member():
"""`Approval` has no `value` and `Done` has no `id`.
Reading either without checking `status` first fails at the attribute
instead of quietly handing back a half-answer — which is as close as Python
gets to the sum type a compiler would refuse to let you ignore.
"""
assert not hasattr(Approval.held(HELD), "value")
assert not hasattr(Done({"id": "card_1"}), "id")
def test_an_approval_read_back_is_the_same_shape():
"""`GET /v1/approvals/{id}` answers in the field names of the 202."""
fetched = Approval.held(json.dumps(HELD).encode())
assert fetched == Approval.held(HELD)
def test_a_resolved_approval_is_not_a_hold():
assert Approval.held({"status": "approved", "id": "apr_7f3"}) is None
assert Approval.held(b"") is None
assert Approval.held("not json") is None
def test_a_hold_with_fields_omitted_still_has_all_four():
assert Approval.held({"status": "held", "id": "apr_7f3"}) == Approval(id="apr_7f3", clause="", reason="")
# --------------------------------------------------------------------------
# The typed error
# --------------------------------------------------------------------------
def test_a_non_2xx_is_a_typed_error_carrying_status_and_body():
with pytest.raises(ApiException) as caught:
deserialize(Reply(500, {"error": "boom"}))
assert caught.value.status == 500
assert json.loads(caught.value.body) == {"error": "boom"}