Skip to content

Commit dadd7a1

Browse files
Zawwarsami16claude
andcommitted
phase 1.0b: lightweight read-only federation (one-hop)
a hub can be configured with peer hub URLs. /registry/global aggregates local + peer listings, annotated with origin. one-hop discovery only — no cross-hub call routing, no shared state, no signed peer relationships yet (those are 1.1+). components: - zhub/federation.py: PeerRegistry — async httpx-backed cache of peer /registry endpoints with refresh interval. concurrent fetch via asyncio.gather so slow peers don't block fast ones. failures become empty lists silently — never block the local response. - zhub/server.py: ZHUB_PEERS env (or new --peers CLI flag, comma- separated URLs) configures peer set. new GET /registry/global endpoint serves local + peer entries (each peer entry stamped with its origin URL). local entries get origin: "self". tests (TDD — RED first, then GREEN): - tests/test_federation.py: 3 cases - test_global_registry_aggregates_peer: hub A peers hub B, B publishes 'onb', A's /registry/global includes 'onb' with non-self origin - test_global_registry_without_peers_returns_only_local: peerless hub serves only its own publishers, all origin: 'self' - test_global_registry_offline_peer_omitted_gracefully: bogus peer URL doesn't 502 the local response — silently skipped result: 32/32 pytest passing across all phases (signing, federation, council, persistence, streaming, e2e, manifest, protocol, invoke shape, generator non-streaming). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 43c4478 commit dadd7a1

3 files changed

Lines changed: 257 additions & 0 deletions

File tree

