Skip to content

Commit b6a9652

Browse files
IronPancopybara-github
authored andcommitted
fix: route Endpoint.predict_async via dedicated DNS when the endpoint is dedicated
PiperOrigin-RevId: 923534146
1 parent ad4284b commit b6a9652

2 files changed

Lines changed: 314 additions & 17 deletions

File tree

google/cloud/aiplatform/models.py

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2715,6 +2715,11 @@ async def predict_async(
27152715
my_predictions = response.predictions
27162716
```
27172717
2718+
For dedicated endpoints (``dedicated_endpoint_enabled=True``), the call
2719+
is routed via async HTTPS POST to the endpoint's dedicated DNS,
2720+
mirroring the synchronous ``predict()`` dedicated-endpoint path.
2721+
Otherwise the GAPIC async prediction client is used.
2722+
27182723
Args:
27192724
instances (List):
27202725
Required. The instances that are the input to the
@@ -2740,29 +2745,96 @@ async def predict_async(
27402745
Returns:
27412746
prediction (aiplatform.Prediction):
27422747
Prediction with returned predictions and Model ID.
2748+
2749+
Raises:
2750+
ValueError: If the dedicated endpoint DNS is empty for dedicated
2751+
endpoints, or if the prediction request to a dedicated endpoint
2752+
returns a non-200 status.
27432753
"""
27442754
self.wait()
27452755

2746-
prediction_response = await self._prediction_async_client.predict(
2747-
endpoint=self._gca_resource.name,
2748-
instances=instances,
2749-
parameters=parameters,
2750-
timeout=timeout,
2751-
)
2752-
if prediction_response._pb.metadata:
2753-
metadata = json_format.MessageToDict(prediction_response._pb.metadata)
2756+
if not self.dedicated_endpoint_enabled:
2757+
prediction_response = await self._prediction_async_client.predict(
2758+
endpoint=self._gca_resource.name,
2759+
instances=instances,
2760+
parameters=parameters,
2761+
timeout=timeout,
2762+
)
2763+
if prediction_response._pb.metadata:
2764+
metadata = json_format.MessageToDict(prediction_response._pb.metadata)
2765+
else:
2766+
metadata = None
2767+
2768+
return Prediction(
2769+
predictions=[
2770+
json_format.MessageToDict(item)
2771+
for item in prediction_response.predictions.pb
2772+
],
2773+
metadata=metadata,
2774+
deployed_model_id=prediction_response.deployed_model_id,
2775+
model_version_id=prediction_response.model_version_id,
2776+
model_resource_name=prediction_response.model,
2777+
)
2778+
2779+
# Dedicated endpoint: REST POST to the dedicated DNS via aiohttp.
2780+
# aiohttp is imported lazily so it is not a hard dependency for callers
2781+
# that only use the synchronous predict() path.
2782+
try:
2783+
import aiohttp
2784+
except ImportError as exc:
2785+
raise ImportError(
2786+
"Cannot import the aiohttp library required for async prediction"
2787+
" on dedicated endpoints. Please install aiohttp."
2788+
) from exc
2789+
2790+
if not self.dedicated_endpoint_dns:
2791+
raise ValueError(
2792+
"Dedicated endpoint DNS is empty. Please make sure endpoint"
2793+
"and model are ready before making a prediction."
2794+
)
2795+
2796+
if parameters is not None:
2797+
data = json.dumps({"instances": instances, "parameters": parameters})
27542798
else:
2755-
metadata = None
2799+
data = json.dumps({"instances": instances})
2800+
2801+
# Refresh the bearer token per call. ``AuthorizedSession`` (sync)
2802+
# handles refresh internally; in the async path we do it explicitly.
2803+
self.credentials._scopes = constants.base.DEFAULT_AUTHED_SCOPES
2804+
self.credentials.refresh(google_auth_requests.Request())
2805+
headers = {
2806+
"Authorization": f"Bearer {self.credentials.token}",
2807+
"Content-Type": "application/json",
2808+
}
2809+
url = f"https://{self.dedicated_endpoint_dns}/v1/{self.resource_name}:predict"
2810+
aiohttp_timeout = (
2811+
aiohttp.ClientTimeout(total=timeout) if timeout is not None else None
2812+
)
2813+
2814+
# Use a per-call session so the underlying connector is always closed,
2815+
# avoiding leaked ClientSessions (Python has no async destructor to do
2816+
# this for a cached session).
2817+
async with aiohttp.ClientSession() as session:
2818+
async with session.post(
2819+
url=url,
2820+
data=data,
2821+
headers=headers,
2822+
timeout=aiohttp_timeout,
2823+
) as response:
2824+
if response.status != 200:
2825+
text = await response.text()
2826+
raise ValueError(
2827+
f"Failed to make prediction request. Status code:"
2828+
f"{response.status}, response: {text}."
2829+
)
2830+
prediction_response = await response.json()
27562831

27572832
return Prediction(
2758-
predictions=[
2759-
json_format.MessageToDict(item)
2760-
for item in prediction_response.predictions.pb
2761-
],
2762-
metadata=metadata,
2763-
deployed_model_id=prediction_response.deployed_model_id,
2764-
model_version_id=prediction_response.model_version_id,
2765-
model_resource_name=prediction_response.model,
2833+
predictions=prediction_response.get("predictions"),
2834+
metadata=prediction_response.get("metadata"),
2835+
deployed_model_id=prediction_response.get("deployedModelId"),
2836+
model_resource_name=prediction_response.get("model"),
2837+
model_version_id=prediction_response.get("modelVersionId"),
27662838
)
27672839

27682840
def raw_predict(

tests/unit/aiplatform/test_endpoints.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from datetime import datetime, timedelta
2020
from importlib import reload
2121
import json
22+
import aiohttp
23+
from aiohttp import web as aiohttp_web
2224
import requests
2325
from unittest import mock
2426
from google.protobuf import duration_pb2
@@ -662,6 +664,38 @@ def predict_async_client_predict_mock():
662664
yield predict_mock
663665

664666

667+
@pytest.fixture
668+
def predict_endpoint_aiohttp_mock():
669+
"""Mocks aiohttp.ClientSession.post for dedicated-endpoint async predict.
670+
671+
ClientSession.post returns a _RequestContextManager (async ctx mgr), so the
672+
mock must implement __aenter__/__aexit__. Also patches Credentials.refresh
673+
to a no-op so we don't hit real auth during tests.
674+
"""
675+
payload = {
676+
"predictions": _TEST_PREDICTION,
677+
"metadata": _TEST_METADATA,
678+
"deployedModelId": _TEST_DEPLOYED_MODELS[0].id,
679+
"model": _TEST_MODEL_NAME,
680+
"modelVersionId": "1",
681+
}
682+
response = mock.AsyncMock()
683+
response.status = 200
684+
response.json = mock.AsyncMock(return_value=payload)
685+
response.text = mock.AsyncMock(return_value=json.dumps(payload))
686+
687+
post_cm = mock.MagicMock()
688+
post_cm.__aenter__ = mock.AsyncMock(return_value=response)
689+
post_cm.__aexit__ = mock.AsyncMock(return_value=None)
690+
691+
with mock.patch.object(
692+
aiohttp.ClientSession, "post", return_value=post_cm
693+
) as post_mock, mock.patch.object(
694+
auth_credentials.AnonymousCredentials, "refresh"
695+
):
696+
yield post_mock
697+
698+
665699
@pytest.fixture
666700
def predict_client_direct_predict_mock():
667701
with mock.patch.object(
@@ -3687,6 +3721,197 @@ async def test_predict_async(self, predict_async_client_predict_mock):
36873721
timeout=None,
36883722
)
36893723

3724+
@pytest.mark.asyncio
3725+
@pytest.mark.usefixtures("get_dedicated_endpoint_mock")
3726+
async def test_predict_async_dedicated_endpoint(
3727+
self, predict_endpoint_aiohttp_mock
3728+
):
3729+
"""Async predict on a dedicated endpoint routes via aiohttp REST."""
3730+
test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME)
3731+
3732+
test_prediction = await test_endpoint.predict_async(
3733+
instances=_TEST_INSTANCES, parameters={"param": 3.0}
3734+
)
3735+
3736+
true_prediction = models.Prediction(
3737+
predictions=_TEST_PREDICTION,
3738+
deployed_model_id=_TEST_ID,
3739+
metadata=_TEST_METADATA,
3740+
model_version_id=_TEST_VERSION_ID,
3741+
model_resource_name=_TEST_MODEL_NAME,
3742+
)
3743+
assert true_prediction == test_prediction
3744+
3745+
predict_endpoint_aiohttp_mock.assert_called_once()
3746+
call_kwargs = predict_endpoint_aiohttp_mock.call_args.kwargs
3747+
assert call_kwargs["url"] == (
3748+
f"https://{_TEST_DEDICATED_ENDPOINT_DNS}/v1/projects/"
3749+
f"{_TEST_PROJECT}/locations/{_TEST_LOCATION}/endpoints/{_TEST_ID}:predict"
3750+
)
3751+
assert call_kwargs["data"] == (
3752+
'{"instances": [[1.0, 2.0, 3.0], [1.0, 3.0, 4.0]],'
3753+
' "parameters": {"param": 3.0}}'
3754+
)
3755+
assert call_kwargs["headers"]["Content-Type"] == "application/json"
3756+
assert call_kwargs["headers"]["Authorization"].startswith("Bearer ")
3757+
assert call_kwargs["timeout"] is None
3758+
3759+
@pytest.mark.asyncio
3760+
@pytest.mark.usefixtures("get_dedicated_endpoint_no_dns_mock")
3761+
async def test_predict_async_dedicated_endpoint_without_dns(
3762+
self, predict_endpoint_aiohttp_mock
3763+
):
3764+
"""Async predict on a dedicated endpoint with no DNS raises ValueError."""
3765+
test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME)
3766+
3767+
with pytest.raises(ValueError) as err:
3768+
await test_endpoint.predict_async(
3769+
instances=_TEST_INSTANCES, parameters={"param": 3.0}
3770+
)
3771+
assert err.match(
3772+
regexp=r"Dedicated endpoint DNS is empty. Please make sure endpoint"
3773+
"and model are ready before making a prediction."
3774+
)
3775+
3776+
@pytest.mark.asyncio
3777+
@pytest.mark.usefixtures("get_dedicated_endpoint_mock")
3778+
async def test_predict_async_dedicated_endpoint_with_timeout(
3779+
self, predict_endpoint_aiohttp_mock
3780+
):
3781+
"""A non-None timeout is forwarded as an aiohttp.ClientTimeout."""
3782+
test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME)
3783+
3784+
await test_endpoint.predict_async(
3785+
instances=_TEST_INSTANCES,
3786+
parameters={"param": 3.0},
3787+
timeout=_TEST_PREDICT_TIMEOUT,
3788+
)
3789+
3790+
predict_endpoint_aiohttp_mock.assert_called_once()
3791+
call_timeout = predict_endpoint_aiohttp_mock.call_args.kwargs["timeout"]
3792+
assert isinstance(call_timeout, aiohttp.ClientTimeout)
3793+
assert call_timeout.total == _TEST_PREDICT_TIMEOUT
3794+
3795+
@pytest.mark.asyncio
3796+
@pytest.mark.usefixtures(
3797+
"get_dedicated_endpoint_mock", "predict_endpoint_aiohttp_mock"
3798+
)
3799+
async def test_predict_async_dedicated_endpoint_closes_session(self):
3800+
"""The per-call ClientSession is closed, so no session is leaked."""
3801+
test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME)
3802+
3803+
with mock.patch.object(
3804+
aiohttp.ClientSession, "close", new_callable=mock.AsyncMock
3805+
) as close_mock:
3806+
await test_endpoint.predict_async(
3807+
instances=_TEST_INSTANCES, parameters={"param": 3.0}
3808+
)
3809+
3810+
close_mock.assert_awaited_once()
3811+
3812+
@pytest.mark.asyncio
3813+
@pytest.mark.usefixtures("get_dedicated_endpoint_mock")
3814+
async def test_predict_async_dedicated_endpoint_integration(self):
3815+
"""Hermetic integration test: real aiohttp request against a local server.
3816+
3817+
Unlike the mock-based tests above, this exercises the real
3818+
aiohttp.ClientSession code path (connection, request encoding,
3819+
response decoding, JSON parsing, async context manager teardown) by
3820+
pointing the dedicated DNS at an in-process aiohttp.web server.
3821+
3822+
The server validates the URL path, headers, and body shape the SDK
3823+
sends, and returns a Vertex-shaped JSON response. ``predict_async``
3824+
creates its own ClientSession per call, so we patch the constructor to
3825+
return a session whose ``post`` downgrades the scheme from ``https://``
3826+
to ``http://`` so the local server can serve plain HTTP without needing
3827+
self-signed TLS plumbing in the test.
3828+
"""
3829+
captured = {}
3830+
3831+
async def predict_handler(request: aiohttp_web.Request):
3832+
captured["path"] = request.path
3833+
captured["headers"] = dict(request.headers)
3834+
captured["body"] = await request.json()
3835+
return aiohttp_web.json_response(
3836+
{
3837+
"predictions": _TEST_PREDICTION,
3838+
"metadata": _TEST_METADATA,
3839+
"deployedModelId": _TEST_DEPLOYED_MODELS[0].id,
3840+
"model": _TEST_MODEL_NAME,
3841+
"modelVersionId": "1",
3842+
}
3843+
)
3844+
3845+
app = aiohttp_web.Application()
3846+
app.router.add_post(
3847+
f"/v1/{_TEST_ENDPOINT_NAME}:predict", predict_handler
3848+
)
3849+
runner = aiohttp_web.AppRunner(app)
3850+
await runner.setup()
3851+
site = aiohttp_web.TCPSite(runner, "127.0.0.1", 0)
3852+
await site.start()
3853+
port = site._server.sockets[0].getsockname()[1]
3854+
3855+
# Build a real session whose post() downgrades https:// -> http:// so
3856+
# the local test server can serve plain HTTP. Real aiohttp does
3857+
# everything else (real socket, real request/response cycle). Capture
3858+
# the real constructor here so the patched one below doesn't recurse.
3859+
real_client_session_cls = aiohttp.ClientSession
3860+
3861+
def make_http_session(*args, **kwargs):
3862+
session = real_client_session_cls(*args, **kwargs)
3863+
original_post = session.post
3864+
3865+
def http_post(url, **post_kwargs):
3866+
return original_post(
3867+
url.replace("https://", "http://", 1), **post_kwargs
3868+
)
3869+
3870+
session.post = http_post
3871+
return session
3872+
3873+
try:
3874+
test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME)
3875+
3876+
# Force the dedicated DNS to point at the local server.
3877+
with mock.patch.object(
3878+
models.Endpoint,
3879+
"dedicated_endpoint_dns",
3880+
new_callable=mock.PropertyMock,
3881+
return_value=f"127.0.0.1:{port}",
3882+
), mock.patch.object(
3883+
auth_credentials.AnonymousCredentials, "refresh"
3884+
), mock.patch.object(
3885+
aiohttp, "ClientSession", side_effect=make_http_session
3886+
):
3887+
# Give the mocked credentials a fake token.
3888+
test_endpoint.credentials.token = "test-token"
3889+
3890+
prediction = await test_endpoint.predict_async(
3891+
instances=_TEST_INSTANCES,
3892+
parameters={"param": 3.0},
3893+
)
3894+
3895+
# Verify the SDK sent the right wire request.
3896+
assert captured["path"] == f"/v1/{_TEST_ENDPOINT_NAME}:predict"
3897+
assert captured["headers"]["Content-Type"] == "application/json"
3898+
assert captured["headers"]["Authorization"] == "Bearer test-token"
3899+
assert captured["body"] == {
3900+
"instances": _TEST_INSTANCES,
3901+
"parameters": {"param": 3.0},
3902+
}
3903+
3904+
# Verify the response was parsed correctly into a Prediction.
3905+
assert prediction == models.Prediction(
3906+
predictions=_TEST_PREDICTION,
3907+
deployed_model_id=_TEST_ID,
3908+
metadata=_TEST_METADATA,
3909+
model_version_id=_TEST_VERSION_ID,
3910+
model_resource_name=_TEST_MODEL_NAME,
3911+
)
3912+
finally:
3913+
await runner.cleanup()
3914+
36903915
@pytest.mark.usefixtures("get_endpoint_mock")
36913916
def test_explain(self, predict_client_explain_mock):
36923917
test_endpoint = models.Endpoint(_TEST_ID)

0 commit comments

Comments
 (0)