HTTP fallback leg (docs/19): POST /v1/frame + SSE /v1/events + long-poll
Second, short-lived-connection transport next to the WS: same frames, same outbox/cursor, same token, served over plain HTTP (stdlib ThreadingHTTPServer bridged into the asyncio loop; zero new deps). - http_server.py: /v1/health (unauthenticated), POST /v1/frame (accept-and-ack; validation rejections as 4xx error frames), SSE /v1/events (outbox catch-up with id=cursor, event: hello, 15s heartbeat, bounded-queue backpressure), long-poll /v1/poll (25s hold). Bearer token + X-Iris-Device (same allowlist as WS hello), 64 KiB body cap, per-device rate limit, optional TLS, non-fatal bind failure. - ws_server.py: inbound dispatch chain extracted to shared dispatch_frame() used by both transports. - adapter.py: ANDROID_HTTP_PORT/CERT/KEY config; start/stop next to the WS; delivery counting in _broadcast_or_log (an SSE subscriber is a live subscriber -> no push, docs/19 19.8); _reply() routes point-to-point replies into the in-flight HTTP response (reply sink) or broadcasts when the device has no live WS (19.7); status/typing/ channel events fan out to both transports. - ws_probe.py: --http mode (health + POST + SSE turn drive, same assertion flags); tests/README updated. - Tests: hermes-agent/tests/gateway/test_android_http.py (23 tests, incl. the 19.8 delivery-counting regression); test_android.py (74) still green.
This commit is contained in:
1 parent
524ed8ce53
commit
e5c7d690b8
7 files changed
+1315
-89
No files matched your search
@@ -43,6 +43,12 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
||||
- `--offer-grace S` — with `--pull-offer`, keep listening S seconds after
|
||||
the final message for a `media.offer` (offers are emitted post-turn,
|
||||
right after the final; default 15).
|
||||
- `--http [--http-url http://host:port]` — docs/19: drive the turn over
|
||||
the **HTTP fallback leg** instead of WS: `GET /v1/health`,
|
||||
`POST /v1/frame` (the `message.send`), receive over SSE `GET
|
||||
/v1/events`. The same assertion flags apply. The base URL defaults to
|
||||
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
|
||||
(`ANDROID_HTTP_PORT`).
|
||||
|
||||
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
|
||||
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
|
||||
@@ -51,7 +57,9 @@ acked, `6` timeout, `7` no final message, `8` upload/sync fail,
|
||||
`12` assert-tools fail, `13` assert-commentary fail, `14` search fail
|
||||
(error or zero hits), `15` channel.create/list fail, `16` channel.delete
|
||||
fail, `17` watch timeout, `18` read.receipt arrived before the sent
|
||||
message, `19` status frame with empty payload.
|
||||
message, `19` status frame with empty payload, `20` `--http` health
|
||||
check failed, `21` `--http` SSE open failed, `22` `--http`
|
||||
`POST /v1/frame` rejected (4xx).
|
||||
|
||||
## E2E driver (`e2e.py`)
|
||||
|
||||
|
||||
@@ -56,6 +56,13 @@ Request modes (no turn driven unless --send/--upload also given):
|
||||
--watch CHAT_ID wait up to --timeout for a message to land in
|
||||
CHAT_ID (cron delivery E2E, scenario 7)
|
||||
|
||||
HTTP fallback leg (docs/19):
|
||||
--http drive the turn over the HTTP leg instead of WS:
|
||||
GET /v1/health, POST /v1/frame (message.send), receive
|
||||
over SSE /v1/events. The same assertion flags apply.
|
||||
--http-url http://host:port base for --http (default: derived
|
||||
from --url, ws(s) -> http(s), port 8791)
|
||||
|
||||
Exit codes:
|
||||
0 ok (incl. SKIP for absent M7 frames)
|
||||
2 connect failed
|
||||
@@ -76,6 +83,9 @@ Exit codes:
|
||||
17 --watch timed out (no message landed in the channel)
|
||||
18 --assert-read-receipt failed (frame arrived before the sent message)
|
||||
19 --assert-status failed (status frame arrived with an empty payload)
|
||||
20 --http: health check failed
|
||||
21 --http: SSE open failed
|
||||
22 --http: POST /v1/frame rejected (4xx)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -690,6 +700,124 @@ async def run(args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def run_http(args, base: str) -> int:
|
||||
"""docs/19: drive a turn over the HTTP fallback leg — GET /v1/health,
|
||||
POST /v1/frame (message.send), receive over SSE /v1/events. Blocking
|
||||
(stdlib http.client); the same assertion flags apply as the WS leg."""
|
||||
from http.client import HTTPConnection
|
||||
from urllib.parse import urlparse
|
||||
|
||||
u = urlparse(base)
|
||||
host = u.hostname or "127.0.0.1"
|
||||
port = u.port or (443 if u.scheme == "https" else 80)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {args.token}",
|
||||
"X-Iris-Device": args.device,
|
||||
}
|
||||
|
||||
# 1. health (unauthenticated liveness probe).
|
||||
try:
|
||||
conn = HTTPConnection(host, port, timeout=5)
|
||||
conn.request("GET", "/v1/health")
|
||||
r = conn.getresponse()
|
||||
body = r.read()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"!! health check failed: {e}")
|
||||
return 20
|
||||
if r.status != 200:
|
||||
print(f"!! health check failed: HTTP {r.status} {body[:200]!r}")
|
||||
return 20
|
||||
print(f"== health ok: {body!r}")
|
||||
|
||||
# 2. open the SSE stream.
|
||||
sse = HTTPConnection(host, port, timeout=args.timeout)
|
||||
sse.request("GET", "/v1/events", headers=headers)
|
||||
resp = sse.getresponse()
|
||||
if resp.status != 200:
|
||||
print(f"!! SSE open failed: HTTP {resp.status}")
|
||||
return 21
|
||||
print("== SSE open (/v1/events)")
|
||||
|
||||
# 3. POST the message.send frame (accept-and-ack).
|
||||
if args.send:
|
||||
frame = {
|
||||
"v": 1, "id": 1, "type": "message.send",
|
||||
"chat_id": "android:default", "payload": {"text": args.send},
|
||||
}
|
||||
conn = HTTPConnection(host, port, timeout=30)
|
||||
conn.request(
|
||||
"POST", "/v1/frame", body=json.dumps(frame),
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
)
|
||||
r = conn.getresponse()
|
||||
body = r.read()
|
||||
conn.close()
|
||||
print(f"== POST /v1/frame -> {r.status} {body[:200]!r}")
|
||||
if r.status >= 400:
|
||||
print("!! POST /v1/frame rejected")
|
||||
return 22
|
||||
|
||||
# 4. read SSE until the final assistant message (same final-detection
|
||||
# logic as the WS leg).
|
||||
st = _TurnState()
|
||||
got_final = False
|
||||
seen_final_frame = False
|
||||
deadline = time.time() + args.timeout
|
||||
cur_data: list[str] = []
|
||||
|
||||
def feed(line: str) -> bool:
|
||||
nonlocal cur_data, got_final, seen_final_frame
|
||||
line = line.rstrip("\r\n")
|
||||
if line == "":
|
||||
if cur_data:
|
||||
data = _print_frame("\n".join(cur_data))
|
||||
if data is not None:
|
||||
ftype = data.get("type")
|
||||
payload = data.get("payload") or {}
|
||||
st.track(ftype, payload)
|
||||
if ftype == "message" and payload.get("role") == "assistant":
|
||||
got_final = True
|
||||
if ftype == "message.stop":
|
||||
seen_final_frame = True
|
||||
if ftype == "typing" and payload.get("on") is False and seen_final_frame:
|
||||
got_final = True
|
||||
cur_data = []
|
||||
return got_final
|
||||
if line.startswith(":"):
|
||||
return got_final # heartbeat comment
|
||||
field, _, value = line.partition(":")
|
||||
if value.startswith(" "):
|
||||
value = value[1:]
|
||||
if field == "data":
|
||||
cur_data.append(value)
|
||||
return got_final
|
||||
|
||||
sock = getattr(getattr(resp.fp, "raw", None), "_sock", None)
|
||||
try:
|
||||
while time.time() < deadline and not got_final:
|
||||
if sock is not None:
|
||||
sock.settimeout(max(0.1, deadline - time.time()))
|
||||
line = resp.fp.readline()
|
||||
if not line:
|
||||
break
|
||||
if feed(line.decode("utf-8")):
|
||||
break
|
||||
finally:
|
||||
sse.close()
|
||||
|
||||
if not got_final:
|
||||
print(f"!! no final assistant message (HTTP leg, {args.timeout:.0f}s)")
|
||||
return 7
|
||||
print("== final assistant message received (via SSE)")
|
||||
|
||||
for code, ok, msg in _evaluate_assertions(args, st):
|
||||
if not ok:
|
||||
print(f"!! {msg}")
|
||||
return code
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", "ws://127.0.0.1:8790/ws"))
|
||||
@@ -739,12 +867,28 @@ def main() -> int:
|
||||
p.add_argument("--offer-grace", type=float, default=15.0,
|
||||
help="seconds to wait for a media.offer after the final "
|
||||
"message when --pull-offer (default 15)")
|
||||
p.add_argument("--http", action="store_true",
|
||||
help="docs/19: drive the turn over the HTTP fallback leg "
|
||||
"(health + POST /v1/frame + SSE /v1/events) instead of WS")
|
||||
p.add_argument("--http-url", default="",
|
||||
help="docs/19: http(s)://host:port base for --http "
|
||||
"(default: derived from --url, port 8791)")
|
||||
args = p.parse_args()
|
||||
if not args.token and not args.authfail:
|
||||
p.error("--token (or $ANDROID_TOKEN) is required")
|
||||
if args.assert_read_receipt and not args.send:
|
||||
p.error("--assert-read-receipt requires --send (the receipt must follow "
|
||||
"the sent message)")
|
||||
if args.http:
|
||||
if args.http_url:
|
||||
base = args.http_url
|
||||
else:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
u = urlparse(args.url)
|
||||
scheme = "https" if u.scheme == "wss" else "http"
|
||||
base = f"{scheme}://{u.hostname or '127.0.0.1'}:8791"
|
||||
return run_http(args, base)
|
||||
return asyncio.run(run(args))
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user