Repository: bdaiinstitute/spot_wrapper
Pull request #164: [WUD-2003] Add reverse proxy servicer
Author: login: mhidalgo-rai
Created: 2025-04-09T13:44:32Z | Merged: 2025-04-11T20:02:39Z

This patch updates the `spot_wrapper.testing` infrastructure to support automatic gRPC proxying in addition to gRPC mocking. It reworks some it in the process, for which additional unit tests have been added.


Diff:
diff --git a/spot_wrapper/testing/fixtures.py b/spot_wrapper/testing/fixtures.py
index 805eb8ba..a3b6a660 100644
--- a/spot_wrapper/testing/fixtures.py
+++ b/spot_wrapper/testing/fixtures.py
@@ -13,28 +13,29 @@
     DEFAULT_TESTING_CERTIFICATES,
     SpotSSLCertificates,
 )
-from spot_wrapper.testing.mocks import BaseMockSpot
+from spot_wrapper.testing.services import BaseSpotServicer
 
 
 @dataclasses.dataclass
 class SpotFixture:
     """
-    A fixture that emulates a Spot robot.
+    A fixture that provides Spot robot services.
 
     Attributes:
-        address: address for the emulated Spot robot gRPC server.
-        port: port for the emulated Spot robot gRPC server.
-        api: mocked API for the emulated Spot robot.
+        address: address of the underlying gRPC server.
+        port: port of the underlying gRPC server.
+        certificate_path: SSL certificates used by the underlying gRPC server.
+        api: access to Spot services implementation.
     """
 
     address: str
     port: int
     certificate_path: pathlib.Path
