Prhub

#1943 Implement a typed RPC client handle

原始 PR 作者 fzyzcjy 合并时间 2026-09-04 13:23 文件变更 11 提交数 1 评论 2 代码增减 +632 / -1

执行摘要

实现类型化 RPC 客户端句柄,为工作器管理重构奠定基础。

为 miles 项目的 Rollout 模块重构提供基础通信层。从近期历史 PR(如 PR#1988、#1989、#1997、#2049)可以看出,团队正在将分散的工作器(Worker)启动、管理和通信逻辑统一到 miles.utils.workers 模块下,并引入工作器规格(Worker Spec)和提供者(Provider)抽象。本 PR 实现的类型化 RPC 客户端句柄,是客户端与工作器进程通信的关键组件,使得上层逻辑可以通过类型安全、声明式的方式调用远程工作器方法,无需手动处理 HTTP 请求、序列化/反序列化和错误传播。

本 PR 是 miles 项目 Rollout 模块基础设施重构的基石,值得精读。它展示了如何为分布式工作器通信设计一个类型安全、关注点分离的客户端抽象。核心设计决策,如使用 __getattr__ 实现动态代理、在客户端进行本地参数验证、以及清晰的 RpcCall 生命周期管理,都具有较高的借鉴价值。但需要注意评论中提到的位置参数兼容性问题,这是一个在采用新句柄时需要关注的 breaking change。建议重点关注 handle.py 和 call.py 的实现,理解其类型系统集成和错误处理哲学。

讨论亮点

在 miles/utils/workers/rpc/client/handle.py 的 call 内部函数中,reviewer guapisolo 指出了一个关键的设计权衡和未解决的兼容性问题:位置参数支持缺失。评论原话为:“Could we preserve the worker method positional-call contract here? This wrapper only accepts **kwargs, so await handle.foo(1) fails locally before submission even when the worker method accepts positional arguments. The gap is still present at safe-merge checkpoint #2176; #2313 is the first complete fix, so please move or backport that argument canonicalization before the checkpoint.” 这表明当前实现强制所有 RPC 方法调用必须使用关键字参数,打破了之前可能存在的位置参数调用约定。虽然评论提到在 PR#2313 中有完整修复,但本次 PR 合并时此问题仍未解决,可能导致依赖位置参数的现有代码在迁移到新句柄时出现局部错误。这是一个已识别的、待在后续 PR 中修复的技术债。

实现拆解

  1. 定义抽象基类与错误类型:在 miles/utils/workers/worker_handle.py 中定义了 BaseWorkerHandle 抽象类,强制所有句柄实现 wait_ready 方法,并定义了 WorkerUnreachableError 异常。这是类型系统的基础,确保了所有工作器句柄遵循统一的接口契约。
  2. 实现 HTTP 传输层:在 miles/utils/workers/rpc/client/misc.py 中实现了 RpcTransport 类。它封装了 httpx.AsyncClient,负责构建请求 URL、设置超时、处理非 200 响应(抛出 RpcProtocolError)以及将响应体验证并解析为指定的 Pydantic 模型。它是所有 RPC 通信的底层通道。
  3. 实现调用生命周期管理:在 miles/utils/workers/rpc/client/call.py 中实现了 RpcCall 类。它封装了单个 RPC 调用的完整生命周期:_submit 将调用请求提交到服务器,_poll_until_done 轮询调用状态直至完成或超时,run 方法编排整个流程并处理成功/失败结果的解码。它通过结构化日志记录了调用的各个阶段。
  4. 实现类型化句柄:在 miles/utils/workers/rpc/client/handle.py 中实现了 RpcWorkerHandle 类。它继承自 BaseWorkerHandle,在初始化时通过 collect_rpc_method_specs 收集工作器类的所有 RPC 方法规范,并在构造时检查是否有方法名与句柄属性冲突(防止遮蔽)。其核心是 __getattr__ 方法,它拦截对不存在属性的访问,如果该属性是 RPC 方法名,则返回一个异步函数,该函数内部通过 _perform_call 创建并执行 RpcCall。wait_ready 方法通过轮询健康检查端点来探测服务器就绪状态。
  5. 全面的测试覆盖:在 tests/fast/utils/workers/rpc/client/ 下新增了 test_handle.py 和 test_misc.py,测试覆盖了类型化调用、默认参数、Pydantic 模型往返、远程异常、本地验证、调用超时和就绪探测等场景。同时,修改了端到端测试 tests/fast/utils/workers/e2e/ 下的 conftest.py 和 test_happy_path.py,以使用新的 RpcWorkerHandle 作为标准测试句柄,并增加了并发调用、类型载荷和手动协议等更深入的集成测试。
文件 模块 状态 重要度
miles/utils/workers/rpc/client/handle.py RPC 客户端句柄 added 8.69
miles/utils/workers/rpc/client/call.py RPC 调用管理 added 8.61
miles/utils/workers/rpc/client/misc.py RPC 传输层 added 8.04

关键符号

RpcWorkerHandle.__init__ RpcWorkerHandle.__getattr__ RpcWorkerHandle.wait_ready RpcWorkerHandle._perform_call RpcCall.run RpcCall._submit RpcCall._poll_until_done RpcTransport.request

关键源码片段

miles/utils/workers/rpc/client/handle.py core-logic

实现了核心的 `RpcWorkerHandle` 类,提供基于工作器类定义的类型化、动态代理式 RPC 调用接口,是本次 PR 的功能主体。

# miles/utils/workers/rpc/client/handle.py
from __future__ import annotations
​
import asyncio
import time
from collections.abc import Awaitable, Callable
from typing import Any
​
import httpx
​
from miles.utils.workers.rpc.client.call import RpcCall
from miles.utils.workers.rpc.client.misc import RpcTransport
from miles.utils.workers.rpc.common.metadata import RpcMethodSpec, collect_rpc_method_specs
from miles.utils.workers.rpc.common.protocol import HEALTH_PATH, HealthResponse
from miles.utils.workers.worker_handle import BaseWorkerHandle, WorkerUnreachableError
​
DEFAULT_CALL_TIMEOUT_SECONDS = 3600.0
DEFAULT_READY_TIMEOUT_SECONDS = 600.0
​
_HEALTH_TIMEOUT_SECONDS = 5.0
_HEALTH_RETRY_DELAY_SECONDS = 0.05
​
​
class RpcWorkerHandle(BaseWorkerHandle):
    def __init__(
        self,
        worker_cls: type,
        *,
        server_url: str,
        call_timeout_seconds: float = DEFAULT_CALL_TIMEOUT_SECONDS,
        ready_timeout_seconds: float = DEFAULT_READY_TIMEOUT_SECONDS,
        http_client: httpx.AsyncClient | None = None,
    ) -> None:
        # 从工作器类收集所有 RPC 方法规范,用于动态代理和类型检查
        self._specs = collect_rpc_method_specs(worker_cls)
        # 检查是否有 RPC 方法名与句柄自身方法名冲突(如 wait_ready),防止意外遮蔽
        shadowed = sorted(name for name in self._specs if hasattr(type(self), name))
        if shadowed:
            raise TypeError(f"{worker_cls.__name__} rpc methods shadow handle attributes: {shadowed}")
​
        self._worker_cls_name = worker_cls.__name__
        self._call_timeout_seconds = call_timeout_seconds
        self._ready_timeout_seconds = ready_timeout_seconds
        # 创建底层的 RPC 传输对象
        self._transport = RpcTransport(server_url=server_url, http_client=http_client)
​
    def __getattr__(self, name: str) -> Callable[..., Awaitable[Any]]:
        # 如果访问私有属性,直接抛出 AttributeError,避免与动态方法代理混淆
        if name.startswith("_"):
            raise AttributeError(name)
        # 尝试从收集的方法规范中获取对应的规范
        spec = self._specs.get(name)
        if spec is None:
            raise AttributeError(f"{self._worker_cls_name} has no rpc method {name!r}")
​
        # 为该 RPC 方法创建一个闭包,该闭包在被调用时会执行远程调用
        async def call(**kwargs: Any) -> Any:
            # 注意:当前实现强制使用关键字参数,不支持位置参数(review 中已指出此限制)
            return await self._perform_call(spec=spec, kwargs=kwargs)
​
        return call
​
    async def wait_ready(self, *, timeout: float) -> None:
        # 通过轮询健康检查端点来探测服务器是否就绪
        expires_at = time.monotonic() + timeout
        last_error: Exception | None = None
​
        while time.monotonic() < expires_at:
            try:
                await self._transport.request(
                    "GET", HEALTH_PATH, seconds=_HEALTH_TIMEOUT_SECONDS, response_model=HealthResponse
                )
                return # 健康检查成功,服务器就绪
            except (httpx.TransportError, TimeoutError, asyncio.TimeoutError) as e:
                last_error = e
                await asyncio.sleep(_HEALTH_RETRY_DELAY_SECONDS)
​
        # 超时后,抛出 WorkerUnreachableError 异常
        raise WorkerUnreachableError(f"{self._worker_cls_name} rpc server not ready within {timeout}s: {last_error!r}")
​
    async def _perform_call(self, *, spec: RpcMethodSpec, kwargs: dict[str, Any]) -> Any:
        # 创建一个 RpcCall 对象来管理本次调用的完整生命周期
        call = RpcCall(
            spec=spec,
            kwargs=kwargs,
            worker_cls_name=self._worker_cls_name,
            transport=self._transport,
            call_timeout_seconds=self._call_timeout_seconds,
        )
​
        # 执行调用并返回结果
        return await call.run()
miles/utils/workers/rpc/client/misc.py dependency-wiring

实现了 `RpcTransport` 类和自定义异常,封装了底层的 HTTP 通信细节和协议错误处理,为 `RpcCall` 提供统一的请求接口。

# miles/utils/workers/rpc/client/misc.py
from __future__ import annotations
​
import asyncio
from typing import Any, TypeVar
​
import httpx
from pydantic import BaseModel
​
from miles.utils.http_utils import GeneralHttpClientProvider
​
_ResponseT = TypeVar("_ResponseT", bound=BaseModel)
​
​
class RpcProtocolError(Exception):
    def __init__(self, message: str, *, status_code: int) -> None:
        super().__init__(message)
        self.status_code = status_code # 保存原始的 HTTP 状态码
​
​
class RpcWorkerCallError(Exception):
    pass # 表示远程工作器调用业务逻辑失败
​
​
class RpcTransport:
    def __init__(self, *, server_url: str, http_client: httpx.AsyncClient | None) -> None:
        self._server_url = server_url.rstrip("/")
        self._http_client_override = http_client # 允许注入测试用的 httpx 客户端
​
    async def request(
        self, method: str, path: str, *, seconds: float, response_model: type[_ResponseT], **kwargs: Any
    ) -> _ResponseT:
        # 使用 asyncio.wait_for 确保整个请求在给定超时时间内完成
        response = await asyncio.wait_for(
            self._client.request(method, f"{self._server_url}{path}", timeout=seconds, **kwargs),
            timeout=seconds,
        )
​
        # 检查 HTTP 状态码,非 200 视为协议错误
        if response.status_code != 200:
            raise RpcProtocolError(
                f"{method} {path} rejected ({response.status_code}): {response.text}",
                status_code=response.status_code,
            )
        # 将 JSON 响应体验证并解析为指定的 Pydantic 模型
        return response_model.model_validate(response.json())
​
    @property
    def _client(self) -> httpx.AsyncClient:
        # 优先使用外部注入的客户端(如测试场景),否则使用全局默认客户端
        if self._http_client_override is not None:
            return self._http_client_override
        return GeneralHttpClientProvider.client()

评论区精华

RPC 方法调用位置参数支持缺失 正确性

Reviewer guapisolo 指出,当前 `RpcWorkerHandle.__getattr__` 中生成的 `call` 闭包只接受 `**kwargs`,导致 `await handle.foo(1)` 这样的位置参数调用会因本地验证失败而报错,即使远程工作器方法支持位置参数。这破坏了原有的方法调用契约。 > 引用原话:"Could we preserve the worker method positional-call contract here? This wrapper only accepts `**kwargs`, so `await handle.foo(1)` fails locally before submission even when the worker method accepts positional arguments."

结论:已识别为一个需要修复的兼容性问题。评论提到此差距在 checkpoint #2176 时仍然存在,而完整的修复在后续的 PR#2313 中。本次 PR 合并时,该问题被接受但未解决,需要在后续工作中回溯或合并参数规范化逻辑。 · 待处理

风险与影响

  1. 类型系统破坏性变更:RpcWorkerHandle 强制所有方法调用必须为关键字参数,可能破坏现有代码中依赖位置参数的调用方式,导致运行时 TypeError。此风险已被 review 明确指出。
  2. 轮询性能与开销:RpcCall._poll_until_done 使用固定间隔(0.05秒)轮询状态,对于低延迟或高吞吐场景可能不是最优方案,且会产生持续的 HTTP 请求负载。
  3. 超时控制的层次:存在三个层次的超时:RpcTransport.request 的请求级超时、RpcCall._poll_until_done 的调用总超时、RpcWorkerHandle.wait_ready 的就绪探测超时。开发者需要正确理解和配置这些超时值,否则可能导致意外的提前失败或长时间挂起。
  4. 异常封装与丢失:远程工作器的业务异常被包装为 RpcWorkerCallError,其原始的调用栈信息可能被简化或丢失(从测试看,仅保留异常类型和消息)。这可能会增加远程调试的难度。
  1. 代码结构影响:新增了 miles/utils/workers/rpc/client/ 包,清晰地划分了 RPC 客户端的职责(传输、调用、句柄),提升了代码的模块化和可维护性。
  2. 后续重构基础:为 PR#1989(RayWorkerManager)、PR#1997(路由器/会话服务器 Worker Spec)、PR#2049(WorkerProvider 抽象)等提供了统一的客户端接口,使得上层工作器管理逻辑可以与具体的传输和通信细节解耦。
  3. 开发者体验:提供了类型安全、符合直觉的 API(await handle.method(**args)),减少了手动编写 HTTP 客户端代码和错误处理的负担。但位置参数的缺失是一个体验上的退化。
  4. 测试覆盖:引入了全面的单元测试和集成测试,显著提高了 RPC 客户端层的代码质量和可靠性。端到端测试的更新确保了新旧组件能协同工作。
类型系统破坏现有代码(位置参数不兼容) 轮询机制性能开销 超时控制层次复杂

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论