Skip to content

Commit 466419f

Browse files
ravwojdyla-agentravwojdylaclaude
authored andcommitted
fix(iris): handle scheme in actor proxy upstream URL (#4157)
## Summary - The iris backend registers actor endpoints with a full URL including scheme (e.g. `http://10.164.0.11:63143`), but the actor proxy unconditionally prepended `http://`, producing a malformed `http://http://...` URL that fails with "Name or service not known" - Use the address as-is when it already contains a scheme; prepend `http://` only for bare `host:port` addresses ## Test plan - [x] Existing `test_actor_proxy.py` tests pass (bare `host:port` path still works) - [ ] Verify proxy works end-to-end with a running zephyr job after controller redeploy 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Rafal Wojdyla <ravwojdyla@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8057f1f commit 466419f

3 files changed

Lines changed: 45 additions & 27 deletions

File tree

lib/iris/src/iris/actor/resolver.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,24 +85,24 @@ class ProxyResolver:
8585
URL so all RPCs go through the proxy. The proxy uses the
8686
``X-Iris-Actor-Endpoint`` header to resolve the actual actor endpoint.
8787
88+
The caller passes the full actor name as registered in the endpoint registry
89+
(e.g. ``/user/job/coordinator/actor-0``).
90+
8891
Args:
8992
controller_url: Controller URL (e.g., ``http://localhost:8080``)
90-
namespace: Namespace prefix for endpoint resolution
9193
"""
9294

93-
def __init__(self, controller_url: str, namespace: str):
95+
def __init__(self, controller_url: str):
9496
self._controller_url = controller_url.rstrip("/")
95-
self._namespace = namespace
9697

9798
def resolve(self, name: str) -> ResolveResult:
98-
endpoint_name = f"{self._namespace}/{name}"
9999
return ResolveResult(
100100
name=name,
101101
endpoints=[
102102
ResolvedEndpoint(
103103
url=self._controller_url,
104-
actor_id=f"proxy-{endpoint_name}",
105-
metadata={ACTOR_ENDPOINT_HEADER: endpoint_name},
104+
actor_id=f"proxy-{name}",
105+
metadata={ACTOR_ENDPOINT_HEADER: name},
106106
)
107107
],
108108
)

lib/iris/src/iris/cluster/controller/actor_proxy.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
Route pattern::
1212
1313
POST /iris.actor.ActorService/{method}
14-
X-Iris-Actor-Endpoint: namespace/actor-name
14+
X-Iris-Actor-Endpoint: /user/job/actor-name
1515
"""
1616

1717
import logging
@@ -72,7 +72,8 @@ async def handle(self, request: Request) -> Response:
7272
status_code=404,
7373
)
7474

75-
upstream_url = f"http://{address}/iris.actor.ActorService/{method}"
75+
base = address if "://" in address else f"http://{address}"
76+
upstream_url = f"{base}/iris.actor.ActorService/{method}"
7677
body = await request.body()
7778
forward_headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP_HEADERS}
7879

lib/iris/tests/actor/test_actor_proxy.py

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,10 @@ def echo(self, message: str) -> str:
3333
return f"echo: {message}"
3434

3535

36-
class FakeEndpointDB:
37-
"""Minimal fake that satisfies ActorProxy._resolve_endpoint via the same interface as ControllerDB."""
38-
39-
def __init__(self):
40-
self._endpoints: dict[str, str] = {}
41-
42-
def register(self, name: str, address: str) -> None:
43-
self._endpoints[name] = address
44-
45-
def resolve(self, name: str) -> str | None:
46-
return self._endpoints.get(name)
47-
48-
4936
class StandaloneActorProxy:
5037
"""A standalone proxy for testing without a full controller.
5138
52-
Wraps ActorProxy's forwarding logic but uses a simple dict-based
39+
Mirrors ActorProxy's forwarding logic but uses a simple dict-based
5340
endpoint registry instead of ControllerDB.
5441
"""
5542

@@ -81,7 +68,8 @@ async def handle(self, request):
8168
status_code=404,
8269
)
8370

84-
upstream_url = f"http://{address}/iris.actor.ActorService/{method}"
71+
base = address if "://" in address else f"http://{address}"
72+
upstream_url = f"{base}/iris.actor.ActorService/{method}"
8573
body = await request.body()
8674
# Forward all headers except hop-by-hop and routing headers.
8775
skip = frozenset({"host", "transfer-encoding", "connection", "keep-alive", ACTOR_ENDPOINT_HEADER})
@@ -125,17 +113,19 @@ def _start_proxy_server(proxy: StandaloneActorProxy, threads: ThreadContainer) -
125113
def test_proxy_round_trip():
126114
"""Full round-trip: client → proxy → actor server → response."""
127115
threads = ThreadContainer()
116+
117+
full_name = "test-ns/status"
128118
actor_server = ActorServer(host="127.0.0.1", threads=threads)
129-
actor_server.register("status", StatusActor())
119+
actor_server.register(full_name, StatusActor())
130120
actor_port = actor_server.serve_background()
131121

132122
proxy = StandaloneActorProxy()
133-
proxy.register("test-ns/status", f"127.0.0.1:{actor_port}")
123+
proxy.register(full_name, f"127.0.0.1:{actor_port}")
134124
proxy_port = _start_proxy_server(proxy, threads)
135125

136126
try:
137-
resolver = ProxyResolver(f"http://127.0.0.1:{proxy_port}", namespace="test-ns")
138-
client = ActorClient(resolver, "status")
127+
resolver = ProxyResolver(f"http://127.0.0.1:{proxy_port}")
128+
client = ActorClient(resolver, full_name)
139129

140130
result = client.get_status()
141131
assert result["documents_processed"] == 1
@@ -151,6 +141,33 @@ def test_proxy_round_trip():
151141
threads.stop()
152142

153143

144+
def test_proxy_namespaced_actor():
145+
"""Proxy forwards to an actor registered under a full namespaced path.
146+
147+
This mirrors real iris backend behavior where actors are registered as
148+
/user/job/coordinator/actor-0 and the address includes the http:// scheme.
149+
"""
150+
threads = ThreadContainer()
151+
152+
full_name = "/user/my-job/coordinator/status-0"
153+
actor_server = ActorServer(host="127.0.0.1", threads=threads)
154+
actor_server.register(full_name, StatusActor())
155+
actor_port = actor_server.serve_background()
156+
157+
proxy = StandaloneActorProxy()
158+
proxy.register(full_name, f"http://127.0.0.1:{actor_port}")
159+
proxy_port = _start_proxy_server(proxy, threads)
160+
161+
try:
162+
resolver = ProxyResolver(f"http://127.0.0.1:{proxy_port}")
163+
client = ActorClient(resolver, full_name)
164+
165+
result = client.get_status()
166+
assert result["documents_processed"] == 1
167+
finally:
168+
threads.stop()
169+
170+
154171
def test_proxy_missing_endpoint_header():
155172
"""Proxy returns 400 when the endpoint header is missing."""
156173
threads = ThreadContainer()

0 commit comments

Comments
 (0)