-    api: BaseMockSpot
+    api: BaseSpotServicer
 
 
 def fixture(
-    cls: typing.Optional[typing.Type[BaseMockSpot]] = None,
+    cls: typing.Optional[typing.Type[BaseSpotServicer]] = None,
     *,
     address: str = "127.0.0.1",
     max_workers: int = 10,
@@ -42,24 +43,22 @@ def fixture(
     **kwargs: typing.Any,
 ) -> typing.Callable:
     """
-    A `pytest.fixture` of a mocked Spot robot.
+    Spot robot services as a `pytest.fixture`.
 
-    This function takes a Spot mock class by decorating it. Spot mock classes may
-    request parameters like any `pytest.fixture` would do by specifying them in their
-    __init__ methods.
-
-    Mocked Spot robots are served through insecure gRPC channels. To enable Spot SDK
-    use, this fixture patches gRPC secure channel creation, forcing them to be insecure.
+    This function decorates a class that implements Spot robot services.
+    Such classes request parameters like any `pytest.fixture` would do by specifying
+    them in their __init__ methods.
 
     Args:
-        address: address for underlying gRPC server.
-        port: port number for underlying gRPC server.
+        address: address for the underlying gRPC server.
+        port: port number for the underlying gRPC server.
+        certificate_path: SSL certificates for the the underlying gRPC server.
         max_workers: maximum number of threads to use for gRPC call servicing.
 
     Other keyword arguments are forwarded to `pytest.fixture`.
     """
 
-    def decorator(cls: typing.Type[BaseMockSpot]) -> typing.Callable:
+    def decorator(cls: typing.Type[BaseSpotServicer]) -> typing.Callable:
         def fixturefunc(**kwargs) -> typing.Iterator[SpotFixture]:
             with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as thread_pool:
                 server = grpc.server(thread_pool)
diff --git a/spot_wrapper/testing/grpc.py b/spot_wrapper/testing/grpc.py
index 95149db4..fd9ff5ec 100644
--- a/spot_wrapper/testing/grpc.py
+++ b/spot_wrapper/testing/grpc.py
@@ -1,6 +1,7 @@
 # Copyright (c) 2023 Boston Dynamics AI Institute LLC. See LICENSE file for more info.
 
 import collections.abc
+import concurrent.futures
 import inspect
 import logging
 import math
@@ -88,7 +89,14 @@ def collect_service_types(servicer: typing.Any) -> typing.Iterable[str]:
     yield from accumulator.service_types
 
 
-class AutoServicer(object):
+class BaseServicer:
+    def add_to(self, server: grpc.Server) -> None:
+        """Adds all service handlers to server."""
+        for add in collect_servicer_add_functions(self.__class__):
+            add(self, server)
+
+
+class AutoServicer(BaseServicer):
     """
     A mocking gRPC servicer to ease testing.
 
@@ -158,11 +166,6 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
                     if underlying_callable is not handler.unary_unary:
                         setattr(self, unqualified_name, underlying_callable)
 
-    def add_to(self, server: grpc.Server) -> None:
-        """Adds all service handlers to server."""
-        for add in collect_servicer_add_functions(self.__class__):
-            add(self, server)
-
     def __enter__(self):
         return self
 
@@ -599,3 +602,84 @@ def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing
             yield from call.response
         else:
             context.abort(call.code, call.details)
+
+
+class BackServicer(typing.Protocol):
+    """Protocol for a servicer that can handle back requests, typically from a proxy."""
+
+    def submit(self, request: typing.Any) -> concurrent.futures.Future:
+        """Submit a unary or streaming request.
+
+        Returns a future unary or streaming response.
+        """
+
+
+class ReverseProxyServicer(BaseServicer):
+    """
+    A gRPC servicer reverse proxy to relay requests.
+
+    Attributes:
+        proxy_services: optional set of services to reverse proxy.
+        If none is provided, proxy will apply to all found services.
+    """
+
+    proxy_services: typing.Optional[typing.Set[str]] = None
+
+    def __init__(self, *args, server: BackServicer, **kwargs) -> None:
+        """Initializes the reverse proxy, binding to the given `server`."""
+        super().__init__(*args, **kwargs)
+        for endpoint_name, handler in collect_method_handlers(self):
+            service_name, _, method_name = endpoint_name.rpartition("/")
+            if self.proxy_services is not None:
+                if not any(proxy_service_name.endswith(service_name) for proxy_service_name in self.proxy_services):
+                    continue
+            proxy_cls: typing.Type
+            if handler.response_streaming:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_stream
+                else:
+                    underlying_callable = handler.unary_stream
+                proxy_cls = ProxiedStreamRpcHandler
+            else:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_unary
+                else:
+                    underlying_callable = handler.unary_unary
+                proxy_cls = ProxiedUnaryRpcHandler
+            if implemented(underlying_callable):
+                raise RuntimeError(f"{endpoint_name} is already implemented")
+            setattr(self, method_name, proxy_cls(server, underlying_callable))
+
+
+class ProxiedRpcHandler(ABC):
+    """A generic gRPC handler that proxies requests."""
+
+    def __init__(self, server: BackServicer, stub: typing.Callable) -> None:
+        for name in ("__module__", "__name__", "__qualname__", "__doc__", "__annotations__", "__type_params__"):
+            try:
+                value = getattr(stub, name)
+            except AttributeError:
+                pass
+            else:
+                setattr(self, name, value)
+        self._server = server
+
+    @abstractmethod
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        ...
+
+
+class ProxiedUnaryRpcHandler(ProxiedRpcHandler):
+    """A gRPC any-unary handler that proxies requests."""
+
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        future = self._server.submit(request)
+        return future.result(timeout=context.time_remaining())
+
+
+class ProxiedStreamRpcHandler(ProxiedRpcHandler):
+    """A gRPC any-stream handler that proxies requests."""
+
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Iterator:
+        future = self._server.submit(request)
+        yield from future.result(timeout=context.time_remaining())
diff --git a/spot_wrapper/testing/mocks/__init__.py b/spot_wrapper/testing/mocks/__init__.py
index 3d1b1acb..3d96d212 100644
--- a/spot_wrapper/testing/mocks/__init__.py
+++ b/spot_wrapper/testing/mocks/__init__.py
@@ -1,93 +1,6 @@
 # Copyright (c) 2023 Boston Dynamics AI Institute LLC. See LICENSE file for more info.
 
-import typing
-
-from bosdyn.api.arm_surface_contact_service_pb2_grpc import (
-    ArmSurfaceContactServiceServicer,
-)
-from bosdyn.api.auth_service_pb2_grpc import AuthServiceServicer
-from bosdyn.api.auto_return.auto_return_service_pb2_grpc import (
-    AutoReturnServiceServicer,
-)
-from bosdyn.api.autowalk.autowalk_service_pb2_grpc import AutowalkServiceServicer
-from bosdyn.api.data_acquisition_plugin_service_pb2_grpc import (
-    DataAcquisitionPluginServiceServicer,
-)
-from bosdyn.api.data_acquisition_store_service_pb2_grpc import (
-    DataAcquisitionStoreServiceServicer,
-)
-from bosdyn.api.data_buffer_service_pb2_grpc import DataBufferServiceServicer
-from bosdyn.api.data_service_pb2_grpc import DataServiceServicer
-from bosdyn.api.directory_registration_service_pb2_grpc import (
-    DirectoryRegistrationServiceServicer,
-)
-from bosdyn.api.directory_service_pb2_grpc import DirectoryServiceServicer
-from bosdyn.api.docking.docking_service_pb2_grpc import DockingServiceServicer
-from bosdyn.api.estop_service_pb2_grpc import EstopServiceServicer
-from bosdyn.api.fault_service_pb2_grpc import FaultServiceServicer
-from bosdyn.api.graph_nav.area_callback_service_pb2_grpc import (
-    AreaCallbackServiceServicer,
-)
-from bosdyn.api.graph_nav.graph_nav_service_pb2_grpc import GraphNavServiceServicer
-from bosdyn.api.graph_nav.map_processing_service_pb2_grpc import (
-    MapProcessingServiceServicer,
-)
-from bosdyn.api.graph_nav.recording_service_pb2_grpc import (
-    GraphNavRecordingServiceServicer,
-)
-from bosdyn.api.gripper_camera_param_service_pb2_grpc import (
-    GripperCameraParamServiceServicer,
-)
-from bosdyn.api.image_service_pb2_grpc import ImageServiceServicer
-from bosdyn.api.ir_enable_disable_service_pb2_grpc import IREnableDisableServiceServicer
-from bosdyn.api.keepalive.keepalive_service_pb2_grpc import KeepaliveServiceServicer
-from bosdyn.api.lease_service_pb2_grpc import LeaseServiceServicer
-from bosdyn.api.license_service_pb2_grpc import LicenseServiceServicer
-from bosdyn.api.local_grid_service_pb2_grpc import LocalGridServiceServicer
-from bosdyn.api.log_status.log_status_service_pb2_grpc import LogStatusServiceServicer
-from bosdyn.api.manipulation_api_service_pb2_grpc import ManipulationApiServiceServicer
-from bosdyn.api.mission.mission_service_pb2_grpc import MissionServiceServicer
-from bosdyn.api.mission.remote_service_pb2_grpc import RemoteMissionServiceServicer
-from bosdyn.api.network_compute_bridge_service_pb2_grpc import (
-    NetworkComputeBridgeWorkerServicer,
-)
-from bosdyn.api.payload_registration_service_pb2_grpc import (
-    PayloadRegistrationServiceServicer,
-)
-from bosdyn.api.payload_service_pb2_grpc import PayloadServiceServicer
-from bosdyn.api.point_cloud_service_pb2_grpc import PointCloudServiceServicer
-from bosdyn.api.power_service_pb2_grpc import (
-    PowerServiceServicer as PowerCommandServiceServicer,
-)
-from bosdyn.api.robot_command_service_pb2_grpc import (
-    RobotCommandServiceServicer,
-    RobotCommandStreamingServiceServicer,
-)
-from bosdyn.api.robot_id_service_pb2_grpc import RobotIdServiceServicer
-from bosdyn.api.robot_state_service_pb2_grpc import (
-    RobotStateServiceServicer,
-    RobotStateStreamingServiceServicer,
-)
-from bosdyn.api.spot.choreography_service_pb2_grpc import ChoreographyServiceServicer
-from bosdyn.api.spot.door_service_pb2_grpc import DoorServiceServicer
-from bosdyn.api.spot.inverse_kinematics_service_pb2_grpc import InverseKinematicsServiceServicer
-from bosdyn.api.spot.spot_check_service_pb2_grpc import SpotCheckServiceServicer
-from bosdyn.api.spot_cam.service_pb2_grpc import (
-    AudioServiceServicer,
-    CompositorServiceServicer,
-    HealthServiceServicer,
-    LightingServiceServicer,
-    MediaLogServiceServicer,
-    NetworkServiceServicer,
-    PowerServiceServicer,
-    PtzServiceServicer,
-    StreamQualityServiceServicer,
-    VersionServiceServicer,
-)
-from bosdyn.api.time_sync_service_pb2_grpc import TimeSyncServiceServicer
-from bosdyn.api.world_object_service_pb2_grpc import WorldObjectServiceServicer
-
-from spot_wrapper.testing.grpc import AutoServicer, collect_method_handlers
+from spot_wrapper.testing.grpc import AutoServicer
 from spot_wrapper.testing.mocks.auth import MockAuthService
 from spot_wrapper.testing.mocks.cam import MockCAMService
 from spot_wrapper.testing.mocks.directory import MockDirectoryService
@@ -100,70 +13,11 @@
 from spot_wrapper.testing.mocks.robot_id import MockRobotIdService
 from spot_wrapper.testing.mocks.robot_state import MockRobotStateService
 from spot_wrapper.testing.mocks.time_sync import MockTimeSyncService
+from spot_wrapper.testing.services import BaseSpotServicer
 
 
-class BaseMockSpot(
-    AutoServicer,
-    AreaCallbackServiceServicer,
-    ArmSurfaceContactServiceServicer,
-    AudioServiceServicer,
-    AuthServiceServicer,
-    AutoReturnServiceServicer,
-    AutowalkServiceServicer,
-    ChoreographyServiceServicer,
-    CompositorServiceServicer,
-    DataAcquisitionPluginServiceServicer,
-    DataAcquisitionStoreServiceServicer,
-    DataBufferServiceServicer,
-    DataServiceServicer,
-    DirectoryRegistrationServiceServicer,
-    DirectoryServiceServicer,
-    DockingServiceServicer,
-    DoorServiceServicer,
-    EstopServiceServicer,
-    FaultServiceServicer,
-    GraphNavRecordingServiceServicer,
-    GraphNavServiceServicer,
-    GripperCameraParamServiceServicer,
-    HealthServiceServicer,
-    IREnableDisableServiceServicer,
-    ImageServiceServicer,
-    InverseKinematicsServiceServicer,
-    KeepaliveServiceServicer,
-    LeaseServiceServicer,
-    LicenseServiceServicer,
-    LightingServiceServicer,
-    LocalGridServiceServicer,
-    LogStatusServiceServicer,
-    ManipulationApiServiceServicer,
-    MapProcessingServiceServicer,
-    MediaLogServiceServicer,
-    MissionServiceServicer,
-    NetworkComputeBridgeWorkerServicer,
-    NetworkServiceServicer,
-    PayloadRegistrationServiceServicer,
-    PayloadServiceServicer,
-    PointCloudServiceServicer,
-    PowerCommandServiceServicer,
-    PowerServiceServicer,
-    PtzServiceServicer,
-    RemoteMissionServiceServicer,
-    RobotCommandServiceServicer,
-    RobotCommandStreamingServiceServicer,
-    RobotIdServiceServicer,
-    RobotStateServiceServicer,
-    RobotStateStreamingServiceServicer,
-    SpotCheckServiceServicer,
-    StreamQualityServiceServicer,
-    TimeSyncServiceServicer,
-    VersionServiceServicer,
-    WorldObjectServiceServicer,
-):
-    """
-    Base Spot mock.
-
-    It implements nothing.
-    """
+class BaseMockSpot(AutoServicer, BaseSpotServicer):
+    """Base Spot mock."""
 
     name = "mockie"
     autocomplete = True
diff --git a/spot_wrapper/testing/services.py b/spot_wrapper/testing/services.py
new file mode 100644
index 00000000..19a0ff39
--- /dev/null
+++ b/spot_wrapper/testing/services.py
@@ -0,0 +1,151 @@
+# Copyright (c) 2023 Boston Dynamics AI Institute LLC. See LICENSE file for more info.
+
+from bosdyn.api.arm_surface_contact_service_pb2_grpc import (
+    ArmSurfaceContactServiceServicer,
+)
+from bosdyn.api.auth_service_pb2_grpc import AuthServiceServicer
+from bosdyn.api.auto_return.auto_return_service_pb2_grpc import (
+    AutoReturnServiceServicer,
+)
+from bosdyn.api.autowalk.autowalk_service_pb2_grpc import AutowalkServiceServicer
+from bosdyn.api.data_acquisition_plugin_service_pb2_grpc import (
+    DataAcquisitionPluginServiceServicer,
+)
+from bosdyn.api.data_acquisition_store_service_pb2_grpc import (
+    DataAcquisitionStoreServiceServicer,
+)
+from bosdyn.api.data_buffer_service_pb2_grpc import DataBufferServiceServicer
+from bosdyn.api.data_service_pb2_grpc import DataServiceServicer
+from bosdyn.api.directory_registration_service_pb2_grpc import (
+    DirectoryRegistrationServiceServicer,
+)
+from bosdyn.api.directory_service_pb2_grpc import DirectoryServiceServicer
+from bosdyn.api.docking.docking_service_pb2_grpc import DockingServiceServicer
+from bosdyn.api.estop_service_pb2_grpc import EstopServiceServicer
+from bosdyn.api.fault_service_pb2_grpc import FaultServiceServicer
+from bosdyn.api.graph_nav.area_callback_service_pb2_grpc import (
+    AreaCallbackServiceServicer,
+)
+from bosdyn.api.graph_nav.graph_nav_service_pb2_grpc import GraphNavServiceServicer
+from bosdyn.api.graph_nav.map_processing_service_pb2_grpc import (
+    MapProcessingServiceServicer,
+)
+from bosdyn.api.graph_nav.recording_service_pb2_grpc import (
+    GraphNavRecordingServiceServicer,
+)
+from bosdyn.api.gripper_camera_param_service_pb2_grpc import (
+    GripperCameraParamServiceServicer,
+)
+from bosdyn.api.image_service_pb2_grpc import ImageServiceServicer
+from bosdyn.api.ir_enable_disable_service_pb2_grpc import IREnableDisableServiceServicer
+from bosdyn.api.keepalive.keepalive_service_pb2_grpc import KeepaliveServiceServicer
+from bosdyn.api.lease_service_pb2_grpc import LeaseServiceServicer
+from bosdyn.api.license_service_pb2_grpc import LicenseServiceServicer
+from bosdyn.api.local_grid_service_pb2_grpc import LocalGridServiceServicer
+from bosdyn.api.log_status.log_status_service_pb2_grpc import LogStatusServiceServicer
+from bosdyn.api.manipulation_api_service_pb2_grpc import ManipulationApiServiceServicer
+from bosdyn.api.mission.mission_service_pb2_grpc import MissionServiceServicer
+from bosdyn.api.mission.remote_service_pb2_grpc import RemoteMissionServiceServicer
+from bosdyn.api.network_compute_bridge_service_pb2_grpc import (
+    NetworkComputeBridgeWorkerServicer,
+)
+from bosdyn.api.payload_registration_service_pb2_grpc import (
+    PayloadRegistrationServiceServicer,
+)
+from bosdyn.api.payload_service_pb2_grpc import PayloadServiceServicer
+from bosdyn.api.point_cloud_service_pb2_grpc import PointCloudServiceServicer
+from bosdyn.api.power_service_pb2_grpc import (
+    PowerServiceServicer as PowerCommandServiceServicer,
+)
+from bosdyn.api.robot_command_service_pb2_grpc import (
+    RobotCommandServiceServicer,
+    RobotCommandStreamingServiceServicer,
+)
+from bosdyn.api.robot_id_service_pb2_grpc import RobotIdServiceServicer
+from bosdyn.api.robot_state_service_pb2_grpc import (
+    RobotStateServiceServicer,
+    RobotStateStreamingServiceServicer,
+)
+from bosdyn.api.spot.choreography_service_pb2_grpc import ChoreographyServiceServicer
+from bosdyn.api.spot.door_service_pb2_grpc import DoorServiceServicer
+from bosdyn.api.spot.inverse_kinematics_service_pb2_grpc import InverseKinematicsServiceServicer
+from bosdyn.api.spot.spot_check_service_pb2_grpc import SpotCheckServiceServicer
+from bosdyn.api.spot_cam.service_pb2_grpc import (
+    AudioServiceServicer,
+    CompositorServiceServicer,
+    HealthServiceServicer,
+    LightingServiceServicer,
+    MediaLogServiceServicer,
+    NetworkServiceServicer,
+    PowerServiceServicer,
+    PtzServiceServicer,
+    StreamQualityServiceServicer,
+    VersionServiceServicer,
+)
+from bosdyn.api.time_sync_service_pb2_grpc import TimeSyncServiceServicer
+from bosdyn.api.world_object_service_pb2_grpc import WorldObjectServiceServicer
+
+
+class BaseSpotServicer(
+    AreaCallbackServiceServicer,
+    ArmSurfaceContactServiceServicer,
+    AudioServiceServicer,
+    AuthServiceServicer,
+    AutoReturnServiceServicer,
+    AutowalkServiceServicer,
+    ChoreographyServiceServicer,
+    CompositorServiceServicer,
+    DataAcquisitionPluginServiceServicer,
+    DataAcquisitionStoreServiceServicer,
+    DataBufferServiceServicer,
+    DataServiceServicer,
+    DirectoryRegistrationServiceServicer,
+    DirectoryServiceServicer,
+    DockingServiceServicer,
+    DoorServiceServicer,
+    EstopServiceServicer,
+    FaultServiceServicer,
+    GraphNavRecordingServiceServicer,
+    GraphNavServiceServicer,
+    GripperCameraParamServiceServicer,
+    HealthServiceServicer,
+    IREnableDisableServiceServicer,
+    ImageServiceServicer,
+    InverseKinematicsServiceServicer,
+    KeepaliveServiceServicer,
+    LeaseServiceServicer,
+    LicenseServiceServicer,
+    LightingServiceServicer,
+    LocalGridServiceServicer,
+    LogStatusServiceServicer,
+    ManipulationApiServiceServicer,
+    MapProcessingServiceServicer,
+    MediaLogServiceServicer,
+    MissionServiceServicer,
+    NetworkComputeBridgeWorkerServicer,
+    NetworkServiceServicer,
+    PayloadRegistrationServiceServicer,
+    PayloadServiceServicer,
+    PointCloudServiceServicer,
+    PowerCommandServiceServicer,
+    PowerServiceServicer,
+    PtzServiceServicer,
+    RemoteMissionServiceServicer,
+    RobotCommandServiceServicer,
+    RobotCommandStreamingServiceServicer,
+    RobotIdServiceServicer,
+    RobotStateServiceServicer,
+    RobotStateStreamingServiceServicer,
+    SpotCheckServiceServicer,
+    StreamQualityServiceServicer,
+    TimeSyncServiceServicer,
+    VersionServiceServicer,
+    WorldObjectServiceServicer,
+):
+    """
+    Base Spot services servicer.
+
+    It implements nothing.
+    """
+
+    name: str
diff --git a/spot_wrapper/tests/test_auto_servicer.py b/spot_wrapper/tests/test_auto_servicer.py
new file mode 100644
index 00000000..feb7a667
--- /dev/null
+++ b/spot_wrapper/tests/test_auto_servicer.py
@@ -0,0 +1,127 @@
+# Copyright (c) 2025 Boston Dynamics AI Institute LLC. See LICENSE file for more info.
+
+import concurrent.futures
+import math
+from typing import Iterator
+
+import grpc
+import pytest
+from bosdyn.api.autowalk.autowalk_service_pb2_grpc import AutowalkServiceStub
+from bosdyn.api.data_chunk_pb2 import DataChunk
+from bosdyn.api.header_pb2 import CommonError
+from bosdyn.api.robot_command_pb2 import (
+    JointControlStreamRequest,
+    JointControlStreamResponse,
+    RobotCommandRequest,
+    RobotCommandResponse,
+)
+from bosdyn.api.robot_command_service_pb2_grpc import RobotCommandServiceStub, RobotCommandStreamingServiceStub
+from bosdyn.api.robot_state_pb2 import RobotStateStreamRequest, RobotStateStreamResponse
+from bosdyn.api.robot_state_service_pb2_grpc import RobotStateStreamingServiceStub
+
+from spot_wrapper.testing.grpc import AutoServicer
+from spot_wrapper.testing.services import BaseSpotServicer
+
+PORT = 50051
+
+
+class AutoSpotServicer(AutoServicer, BaseSpotServicer):
+    autospec = True
+    autotrack = True
+    autocomplete = True
+
+
+@pytest.fixture
+def auto_spot_servicer() -> Iterator[AutoSpotServicer]:
+    with concurrent.futures.ThreadPoolExecutor() as thread_pool:
+        server = grpc.server(thread_pool)
+        server.add_insecure_port(f"localhost:{PORT}")
+        with AutoSpotServicer() as servicer:
+            servicer.add_to(server)
+            server.start()
+            try:
+                yield servicer
+            finally:
+                server.stop(grace=None)
+
+
+@pytest.fixture
+def auto_spot_channel(auto_spot_servicer: AutoSpotServicer) -> Iterator[grpc.Channel]:
+    with grpc.insecure_channel(f"localhost:{PORT}") as channel:
+        yield channel
+
+
+def test_unary_unary_auto_service(auto_spot_servicer: AutoSpotServicer, auto_spot_channel: grpc.Channel) -> None:
+    stub = RobotCommandServiceStub(auto_spot_channel)
+    expected_response = RobotCommandResponse()
+    expected_response.status = RobotCommandResponse.Status.STATUS_OK
+    auto_spot_servicer.RobotCommand.future.returns(expected_response)
+    expected_request = RobotCommandRequest()
+    expected_request.header.client_name = "test"
+    expected_request.command.full_body_command.stop_request.SetInParent()
+    response = stub.RobotCommand(expected_request)
+    assert auto_spot_servicer.RobotCommand.num_calls == 1
+    request = auto_spot_servicer.RobotCommand.requests[0]
+    assert request.command.full_body_command.HasField("stop_request")
+    assert response.header.request_header.client_name == request.header.client_name
+    assert response.header.error.code == CommonError.Code.CODE_OK
+    assert response.status == expected_response.status
+
+
+def test_unary_stream_auto_service(auto_spot_servicer: AutoSpotServicer, auto_spot_channel: grpc.Channel) -> None:
+    stub = RobotStateStreamingServiceStub(auto_spot_channel)
+    expected_response_stream: list[RobotStateStreamResponse] = []
+    for i in range(10):
+        response = RobotStateStreamResponse()
+        response.joint_states.position.append(i)
+        expected_response_stream.append(response)
+    auto_spot_servicer.GetRobotStateStream.future.returns(expected_response_stream)
+    expected_request = RobotStateStreamRequest()
+    expected_request.header.client_name = "test"
+    response_stream = list(stub.GetRobotStateStream(expected_request))
+    for i, (expected_response, response) in enumerate(zip(expected_response_stream, response_stream)):
+        assert response.header.request_header.client_name == expected_request.header.client_name
+        assert response.header.error.code == CommonError.Code.CODE_OK
+        assert (
+            response.joint_states.position[0] == expected_response.joint_states.position[0]
+        ), f"Bad joint position at index {i}"
+    assert auto_spot_servicer.GetRobotStateStream.num_calls == 1
+    request = auto_spot_servicer.GetRobotStateStream.requests[0]
+    assert request.header.client_name == expected_request.header.client_name
+
+
+def test_stream_unary_auto_service(auto_spot_servicer: AutoSpotServicer, auto_spot_channel: grpc.Channel) -> None:
+    stub = RobotCommandStreamingServiceStub(auto_spot_channel)
+    expected_response = JointControlStreamResponse()
+    expected_response.status = JointControlStreamResponse.Status.STATUS_OK
+    auto_spot_servicer.JointControlStream.future.returns(expected_response)
+    expected_request_stream: list[JointControlStreamRequest] = []
+    for i in range(10):
+        request = JointControlStreamRequest()
+        request.header.client_name = "test"
+        request.joint_command.position.append(i * math.pi / 10)
+        expected_request_stream.append(request)
+    response = stub.JointControlStream(iter(expected_request_stream))
+    assert auto_spot_servicer.JointControlStream.num_calls == 1
+    request_stream = auto_spot_servicer.JointControlStream.requests[0]
+    for i, (expected_request, request) in enumerate(zip(expected_request_stream, request_stream)):
+        assert (
+            expected_request.joint_command.position[0] == request.joint_command.position[0]
+        ), f"Bad joint position command at index {i}"
+    assert response.header.request_header.client_name == expected_request_stream[0].header.client_name
+    assert response.header.error.code == CommonError.Code.CODE_OK
+    assert response.status == expected_response.status
+
+
+def test_stream_stream_auto_service(auto_spot_servicer: AutoSpotServicer, auto_spot_channel: grpc.Channel) -> None:
+    stub = AutowalkServiceStub(auto_spot_channel)
+    expected_request_stream = [DataChunk(total_size=i * 100) for i in range(10)]
+    expected_response_stream = [DataChunk(total_size=i * 10) for i in range(10)]
+    auto_spot_servicer.CompileAutowalk.future.returns(expected_response_stream)
+    response_stream = list(stub.CompileAutowalk(iter(expected_request_stream)))
+    for i, (expected_chunk, chunk) in enumerate(zip(expected_response_stream, response_stream)):
+        assert expected_chunk.total_size == chunk.total_size, f"Bad response chunk at index {i}"
+    assert auto_spot_servicer.CompileAutowalk.num_calls == 1
+    request_stream = auto_spot_servicer.CompileAutowalk.requests[0]
+    for i, (expected_chunk, chunk) in enumerate(zip(expected_request_stream, request_stream)):
+        assert expected_chunk.total_size == chunk.total_size, f"Bad request chunk at index {i}"
diff --git a/spot_wrapper/tests/test_proxy_servicer.py b/spot_wrapper/tests/test_proxy_servicer.py
new file mode 100644
index 00000000..7be38fa7
--- /dev/null
+++ b/spot_wrapper/tests/test_proxy_servicer.py
@@ -0,0 +1,84 @@
+# Copyright (c) 2025 Boston Dynamics AI Institute LLC. See LICENSE file for more info.
+
+import concurrent.futures
+from typing import Iterator
+from unittest.mock import Mock
+
+import grpc
+import pytest
+from bosdyn.api.robot_command_pb2 import RobotCommandRequest, RobotCommandResponse
+from bosdyn.api.robot_command_service_pb2_grpc import RobotCommandServiceStub
+from bosdyn.api.robot_state_pb2 import RobotStateStreamRequest, RobotStateStreamResponse
+from bosdyn.api.robot_state_service_pb2_grpc import RobotStateStreamingServiceStub
+
+from spot_wrapper.testing.grpc import AutoServicer, BackServicer, ReverseProxyServicer
+from spot_wrapper.testing.services import BaseSpotServicer
+
+PORT = 50052
+
+
+class ProxySpotServicer(AutoServicer, ReverseProxyServicer, BaseSpotServicer):
+    ...
+
+
+@pytest.fixture
+def mock_spot_backbone() -> Mock:
+    return Mock(BackServicer)
+
+
+@pytest.fixture
+def proxy_spot_servicer(mock_spot_backbone: BackServicer) -> Iterator[ProxySpotServicer]:
+    with concurrent.futures.ThreadPoolExecutor() as thread_pool:
+        server = grpc.server(thread_pool)
+        server.add_insecure_port(f"localhost:{PORT}")
+        servicer = ProxySpotServicer(server=mock_spot_backbone)
+        servicer.add_to(server)
+        server.start()
+        try:
+            yield servicer
+        finally:
+            server.stop(grace=None)
+
+
+@pytest.fixture
+def proxy_spot_channel(proxy_spot_servicer: ProxySpotServicer) -> Iterator[grpc.Channel]:
+    with grpc.insecure_channel(f"localhost:{PORT}") as channel:
+        yield channel
+
+
+def test_unary_proxy_service(proxy_spot_channel: grpc.Channel, mock_spot_backbone: Mock) -> None:
+    stub = RobotCommandServiceStub(proxy_spot_channel)
+    expected_response = RobotCommandResponse()
+    expected_response.status = RobotCommandResponse.Status.STATUS_OK
+    future = concurrent.futures.Future[RobotCommandResponse]()
+    future.set_result(expected_response)
+    mock_spot_backbone.submit.return_value = future
+    expected_request = RobotCommandRequest()
+    expected_request.command.full_body_command.stop_request.SetInParent()
+    response = stub.RobotCommand(expected_request)
+    assert mock_spot_backbone.submit.call_count == 1
+    request = mock_spot_backbone.submit.call_args.args[0]
+    assert request.command.full_body_command.HasField("stop_request")
+    assert response.status == expected_response.status
+
+
+def test_stream_proxy_service(proxy_spot_channel: grpc.Channel, mock_spot_backbone: Mock) -> None:
+    stub = RobotStateStreamingServiceStub(proxy_spot_channel)
+    expected_response_stream: list[RobotStateStreamResponse] = []
+    for i in range(10):
+        response = RobotStateStreamResponse()
+        response.joint_states.position.append(i)
+        expected_response_stream.append(response)
+    future = concurrent.futures.Future[list[RobotStateStreamResponse]]()
+    future.set_result(expected_response_stream)
+    mock_spot_backbone.submit.return_value = future
+    expected_request = RobotStateStreamRequest()
+    expected_request.header.client_name = "test"
+    response_stream = stub.GetRobotStateStream(expected_request)
+    for i, (expected_response, response) in enumerate(zip(expected_response_stream, response_stream)):
+        assert (
+            response.joint_states.position[0] == expected_response.joint_states.position[0]
+        ), f"Bad joint position at index {i}"
+    assert mock_spot_backbone.submit.call_count == 1
+    request = mock_spot_backbone.submit.call_args.args[0]
+    assert request.header.client_name == expected_request.header.client_name


Reviews:
user: login: amessing-bdai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T14:25:07Z
user: login: amessing-bdai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T14:27:57Z
user: login: amessing-bdai
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T14:29:03Z
user: login: mhidalgo-rai
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T18:21:11Z
user: login: mhidalgo-rai
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T18:22:04Z
user: login: amessing-bdai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:31:45Z
user: login: mhidalgo-rai
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T18:36:33Z
user: login: amessing-bdai
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:41:17Z

Review comments:
diff_hunk: @@ -13,53 +13,52 @@
     DEFAULT_TESTING_CERTIFICATES,
     SpotSSLCertificates,
 )
-from spot_wrapper.testing.mocks import BaseMockSpot
+from spot_wrapper.testing.services import BaseSpotServicer
 
 
 @dataclasses.dataclass
 class SpotFixture:
     """
-    A fixture that emulates a Spot robot.
+    A fixture that provides Spot robot services.
 
     Attributes:
-        address: address for the emulated Spot robot gRPC server.
-        port: port for the emulated Spot robot gRPC server.
-        api: mocked API for the emulated Spot robot.
+        address: address of the underlying gRPC server.
+        port: port of the underlying gRPC server.
+        certificate_path: SSL certificates used by the underlying gRPC server.
+        api: access to Spot services implementation.
     """
 
     address: str
     port: int
     certificate_path: pathlib.Path
-    api: BaseMockSpot
+    api: BaseSpotServicer
 
 
 def fixture(
-    cls: typing.Optional[typing.Type[BaseMockSpot]] = None,
+    cls: typing.Optional[typing.Type[BaseSpotServicer]] = None,
     *,
     address: str = "127.0.0.1",
     max_workers: int = 10,
     certificates: SpotSSLCertificates = DEFAULT_TESTING_CERTIFICATES,
     **kwargs: typing.Any,
 ) -> typing.Callable:
     """
-    A `pytest.fixture` of a mocked Spot robot.
+    Spot robot services as a `pytest.fixture`.
 
-    This function takes a Spot mock class by decorating it. Spot mock classes may
-    request parameters like any `pytest.fixture` would do by specifying them in their
-    __init__ methods.
-
-    Mocked Spot robots are served through insecure gRPC channels. To enable Spot SDK
-    use, this fixture patches gRPC secure channel creation, forcing them to be insecure.
+    This function takes class that implements Spot robot services by decorating it.
path: spot_wrapper/testing/fixtures.py
user: login: amessing-bdai
body: ```suggestion
    This function decorates a class that implements Spot robot services.
```
created_at: 2025-04-09T14:25:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -599,3 +602,84 @@ def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing
             yield from call.response
         else:
             context.abort(call.code, call.details)
+
+
+class BackServicer(typing.Protocol):
+    """Protocol for a servicer that can handle back requests, typically from a proxy."""
+
+    def submit(self, request: typing.Any) -> concurrent.futures.Future:
+        """Submit a unary or streaming request.
+
+        Returns a future unary or streaming response.
+        """
+
+
+class ReverseProxyServicer(BaseServicer):
+    """
+    A gRPC servicer reverse proxy to relay requests.
+
+    Attributes:
+        proxy_services: optional set of services to reverse proxy.
+        If none is provided, proxy will apply to all found services.
+    """
+
+    proxy_services: typing.Optional[typing.Set[str]] = None
+
+    def __init__(self, *args, server: BackServicer, **kwargs) -> None:
+        """Initializes the reverse proxy, binding to the given `server`."""
+        super().__init__(*args, **kwargs)
+        for endpoint_name, handler in collect_method_handlers(self):
+            service_name, _, method_name = endpoint_name.rpartition("/")
+            if self.proxy_services is not None:
+                if not any(proxy_service_name.endswith(service_name) for proxy_service_name in self.proxy_services):
+                    continue
+            proxy_cls: typing.Type
+            if handler.response_streaming:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_stream
+                else:
+                    underlying_callable = handler.unary_stream
+                proxy_cls = ProxiedStreamRpcHandler
+            else:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_unary
+                else:
+                    underlying_callable = handler.unary_unary
+                proxy_cls = ProxiedUnaryRpcHandler
+            if implemented(underlying_callable):
+                raise RuntimeError(f"{endpoint_name} is already implemented")
+            setattr(self, method_name, proxy_cls(server, underlying_callable))
+
+
+class ProxiedRpcHandler(ABC):
+    """A generic gRPC handler that proxies requests."""
+
+    def __init__(self, server: BackServicer, stub: typing.Callable) -> None:
+        for name in ("__module__", "__name__", "__qualname__", "__doc__", "__annotations__", "__type_params__"):
+            try:
+                value = getattr(stub, name)
+            except AttributeError:
+                pass
+            else:
+                setattr(self, name, value)
+        self._server = server
+
+    @abstractmethod
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        ...
+
+
+class ProxiedUnaryRpcHandler(ProxiedRpcHandler):
+    """A gRPC any-unary handler that proxies requests."""
+
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        future = self._server.submit(request)
+        return future.result(timeout=context.time_remaining())
+
+
+class ProxiedStreamRpcHandler(ProxiedRpcHandler):
path: spot_wrapper/testing/grpc.py
user: login: amessing-bdai
body: Can you explain the difference between this and `ProxiedUnaryRpcHandler`? Currently they are the same implementation
created_at: 2025-04-09T14:27:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -599,3 +602,84 @@ def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing
             yield from call.response
         else:
             context.abort(call.code, call.details)
+
+
+class BackServicer(typing.Protocol):
+    """Protocol for a servicer that can handle back requests, typically from a proxy."""
+
+    def submit(self, request: typing.Any) -> concurrent.futures.Future:
+        """Submit a unary or streaming request.
+
+        Returns a future unary or streaming response.
+        """
+
+
+class ReverseProxyServicer(BaseServicer):
+    """
+    A gRPC servicer reverse proxy to relay requests.
+
+    Attributes:
+        proxy_services: optional set of services to reverse proxy.
+        If none is provided, proxy will apply to all found services.
+    """
+
+    proxy_services: typing.Optional[typing.Set[str]] = None
+
+    def __init__(self, *args, server: BackServicer, **kwargs) -> None:
+        """Initializes the reverse proxy, binding to the given `server`."""
+        super().__init__(*args, **kwargs)
+        for endpoint_name, handler in collect_method_handlers(self):
+            service_name, _, method_name = endpoint_name.rpartition("/")
+            if self.proxy_services is not None:
+                if not any(proxy_service_name.endswith(service_name) for proxy_service_name in self.proxy_services):
+                    continue
+            proxy_cls: typing.Type
+            if handler.response_streaming:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_stream
+                else:
+                    underlying_callable = handler.unary_stream
+                proxy_cls = ProxiedStreamRpcHandler
+            else:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_unary
+                else:
+                    underlying_callable = handler.unary_unary
+                proxy_cls = ProxiedUnaryRpcHandler
+            if implemented(underlying_callable):
+                raise RuntimeError(f"{endpoint_name} is already implemented")
+            setattr(self, method_name, proxy_cls(server, underlying_callable))
+
+
+class ProxiedRpcHandler(ABC):
+    """A generic gRPC handler that proxies requests."""
+
+    def __init__(self, server: BackServicer, stub: typing.Callable) -> None:
+        for name in ("__module__", "__name__", "__qualname__", "__doc__", "__annotations__", "__type_params__"):
+            try:
+                value = getattr(stub, name)
+            except AttributeError:
+                pass
+            else:
+                setattr(self, name, value)
+        self._server = server
+
+    @abstractmethod
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        ...
+
+
+class ProxiedUnaryRpcHandler(ProxiedRpcHandler):
+    """A gRPC any-unary handler that proxies requests."""
+
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        future = self._server.submit(request)
+        return future.result(timeout=context.time_remaining())
+
+
+class ProxiedStreamRpcHandler(ProxiedRpcHandler):
path: spot_wrapper/testing/grpc.py
user: login: mhidalgo-rai
body: Almost the same. `ProxiedStreamRpcHandler.__call__` is a generator, `ProxiedUnaryRpcHandler.__call__` is a plain function.
created_at: 2025-04-09T18:21:11Z
author_association: COLLABORATOR
diff_hunk: @@ -13,53 +13,52 @@
     DEFAULT_TESTING_CERTIFICATES,
     SpotSSLCertificates,
 )