tests/test_federation.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Two hubs in-process; one peers the other; /registry/global aggregates."""
2+
3+
import asyncio
4+
import os
5+
import socket
6+
import threading
7+
import time
8+
9+
import pytest
10+
11+
try:
12+
import fastapi # noqa
13+
import uvicorn # noqa
14+
import httpx # noqa
15+
DEPS_AVAILABLE = True
16+
except ImportError:
17+
DEPS_AVAILABLE = False
18+
19+
if DEPS_AVAILABLE:
20+
from zhub.server import create_app
21+
from zhub import publish
22+
23+
24+
def _free_port() -> int:
25+
with socket.socket() as s:
26+
s.bind(("", 0))
27+
return s.getsockname()[1]
28+
29+
30+
def _start_hub(port: int, peers_env: str = "") -> None:
31+
"""Start a hub in this thread. Reads peers from process env at start."""
32+
if peers_env:
33+
os.environ["ZHUB_PEERS"] = peers_env
34+
else:
35+
os.environ.pop("ZHUB_PEERS", None)
36+
config = uvicorn.Config(create_app(), host="127.0.0.1", port=port, log_level="warning")
37+
asyncio.run(uvicorn.Server(config).serve())
38+
39+
40+
def _wait(port: int) -> None:
41+
for _ in range(30):
42+
try:
43+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
44+
return
45+
except OSError:
46+
time.sleep(0.1)
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_global_registry_aggregates_peer():
51+
"""Hub A peers hub B. A publisher registered on B is visible via A's
52+
/registry/global endpoint, annotated with origin."""
53+
if not DEPS_AVAILABLE:
54+
pytest.skip("fastapi/uvicorn/httpx not installed")
55+
port_a = _free_port()
56+
port_b = _free_port()
57+
58+
# Start B first (no peers), then A peering B.
59+
threading.Thread(target=_start_hub, args=(port_b, ""), daemon=True).start()
60+
_wait(port_b)
61+
threading.Thread(target=_start_hub, args=(port_a, f"http://127.0.0.1:{port_b}"), daemon=True).start()
62+
_wait(port_a)
63+
64+
# Publish on B
65+
pub = publish(
66+
name="onb",
67+
description="lives on B",
68+
chat_handler=lambda m, o: "ok",
69+
hub_url=f"ws://127.0.0.1:{port_b}",
70+
public=True,
71+
)
72+
for _ in range(50):
73+
if pub.api_key:
74+
break
75+
await asyncio.sleep(0.1)
76+
assert pub.api_key
77+
78+
# Fetch hub A's global registry
79+
async with httpx.AsyncClient() as c:
80+
resp = await c.get(f"http://127.0.0.1:{port_a}/registry/global")
81+
assert resp.status_code == 200
82+
data = resp.json()
83+
84+
names = {e["name"] for e in data}
85+
assert "onb" in names, f"expected 'onb' in {names}"
86+
origins = {e.get("origin") for e in data if e["name"] == "onb"}
87+
assert any(o and o != "self" for o in origins), \
88+
f"expected at least one non-self origin, got {origins}"
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_global_registry_without_peers_returns_only_local():
93+
"""A hub with no peers configured returns only its own local listings
94+
from /registry/global (degenerate but correct)."""
95+
if not DEPS_AVAILABLE:
96+
pytest.skip("fastapi/uvicorn/httpx not installed")
97+
port = _free_port()
98+
threading.Thread(target=_start_hub, args=(port, ""), daemon=True).start()
99+
_wait(port)
100+
101+
pub = publish(
102+
name="solo",
103+
description="no peers",
104+
chat_handler=lambda m, o: "ok",
105+
hub_url=f"ws://127.0.0.1:{port}",
106+
public=True,
107+
)
108+
for _ in range(50):
109+
if pub.api_key:
110+
break
111+
await asyncio.sleep(0.1)
112+
113+
async with httpx.AsyncClient() as c:
114+
resp = await c.get(f"http://127.0.0.1:{port}/registry/global")
115+
assert resp.status_code == 200
116+
data = resp.json()
117+
assert len(data) >= 1
118+
assert all(e.get("origin") == "self" for e in data)
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_global_registry_offline_peer_omitted_gracefully():
123+
"""If a peer is unreachable, /registry/global still returns local
124+
listings — the offline peer is silently skipped."""
125+
if not DEPS_AVAILABLE:
126+
pytest.skip("fastapi/uvicorn/httpx not installed")
127+
bogus_port = _free_port() # nothing listens here
128+
port = _free_port()
129+
threading.Thread(target=_start_hub,
130+
args=(port, f"http://127.0.0.1:{bogus_port}"),
131+
daemon=True).start()
132+
_wait(port)
133+
134+
async with httpx.AsyncClient() as c:
135+
resp = await c.get(f"http://127.0.0.1:{port}/registry/global")
136+
assert resp.status_code == 200 # not 502
137+
# Body may be empty if no local publishers — that's fine.

zhub/federation.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Lightweight, read-only federation between hubs.
2+
3+
A hub can be configured with a list of peer hub URLs. Periodically, each
4+
peer's `/registry` is fetched and cached. The aggregator endpoint
5+
`/registry/global` returns local listings + peer listings, annotated with
6+
their origin URL.
7+
8+
This is one-hop discovery only:
9+
- no cross-hub call routing (clients still go through their hub's own
10+
publishers)
11+
- no shared registry state
12+
- offline peers are skipped silently — never block the local response
13+
14+
Anything richer (cross-hub call routing, signed peer relationships, etc.)
15+
is Phase 1.1+.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import asyncio
21+
import logging
22+
import time
23+
from typing import Any
24+
25+
try:
26+
import httpx
27+
except ImportError as e:
28+
raise SystemExit(
29+
"zhub.federation requires httpx. install:\n"
30+
" pip install httpx"
31+
) from e
32+
33+
34+
log = logging.getLogger("zhub.federation")
35+
36+
37+
class PeerRegistry:
38+
"""Caches peer hub registries with a refresh interval."""
39+
40+
def __init__(self, peers: list[str], refresh_seconds: float = 60.0,
41+
timeout_seconds: float = 5.0) -> None:
42+
self.peers = peers
43+
self.refresh_seconds = refresh_seconds
44+
self._cache: dict[str, tuple[list[dict[str, Any]], float]] = {}
45+
self._http = httpx.AsyncClient(timeout=timeout_seconds)
46+
47+
async def get(self, peer_url: str) -> list[dict[str, Any]]:
48+
"""Return cached peer registry if fresh, otherwise re-fetch.
49+
Empty list on any failure — callers must not block on a dead peer."""
50+
cached = self._cache.get(peer_url)
51+
if cached and time.time() - cached[1] < self.refresh_seconds:
52+
return cached[0]
53+
try:
54+
resp = await self._http.get(peer_url.rstrip("/") + "/registry")
55+
resp.raise_for_status()
56+
data = resp.json()
57+
if not isinstance(data, list):
58+
log.warning("peer %s returned non-list registry; treating as empty", peer_url)
59+
data = []
60+
except Exception as e:
61+
log.warning("peer %s unreachable: %s", peer_url, e)
62+
return []
63+
self._cache[peer_url] = (data, time.time())
64+
return data
65+
66+
async def aggregate(self) -> list[dict[str, Any]]:
67+
"""Return all peer entries annotated with their origin URL.
68+
Concurrent fetch — slow peers don't block fast ones."""
69+
if not self.peers:
70+
return []
71+
results = await asyncio.gather(
72+
*(self.get(peer) for peer in self.peers),
73+
return_exceptions=False,
74+
)
75+
out: list[dict[str, Any]] = []
76+
for peer, entries in zip(self.peers, results):
77+
for e in entries:
78+
e2 = dict(e)
79+
e2["origin"] = peer
80+
out.append(e2)
81+
return out
82+
83+
async def close(self) -> None:
84+
await self._http.aclose()

