mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
0.1.0 shipped a Temporal gRPC client under our name: it depended on
temporalio and dialled localhost:7233, a port the native engine does not
serve. Anyone who installed it got a client that could not reach us.
The engine (hanzoai/tasks) is native and speaks JSON over
/v1/tasks/namespaces/{ns}/activities. So this drops temporalio for httpx
and rewrites the package against that surface: a client that dispatches,
reads and settles activities, and a worker that claims them.
The worker POLLS rather than accepting a push, which is what lets it run
behind NAT — the claim endpoint exists for exactly that, and the shipped
Go worker (`hanzo gpu connect`) already pulls from it.
The engine's rules are not copied here. It reaps expired leases before
every claim, serializes claims per namespace, and derives the lease from
the activity's own heartbeat timeout; a second opinion on any of that
could only disagree with it. What the worker does hold up is the lease
while a handler runs, beating at a third of the window the server
granted — and a plain function runs in a thread, because a blocking call
on the event loop would stall that beat and let the work be reaped
out from under itself.
The Temporal workflow modules are deleted rather than ported. Replay
determinism is not something this surface offers, and a @workflow.defn
that cannot replay would be a promise the engine never made.
Also names the sdist contents, so a local virtualenv beside the package
can never be swept into a release again.
Co-authored-by: Hanzo Dev <[email protected]>
57 lines
1.6 KiB
Python
57 lines
1.6 KiB
Python
"""Refusals the engine states, as exceptions.
|
|
|
|
The engine answers an error as ``{"error": "...", "code": <int>}`` — ``code``
|
|
is a NUMBER here, not the ``status`` string the rest of the Hanzo API uses, so
|
|
a generic client that reads ``status`` sees nothing. `raise_for` is the one
|
|
place that shape is read.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
class TasksError(Exception):
|
|
"""An error the engine reported, carrying its numeric code."""
|
|
|
|
def __init__(self, message: str, code: int = 0) -> None:
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.code = code
|
|
|
|
|
|
class Denied(TasksError):
|
|
"""No validated principal, or one carrying no org (401/403).
|
|
|
|
The surface fails closed: a token that names no org is refused rather than
|
|
served the shared unscoped store.
|
|
"""
|
|
|
|
|
|
class NotFound(TasksError):
|
|
"""No such activity in this namespace (404)."""
|
|
|
|
|
|
class Terminal(TasksError):
|
|
"""The activity already completed, failed or was canceled (409).
|
|
|
|
Reported rather than swallowed: a second terminal call means two workers
|
|
believe they own the same run, which is worth surfacing.
|
|
"""
|
|
|
|
|
|
def raise_for(status: int, body: object) -> None:
|
|
"""Raise the exception a non-2xx answer stands for."""
|
|
message = str(body)
|
|
code = status
|
|
if isinstance(body, dict):
|
|
message = str(body.get("error", body))
|
|
raw = body.get("code")
|
|
if isinstance(raw, int):
|
|
code = raw
|
|
if status in (401, 403):
|
|
raise Denied(message, code)
|
|
if status == 404:
|
|
raise NotFound(message, code)
|
|
if status == 409:
|
|
raise Terminal(message, code)
|
|
raise TasksError(message, code)
|