-from spot_wrapper.testing.mocks import BaseMockSpot
+from spot_wrapper.testing.services import BaseSpotServicer
 
 
 @dataclasses.dataclass
 class SpotFixture:
     """
-    A fixture that emulates a Spot robot.
+    A fixture that provides Spot robot services.
 
     Attributes:
-        address: address for the emulated Spot robot gRPC server.
-        port: port for the emulated Spot robot gRPC server.
-        api: mocked API for the emulated Spot robot.
+        address: address of the underlying gRPC server.
+        port: port of the underlying gRPC server.
+        certificate_path: SSL certificates used by the underlying gRPC server.
+        api: access to Spot services implementation.
     """
 
     address: str
     port: int
     certificate_path: pathlib.Path
-    api: BaseMockSpot
+    api: BaseSpotServicer
 
 
 def fixture(
-    cls: typing.Optional[typing.Type[BaseMockSpot]] = None,
+    cls: typing.Optional[typing.Type[BaseSpotServicer]] = None,
     *,
     address: str = "127.0.0.1",
     max_workers: int = 10,
     certificates: SpotSSLCertificates = DEFAULT_TESTING_CERTIFICATES,
     **kwargs: typing.Any,
 ) -> typing.Callable:
     """
-    A `pytest.fixture` of a mocked Spot robot.
+    Spot robot services as a `pytest.fixture`.
 
-    This function takes a Spot mock class by decorating it. Spot mock classes may
-    request parameters like any `pytest.fixture` would do by specifying them in their
-    __init__ methods.
-
-    Mocked Spot robots are served through insecure gRPC channels. To enable Spot SDK
-    use, this fixture patches gRPC secure channel creation, forcing them to be insecure.
+    This function takes class that implements Spot robot services by decorating it.
path: spot_wrapper/testing/fixtures.py
user: login: mhidalgo-rai
body: Yeap, much better. Fixed in https://github.com/bdaiinstitute/spot_wrapper/pull/164/commits/75ddb69920d0c60157bf60e7b59257a5991092f1
created_at: 2025-04-09T18:22:04Z
author_association: COLLABORATOR
diff_hunk: @@ -599,3 +602,84 @@ def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing
             yield from call.response
         else:
             context.abort(call.code, call.details)
+
+
+class BackServicer(typing.Protocol):
+    """Protocol for a servicer that can handle back requests, typically from a proxy."""
+
+    def submit(self, request: typing.Any) -> concurrent.futures.Future:
+        """Submit a unary or streaming request.
+
+        Returns a future unary or streaming response.
+        """
+
+
+class ReverseProxyServicer(BaseServicer):
+    """
+    A gRPC servicer reverse proxy to relay requests.
+
+    Attributes:
+        proxy_services: optional set of services to reverse proxy.
+        If none is provided, proxy will apply to all found services.
+    """
+
+    proxy_services: typing.Optional[typing.Set[str]] = None
+
+    def __init__(self, *args, server: BackServicer, **kwargs) -> None:
+        """Initializes the reverse proxy, binding to the given `server`."""
+        super().__init__(*args, **kwargs)
+        for endpoint_name, handler in collect_method_handlers(self):
+            service_name, _, method_name = endpoint_name.rpartition("/")
+            if self.proxy_services is not None:
+                if not any(proxy_service_name.endswith(service_name) for proxy_service_name in self.proxy_services):
+                    continue
+            proxy_cls: typing.Type
+            if handler.response_streaming:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_stream
+                else:
+                    underlying_callable = handler.unary_stream
+                proxy_cls = ProxiedStreamRpcHandler
+            else:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_unary
+                else:
+                    underlying_callable = handler.unary_unary
+                proxy_cls = ProxiedUnaryRpcHandler
+            if implemented(underlying_callable):
+                raise RuntimeError(f"{endpoint_name} is already implemented")
+            setattr(self, method_name, proxy_cls(server, underlying_callable))
+
+
+class ProxiedRpcHandler(ABC):
+    """A generic gRPC handler that proxies requests."""
+
+    def __init__(self, server: BackServicer, stub: typing.Callable) -> None:
+        for name in ("__module__", "__name__", "__qualname__", "__doc__", "__annotations__", "__type_params__"):
+            try:
+                value = getattr(stub, name)
+            except AttributeError:
+                pass
+            else:
+                setattr(self, name, value)
+        self._server = server
+
+    @abstractmethod
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        ...
+
+
+class ProxiedUnaryRpcHandler(ProxiedRpcHandler):
+    """A gRPC any-unary handler that proxies requests."""
+
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        future = self._server.submit(request)
+        return future.result(timeout=context.time_remaining())
+
+
+class ProxiedStreamRpcHandler(ProxiedRpcHandler):
path: spot_wrapper/testing/grpc.py
user: login: amessing-bdai
body: Ah, missed that. Should the return type be `typing.Generator` or `typing.Iterator`?
created_at: 2025-04-09T18:31:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -599,3 +602,84 @@ def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing
             yield from call.response
         else:
             context.abort(call.code, call.details)
+
+
+class BackServicer(typing.Protocol):
+    """Protocol for a servicer that can handle back requests, typically from a proxy."""
+
+    def submit(self, request: typing.Any) -> concurrent.futures.Future:
+        """Submit a unary or streaming request.
+
+        Returns a future unary or streaming response.
+        """
+
+
+class ReverseProxyServicer(BaseServicer):
+    """
+    A gRPC servicer reverse proxy to relay requests.
+
+    Attributes:
+        proxy_services: optional set of services to reverse proxy.
+        If none is provided, proxy will apply to all found services.
+    """
+
+    proxy_services: typing.Optional[typing.Set[str]] = None
+
+    def __init__(self, *args, server: BackServicer, **kwargs) -> None:
+        """Initializes the reverse proxy, binding to the given `server`."""
+        super().__init__(*args, **kwargs)
+        for endpoint_name, handler in collect_method_handlers(self):
+            service_name, _, method_name = endpoint_name.rpartition("/")
+            if self.proxy_services is not None:
+                if not any(proxy_service_name.endswith(service_name) for proxy_service_name in self.proxy_services):
+                    continue
+            proxy_cls: typing.Type
+            if handler.response_streaming:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_stream
+                else:
+                    underlying_callable = handler.unary_stream
+                proxy_cls = ProxiedStreamRpcHandler
+            else:
+                if handler.request_streaming:
+                    underlying_callable = handler.stream_unary
+                else:
+                    underlying_callable = handler.unary_unary
+                proxy_cls = ProxiedUnaryRpcHandler
+            if implemented(underlying_callable):
+                raise RuntimeError(f"{endpoint_name} is already implemented")
+            setattr(self, method_name, proxy_cls(server, underlying_callable))
+
+
+class ProxiedRpcHandler(ABC):
+    """A generic gRPC handler that proxies requests."""
+
+    def __init__(self, server: BackServicer, stub: typing.Callable) -> None:
+        for name in ("__module__", "__name__", "__qualname__", "__doc__", "__annotations__", "__type_params__"):
+            try:
+                value = getattr(stub, name)
+            except AttributeError:
+                pass
+            else:
+                setattr(self, name, value)
+        self._server = server
+
+    @abstractmethod
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        ...
+
+
+class ProxiedUnaryRpcHandler(ProxiedRpcHandler):
+    """A gRPC any-unary handler that proxies requests."""
+
+    def __call__(self, request: typing.Any, context: grpc.ServicerContext) -> typing.Any:
+        future = self._server.submit(request)
+        return future.result(timeout=context.time_remaining())
+
+
+class ProxiedStreamRpcHandler(ProxiedRpcHandler):
path: spot_wrapper/testing/grpc.py
user: login: mhidalgo-rai
body: Could be, updated annotation in https://github.com/bdaiinstitute/spot_wrapper/pull/164/commits/6efcafc1f597236a2769b3e21301c58b93b576bc
created_at: 2025-04-09T18:36:33Z
author_association: COLLABORATOR

PR comments:
user: login: coveralls
created_at: 2025-04-09T13:48:34Z
body: ## Pull Request Test Coverage Report for [Build 14366329441](https://coveralls.io/builds/73154562)


### Details

* **258** of **262**   **(98.47%)**  changed or added relevant lines in **6** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**3.6%**) to **44.909%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [spot_wrapper/testing/grpc.py](https://coveralls.io/builds/73154562/source?filename=spot_wrapper%2Ftesting%2Fgrpc.py#L634) | 45 | 49 | 91.84%
<!-- | **Total:** | **258** | **262** | **98.47%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/73154562/badge)](https://coveralls.io/builds/73154562) |
| :-- | --: |
| Change from base [Build 14366033339](https://coveralls.io/builds/73154271): |  3.6% |
| Covered Lines: | 2179 |
| Relevant Lines: | 4852 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE
user: login: mhidalgo-rai
created_at: 2025-04-11T20:02:26Z
body: ### Merge activity

* **Apr 11, 4:02 PM EDT**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/bdaiinstitute/spot_wrapper/164).
* **Apr 11, 4:02 PM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/bdaiinstitute/spot_wrapper/164).

author_association: COLLABORATOR