zhub/server.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from __future__ import annotations
1919

2020
import argparse
21+
import os
2122
import asyncio
2223
import json # noqa: F401 -- used in inline SSE serialization
2324
import logging
@@ -323,6 +324,33 @@ async def registry() -> JSONResponse:
323324
})
324325
return JSONResponse(listings)
325326

327+
@app.get("/registry/global")
328+
async def registry_global() -> JSONResponse:
329+
"""Local listings + peer-hub listings, annotated with origin.
330+
Peers come from ZHUB_PEERS env var (comma-separated URLs).
331+
Offline peers are silently skipped — never block the local response."""
332+
local: list[dict[str, Any]] = []
333+
for name, p in hub.publishers.items():
334+
if p.manifest.get("public"):
335+
local.append({
336+
"name": name,
337+
"description": p.manifest.get("description", ""),
338+
"capabilities": [c.get("name") for c in p.manifest.get("capabilities", [])],
339+
"manifest_url": f"/{name}/manifest.json",
340+
"origin": "self",
341+
})
342+
peers_env = os.environ.get("ZHUB_PEERS", "")
343+
peers = [p.strip() for p in peers_env.split(",") if p.strip()]
344+
if peers:
345+
from .federation import PeerRegistry
346+
pr = PeerRegistry(peers)
347+
try:
348+
peer_entries = await pr.aggregate()
349+
finally:
350+
await pr.close()
351+
return JSONResponse(local + peer_entries)
352+
return JSONResponse(local)
353+
326354
@app.get("/{ai_name}/manifest.json")
327355
async def manifest(ai_name: str) -> JSONResponse:
328356
publisher = hub.publishers.get(ai_name)
@@ -673,8 +701,16 @@ def main() -> None:
673701
default="zhub.db",
674702
help="SQLite path for persistent publisher registry. Pass empty string to disable.",
675703
)
704+
parser.add_argument(
705+
"--peers",
706+
default="",
707+
help="Comma-separated peer hub URLs for read-only federation. "
708+
"Peer registries surface at /registry/global with origin annotation.",
709+
)
676710
args = parser.parse_args()
677711
db_path: Optional[str] = args.db if args.db else None
712+
if args.peers:
713+
os.environ["ZHUB_PEERS"] = args.peers
678714

679715
logging.basicConfig(
680716
level=getattr(logging, args.log_level.upper(), logging.INFO),

0 commit comments

Comments
 (0)