mirror of https://github.com/grpc/grpc.git
[Interop] Tests for SSA and GAMMA (#34387)
This is just an initial scope of tests. Much of this code was written by @ginayeh . I just did the final polish/integration step. There are 3 main tests included: 1. The GAMMA baseline test, including the [actual GAMMA API](https://gateway-api.sigs.k8s.io/geps/gep-1426/) rather than vendor extensions. 2. Kubernetes-based stateful session affinity tests, where the mesh (including SSA configuration) is configured using CRDs 3. GCP-based stateful session affinity tests, where the mesh is configured using the networkservices APIs directly Tests 1 and 2 will run in both prod and GKE staging, i.e. `container.googleapis.com` and `staging-container.sandbox.googleapis.com`. The latter of these will act as an early detection mechanism for regressions in the controller that translates Gateway resources into networkservices resources. Test 3 will run against `staging-networkservices.sandbox.googleapis.com` to act as an early detection mechanism for regressions in the control plane SSA implementation. The scope of the SSA tests is still fairly minimal. Session drain testing is in-progress but not included in this PR, though several elements required for it are (grace period, pre-stop hook, and the ability to kill a single pod in a deployment). --------- Co-authored-by: Jung-Yu (Gina) Yeh <ginayeh@google.com> Co-authored-by: Sergii Tkachenko <sergiitk@google.com>pull/32941/merge
parent
e57b32588b
commit
62521a889f
25 changed files with 1195 additions and 131 deletions
@ -0,0 +1,90 @@ |
|||||||
|
# Copyright 2023 gRPC authors. |
||||||
|
# |
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
# you may not use this file except in compliance with the License. |
||||||
|
# You may obtain a copy of the License at |
||||||
|
# |
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
# |
||||||
|
# Unless required by applicable law or agreed to in writing, software |
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
# See the License for the specific language governing permissions and |
||||||
|
# limitations under the License. |
||||||
|
"""Utilities for stateful session affinity tests. |
||||||
|
|
||||||
|
These utilities must be shared between test environments that configure SSA |
||||||
|
via Kubernetes CRDs and environments that configure SSA directly through the |
||||||
|
networkservices.googleapis.com API. |
||||||
|
""" |
||||||
|
|
||||||
|
import datetime |
||||||
|
import logging |
||||||
|
from typing import Sequence, Tuple |
||||||
|
|
||||||
|
from framework import xds_k8s_testcase |
||||||
|
from framework.helpers import retryers |
||||||
|
|
||||||
|
_XdsKubernetesBaseTestCase = xds_k8s_testcase.XdsKubernetesBaseTestCase |
||||||
|
_XdsTestServer = xds_k8s_testcase.XdsTestServer |
||||||
|
_XdsTestClient = xds_k8s_testcase.XdsTestClient |
||||||
|
|
||||||
|
_SET_COOKIE_MAX_WAIT_SEC = 300 |
||||||
|
|
||||||
|
|
||||||
|
def get_setcookie_headers( |
||||||
|
metadatas_by_peer: dict[str, "MetadataByPeer"] |
||||||
|
) -> dict[str, str]: |
||||||
|
cookies = dict() |
||||||
|
for peer, metadatas in metadatas_by_peer.items(): |
||||||
|
for rpc_metadatas in metadatas.rpc_metadata: |
||||||
|
for metadata in rpc_metadatas.metadata: |
||||||
|
if metadata.key.lower() == "set-cookie": |
||||||
|
cookies[peer] = metadata.value |
||||||
|
return cookies |
||||||
|
|
||||||
|
|
||||||
|
def assert_eventually_retrieve_cookie_and_server( |
||||||
|
test: _XdsKubernetesBaseTestCase, |
||||||
|
test_client: _XdsTestClient, |
||||||
|
servers: Sequence[_XdsTestServer], |
||||||
|
) -> Tuple[str, _XdsTestServer]: |
||||||
|
"""Retrieves the initial cookie and corresponding server. |
||||||
|
|
||||||
|
Given a test client and set of backends for which SSA is enabled, samples |
||||||
|
a single RPC from the test client to the backends, with metadata collection enabled. |
||||||
|
The "set-cookie" header is retrieved and its contents are returned along with the |
||||||
|
server to which it corresponds. |
||||||
|
|
||||||
|
Since SSA config is supplied as a separate resource from the Route resource, |
||||||
|
there will be periods of time where the SSA config may not be applied. This is |
||||||
|
therefore an eventually consistent function. |
||||||
|
""" |
||||||
|
|
||||||
|
def _assert_retrieve_cookie_and_server(): |
||||||
|
lb_stats = test.assertSuccessfulRpcs(test_client, 1) |
||||||
|
cookies = get_setcookie_headers(lb_stats.metadatas_by_peer) |
||||||
|
test.assertLen(cookies, 1) |
||||||
|
hostname = next(iter(cookies.keys())) |
||||||
|
cookie = cookies[hostname] |
||||||
|
|
||||||
|
chosen_server_candidates = tuple( |
||||||
|
srv for srv in servers if srv.hostname == hostname |
||||||
|
) |
||||||
|
test.assertLen(chosen_server_candidates, 1) |
||||||
|
chosen_server = chosen_server_candidates[0] |
||||||
|
return cookie, chosen_server |
||||||
|
|
||||||
|
retryer = retryers.constant_retryer( |
||||||
|
wait_fixed=datetime.timedelta(seconds=10), |
||||||
|
timeout=datetime.timedelta(seconds=_SET_COOKIE_MAX_WAIT_SEC), |
||||||
|
log_level=logging.INFO, |
||||||
|
) |
||||||
|
try: |
||||||
|
return retryer(_assert_retrieve_cookie_and_server) |
||||||
|
except retryers.RetryError as retry_error: |
||||||
|
logging.exception( |
||||||
|
"Rpcs did not go to expected servers before timeout %s", |
||||||
|
_SET_COOKIE_MAX_WAIT_SEC, |
||||||
|
) |
||||||
|
raise retry_error |
@ -0,0 +1,17 @@ |
|||||||
|
--- |
||||||
|
kind: GCPBackendPolicy |
||||||
|
apiVersion: networking.gke.io/v1 |
||||||
|
metadata: |
||||||
|
name: ${be_policy_name} |
||||||
|
namespace: ${namespace_name} |
||||||
|
labels: |
||||||
|
owner: xds-k8s-interop-test |
||||||
|
spec: |
||||||
|
targetRef: |
||||||
|
group: "" |
||||||
|
kind: Service |
||||||
|
name: ${service_name} |
||||||
|
default: |
||||||
|
connectionDraining: |
||||||
|
drainingTimeoutSec: 600 |
||||||
|
... |
@ -0,0 +1,10 @@ |
|||||||
|
--- |
||||||
|
apiVersion: v1 |
||||||
|
kind: Service |
||||||
|
metadata: |
||||||
|
name: ${service_name} |
||||||
|
namespace: ${namespace_name} |
||||||
|
spec: |
||||||
|
ports: |
||||||
|
- port: 8080 |
||||||
|
targetPort: 8080 |
@ -0,0 +1,23 @@ |
|||||||
|
--- |
||||||
|
kind: HTTPRoute |
||||||
|
apiVersion: gateway.networking.k8s.io/v1beta1 |
||||||
|
metadata: |
||||||
|
name: ${route_name} |
||||||
|
namespace: ${namespace_name} |
||||||
|
labels: |
||||||
|
owner: xds-k8s-interop-test |
||||||
|
spec: |
||||||
|
parentRefs: |
||||||
|
- name: ${frontend_service_name} |
||||||
|
namespace: ${namespace_name} |
||||||
|
group: "" |
||||||
|
kind: Service |
||||||
|
rules: |
||||||
|
- matches: |
||||||
|
- path: |
||||||
|
type: Exact |
||||||
|
value: /grpc.testing.TestService/UnaryCall |
||||||
|
backendRefs: |
||||||
|
- name: ${service_name} |
||||||
|
port: 8080 |
||||||
|
... |
@ -0,0 +1,29 @@ |
|||||||
|
--- |
||||||
|
kind: HTTPRoute |
||||||
|
apiVersion: gateway.networking.k8s.io/v1beta1 |
||||||
|
metadata: |
||||||
|
name: ${route_name} |
||||||
|
namespace: ${namespace_name} |
||||||
|
labels: |
||||||
|
owner: xds-k8s-interop-test |
||||||
|
spec: |
||||||
|
parentRefs: |
||||||
|
- name: ${frontend_service_name} |
||||||
|
namespace: ${namespace_name} |
||||||
|
group: "" |
||||||
|
kind: Service |
||||||
|
rules: |
||||||
|
- matches: |
||||||
|
- path: |
||||||
|
type: Exact |
||||||
|
value: /grpc.testing.TestService/UnaryCall |
||||||
|
filters: |
||||||
|
- type: ExtensionRef |
||||||
|
extensionRef: |
||||||
|
group: networking.gke.io |
||||||
|
kind: GCPSessionAffinityFilter |
||||||
|
name: ssa-filter |
||||||
|
backendRefs: |
||||||
|
- name: ${service_name} |
||||||
|
port: 8080 |
||||||
|
... |
@ -0,0 +1,10 @@ |
|||||||
|
--- |
||||||
|
apiVersion: networking.gke.io/v1 |
||||||
|
kind: GCPSessionAffinityFilter |
||||||
|
metadata: |
||||||
|
name: ${session_affinity_filter_name} |
||||||
|
namespace: ${namespace_name} |
||||||
|
spec: |
||||||
|
statefulGeneratedCookie: |
||||||
|
cookieTtlSeconds: 50 |
||||||
|
... |
@ -0,0 +1,15 @@ |
|||||||
|
--- |
||||||
|
apiVersion: networking.gke.io/v1 |
||||||
|
kind: GCPSessionAffinityPolicy |
||||||
|
metadata: |
||||||
|
name: ${session_affinity_policy_name} |
||||||
|
namespace: ${namespace_name} |
||||||
|
spec: |
||||||
|
statefulGeneratedCookie: |
||||||
|
cookieTtlSeconds: 50 |
||||||
|
targetRef: |
||||||
|
name: ${route_name} |
||||||
|
group: gateway.networking.k8s.io |
||||||
|
kind: HTTPRoute |
||||||
|
namespace: ${namespace_name} |
||||||
|
... |
@ -0,0 +1,15 @@ |
|||||||
|
--- |
||||||
|
apiVersion: networking.gke.io/v1 |
||||||
|
kind: GCPSessionAffinityPolicy |
||||||
|
metadata: |
||||||
|
name: ${session_affinity_policy_name} |
||||||
|
namespace: ${namespace_name} |
||||||
|
spec: |
||||||
|
statefulGeneratedCookie: |
||||||
|
cookieTtlSeconds: 50 |
||||||
|
targetRef: |
||||||
|
name: ${service_name} |
||||||
|
kind: Service |
||||||
|
namespace: ${namespace_name} |
||||||
|
group: "" |
||||||
|
... |
@ -1,19 +0,0 @@ |
|||||||
--- |
|
||||||
kind: TDMesh |
|
||||||
apiVersion: net.gke.io/v1alpha1 |
|
||||||
metadata: |
|
||||||
name: ${mesh_name} |
|
||||||
namespace: ${namespace_name} |
|
||||||
labels: |
|
||||||
owner: xds-k8s-interop-test |
|
||||||
spec: |
|
||||||
gatewayClassName: gke-td |
|
||||||
allowedRoutes: |
|
||||||
namespaces: |
|
||||||
from: All |
|
||||||
kinds: |
|
||||||
- group: net.gke.io |
|
||||||
# This is intentionally incorrect and should be set to GRPCRoute. |
|
||||||
# TODO(sergiitk): [GAMMA] Change when the fix is ready. |
|
||||||
kind: TDGRPCRoute |
|
||||||
... |
|
@ -0,0 +1,116 @@ |
|||||||
|
# Copyright 2023 gRPC authors. |
||||||
|
# |
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
# you may not use this file except in compliance with the License. |
||||||
|
# You may obtain a copy of the License at |
||||||
|
# |
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
# |
||||||
|
# Unless required by applicable law or agreed to in writing, software |
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
# See the License for the specific language governing permissions and |
||||||
|
# limitations under the License. |
||||||
|
import logging |
||||||
|
from typing import List |
||||||
|
|
||||||
|
from absl import flags |
||||||
|
from absl.testing import absltest |
||||||
|
|
||||||
|
from framework import xds_k8s_testcase |
||||||
|
from framework import xds_url_map_testcase |
||||||
|
from framework.test_cases import session_affinity_util |
||||||
|
|
||||||
|
logger = logging.getLogger(__name__) |
||||||
|
flags.adopt_module_key_flags(xds_k8s_testcase) |
||||||
|
|
||||||
|
_XdsTestServer = xds_k8s_testcase.XdsTestServer |
||||||
|
_XdsTestClient = xds_k8s_testcase.XdsTestClient |
||||||
|
RpcTypeUnaryCall = xds_url_map_testcase.RpcTypeUnaryCall |
||||||
|
|
||||||
|
_REPLICA_COUNT = 3 |
||||||
|
|
||||||
|
# This is here temporarily to run this test separately from other app_net tests. |
||||||
|
# TODO(sergiitk): Move into app_net_test.py |
||||||
|
|
||||||
|
|
||||||
|
class AppNetSsaTest(xds_k8s_testcase.AppNetXdsKubernetesTestCase): |
||||||
|
def test_session_affinity_policy(self): |
||||||
|
test_servers: List[_XdsTestServer] |
||||||
|
|
||||||
|
with self.subTest("0_create_health_check"): |
||||||
|
self.td.create_health_check() |
||||||
|
|
||||||
|
with self.subTest("1_create_backend_service"): |
||||||
|
self.td.create_backend_service() |
||||||
|
|
||||||
|
with self.subTest("2_create_mesh"): |
||||||
|
self.td.create_mesh() |
||||||
|
|
||||||
|
with self.subTest("3_create_http_route"): |
||||||
|
self.td.create_http_route_with_content( |
||||||
|
{ |
||||||
|
"meshes": [self.td.mesh.url], |
||||||
|
"hostnames": [ |
||||||
|
f"{self.server_xds_host}:{self.server_xds_port}" |
||||||
|
], |
||||||
|
"rules": [ |
||||||
|
{ |
||||||
|
"action": { |
||||||
|
"destinations": [ |
||||||
|
{ |
||||||
|
"serviceName": self.td.netsvc.resource_full_name( |
||||||
|
self.td.backend_service.name, |
||||||
|
"backendServices", |
||||||
|
), |
||||||
|
"weight": 1, |
||||||
|
}, |
||||||
|
], |
||||||
|
"statefulSessionAffinity": { |
||||||
|
"cookieTtl": "50s", |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
with self.subTest("4_run_test_server"): |
||||||
|
test_servers = self.startTestServers(replica_count=_REPLICA_COUNT) |
||||||
|
|
||||||
|
with self.subTest("5_setup_server_backends"): |
||||||
|
self.setupServerBackends() |
||||||
|
|
||||||
|
# Default is round robin LB policy. |
||||||
|
|
||||||
|
with self.subTest("6_start_test_client"): |
||||||
|
test_client: _XdsTestClient = self.startTestClient( |
||||||
|
test_servers[0], config_mesh=self.td.mesh.name |
||||||
|
) |
||||||
|
|
||||||
|
with self.subTest("7_send_first_RPC_and_retrieve_cookie"): |
||||||
|
( |
||||||
|
cookie, |
||||||
|
chosen_server, |
||||||
|
) = session_affinity_util.assert_eventually_retrieve_cookie_and_server( |
||||||
|
self, test_client, test_servers |
||||||
|
) |
||||||
|
|
||||||
|
with self.subTest("8_send_RPCs_with_cookie"): |
||||||
|
test_client.update_config.configure( |
||||||
|
rpc_types=(RpcTypeUnaryCall,), |
||||||
|
metadata=( |
||||||
|
( |
||||||
|
RpcTypeUnaryCall, |
||||||
|
"cookie", |
||||||
|
cookie, |
||||||
|
), |
||||||
|
), |
||||||
|
) |
||||||
|
self.assertRpcsEventuallyGoToGivenServers( |
||||||
|
test_client, [chosen_server], 10 |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
absltest.main(failfast=True) |
@ -0,0 +1,170 @@ |
|||||||
|
# Copyright 2023 gRPC authors. |
||||||
|
# |
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
# you may not use this file except in compliance with the License. |
||||||
|
# You may obtain a copy of the License at |
||||||
|
# |
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
# |
||||||
|
# Unless required by applicable law or agreed to in writing, software |
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
# See the License for the specific language governing permissions and |
||||||
|
# limitations under the License. |
||||||
|
import logging |
||||||
|
from typing import List, Optional |
||||||
|
|
||||||
|
from absl import flags |
||||||
|
from absl.testing import absltest |
||||||
|
|
||||||
|
from framework import xds_gamma_testcase |
||||||
|
from framework import xds_k8s_testcase |
||||||
|
from framework import xds_url_map_testcase |
||||||
|
from framework.rpc import grpc_testing |
||||||
|
from framework.test_app import client_app |
||||||
|
from framework.test_app import server_app |
||||||
|
from framework.test_cases import session_affinity_util |
||||||
|
|
||||||
|
logger = logging.getLogger(__name__) |
||||||
|
flags.adopt_module_key_flags(xds_k8s_testcase) |
||||||
|
|
||||||
|
_XdsTestServer = server_app.XdsTestServer |
||||||
|
_XdsTestClient = client_app.XdsTestClient |
||||||
|
RpcTypeUnaryCall = xds_url_map_testcase.RpcTypeUnaryCall |
||||||
|
|
||||||
|
_REPLICA_COUNT = 3 |
||||||
|
|
||||||
|
|
||||||
|
class AffinityTest(xds_gamma_testcase.GammaXdsKubernetesTestCase): |
||||||
|
def getClientRpcStats( |
||||||
|
self, |
||||||
|
test_client: _XdsTestClient, |
||||||
|
num_rpcs: int, |
||||||
|
*, |
||||||
|
metadata_keys: Optional[tuple[str, ...]] = None, |
||||||
|
) -> grpc_testing.LoadBalancerStatsResponse: |
||||||
|
"""Load all metadata_keys by default.""" |
||||||
|
return super().getClientRpcStats( |
||||||
|
test_client, |
||||||
|
num_rpcs, |
||||||
|
metadata_keys=metadata_keys or client_app.REQ_LB_STATS_METADATA_ALL, |
||||||
|
) |
||||||
|
|
||||||
|
def test_session_affinity_filter(self): |
||||||
|
test_servers: List[_XdsTestServer] |
||||||
|
with self.subTest("01_run_test_server"): |
||||||
|
test_servers = self.startTestServers( |
||||||
|
replica_count=_REPLICA_COUNT, |
||||||
|
route_template="gamma/route_http_ssafilter.yaml", |
||||||
|
) |
||||||
|
|
||||||
|
with self.subTest("02_create_ssa_filter"): |
||||||
|
self.server_runner.createSessionAffinityFilter() |
||||||
|
|
||||||
|
# Default is round robin LB policy. |
||||||
|
|
||||||
|
with self.subTest("03_start_test_client"): |
||||||
|
test_client: _XdsTestClient = self.startTestClient(test_servers[0]) |
||||||
|
|
||||||
|
with self.subTest("04_send_first_RPC_and_retrieve_cookie"): |
||||||
|
( |
||||||
|
cookie, |
||||||
|
chosen_server, |
||||||
|
) = session_affinity_util.assert_eventually_retrieve_cookie_and_server( |
||||||
|
self, test_client, test_servers |
||||||
|
) |
||||||
|
|
||||||
|
with self.subTest("05_send_RPCs_with_cookie"): |
||||||
|
test_client.update_config.configure( |
||||||
|
rpc_types=(RpcTypeUnaryCall,), |
||||||
|
metadata=( |
||||||
|
( |
||||||
|
RpcTypeUnaryCall, |
||||||
|
"cookie", |
||||||
|
cookie, |
||||||
|
), |
||||||
|
), |
||||||
|
) |
||||||
|
self.assertRpcsEventuallyGoToGivenServers( |
||||||
|
test_client, [chosen_server], 10 |
||||||
|
) |
||||||
|
|
||||||
|
def test_session_affinity_policy_with_route_target(self): |
||||||
|
test_servers: List[_XdsTestServer] |
||||||
|
with self.subTest("01_run_test_server"): |
||||||
|
test_servers = self.startTestServers(replica_count=_REPLICA_COUNT) |
||||||
|
|
||||||
|
with self.subTest("02_create_ssa_policy"): |
||||||
|
self.server_runner.createSessionAffinityPolicy( |
||||||
|
"gamma/session_affinity_policy_route.yaml" |
||||||
|
) |
||||||
|
|
||||||
|
# Default is round robin LB policy. |
||||||
|
|
||||||
|
with self.subTest("03_start_test_client"): |
||||||
|
test_client: _XdsTestClient = self.startTestClient(test_servers[0]) |
||||||
|
|
||||||
|
with self.subTest("04_send_first_RPC_and_retrieve_cookie"): |
||||||
|
( |
||||||
|
cookie, |
||||||
|
chosen_server, |
||||||
|
) = session_affinity_util.assert_eventually_retrieve_cookie_and_server( |
||||||
|
self, test_client, test_servers |
||||||
|
) |
||||||
|
|
||||||
|
with self.subTest("05_send_RPCs_with_cookie"): |
||||||
|
test_client.update_config.configure( |
||||||
|
rpc_types=(RpcTypeUnaryCall,), |
||||||
|
metadata=( |
||||||
|
( |
||||||
|
RpcTypeUnaryCall, |
||||||
|
"cookie", |
||||||
|
cookie, |
||||||
|
), |
||||||
|
), |
||||||
|
) |
||||||
|
self.assertRpcsEventuallyGoToGivenServers( |
||||||
|
test_client, [chosen_server], 10 |
||||||
|
) |
||||||
|
|
||||||
|
def test_session_affinity_policy_with_service_target(self): |
||||||
|
test_servers: List[_XdsTestServer] |
||||||
|
with self.subTest("01_run_test_server"): |
||||||
|
test_servers = self.startTestServers(replica_count=_REPLICA_COUNT) |
||||||
|
|
||||||
|
with self.subTest("02_create_ssa_policy"): |
||||||
|
self.server_runner.createSessionAffinityPolicy( |
||||||
|
"gamma/session_affinity_policy_service.yaml" |
||||||
|
) |
||||||
|
|
||||||
|
# Default is round robin LB policy. |
||||||
|
|
||||||
|
with self.subTest("03_start_test_client"): |
||||||
|
test_client: _XdsTestClient = self.startTestClient(test_servers[0]) |
||||||
|
|
||||||
|
with self.subTest("04_send_first_RPC_and_retrieve_cookie"): |
||||||
|
( |
||||||
|
cookie, |
||||||
|
chosen_server, |
||||||
|
) = session_affinity_util.assert_eventually_retrieve_cookie_and_server( |
||||||
|
self, test_client, test_servers |
||||||
|
) |
||||||
|
|
||||||
|
with self.subTest("05_send_RPCs_with_cookie"): |
||||||
|
test_client.update_config.configure( |
||||||
|
rpc_types=(RpcTypeUnaryCall,), |
||||||
|
metadata=( |
||||||
|
( |
||||||
|
RpcTypeUnaryCall, |
||||||
|
"cookie", |
||||||
|
cookie, |
||||||
|
), |
||||||
|
), |
||||||
|
) |
||||||
|
self.assertRpcsEventuallyGoToGivenServers( |
||||||
|
test_client, [chosen_server], 10 |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
absltest.main(failfast=True) |
Loading…
Reference in new issue