Skip to content

Commit a0559d0

Browse files
IronPancopybara-github
authored andcommitted
fix: route Endpoint.predict_async via dedicated DNS when the endpoint is dedicated
Endpoint.predict_async was hardcoded to the public regional GAPIC async client, which rejects dedicated endpoint traffic with FailedPrecondition. The synchronous predict() already detects dedicated_endpoint_enabled and POSTs via AuthorizedSession to https://{dedicated_endpoint_dns}/v1/{name}:predict; this change mirrors that decision for the async path using aiohttp. When the endpoint is not dedicated, predict_async behavior is unchanged (still goes through PredictionServiceAsyncClient), so non-dedicated callers see no diff. The dedicated branch: - Raises ValueError when dedicated_endpoint_dns is empty, matching the sync path's error message. - Lazily builds and caches an aiohttp.ClientSession on the Endpoint instance (self._authorized_session_async). - Refreshes self.credentials per call (AuthorizedSession does this transparently in sync; async path must do it explicitly) and sends the bearer token in the Authorization header. - Returns a Prediction with the same field mapping the sync dedicated path uses. PiperOrigin-RevId: 922598716
1 parent 81c7f38 commit a0559d0

2 files changed

Lines changed: 284 additions & 17 deletions

File tree

google/cloud/aiplatform/models.py

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import re
2121
import shutil
2222
import tempfile
23+
import aiohttp
2324
import requests
2425
from typing import (
2526
Any,
@@ -696,6 +697,8 @@ def __init__(
696697
self._gca_resource = gca_endpoint_compat.Endpoint(name=endpoint_name)
697698

698699
self.authorized_session = None
700+
# Lazy aiohttp ClientSession for async predict on dedicated endpoints.
701+
self._authorized_session_async = None
699702

700703
@property
701704
def _prediction_client(self) -> utils.PredictionClientWithOverride:
@@ -2715,6 +2718,11 @@ async def predict_async(
27152718
my_predictions = response.predictions
27162719
```
27172720
2721+
For dedicated endpoints (``dedicated_endpoint_enabled=True``), the call
2722+
is routed via async HTTPS POST to the endpoint's dedicated DNS,
2723+
mirroring the synchronous ``predict()`` dedicated-endpoint path.
2724+
Otherwise the GAPIC async prediction client is used.
2725+
27182726
Args:
27192727
instances (List):
27202728
Required. The instances that are the input to the
@@ -2740,29 +2748,85 @@ async def predict_async(
27402748
Returns:
27412749
prediction (aiplatform.Prediction):
27422750
Prediction with returned predictions and Model ID.
2751+
2752+
Raises:
2753+
ValueError: If the dedicated endpoint DNS is empty for dedicated
2754+
endpoints, or if the prediction request to a dedicated endpoint
2755+
returns a non-200 status.
27432756
"""
27442757
self.wait()
27452758

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)
2759+
if not self.dedicated_endpoint_enabled:
2760+
prediction_response = await self._prediction_async_client.predict(
2761+
endpoint=self._gca_resource.name,
2762+
instances=instances,
2763+
parameters=parameters,
2764+
timeout=timeout,
2765+
)
2766+
if prediction_response._pb.metadata:
2767+
metadata = json_format.MessageToDict(prediction_response._pb.metadata)
2768+
else:
2769+
metadata = None
2770+
2771+
return Prediction(
2772+
predictions=[
2773+
json_format.MessageToDict(item)
2774+
for item in prediction_response.predictions.pb
2775+
],
2776+
metadata=metadata,
2777+
deployed_model_id=prediction_response.deployed_model_id,
2778+
model_version_id=prediction_response.model_version_id,
2779+
model_resource_name=prediction_response.model,
2780+
)
2781+
2782+
# Dedicated endpoint: REST POST to the dedicated DNS via aiohttp.
2783+
if not self.dedicated_endpoint_dns:
2784+
raise ValueError(
2785+
"Dedicated endpoint DNS is empty. Please make sure endpoint"
2786+
"and model are ready before making a prediction."
2787+
)
2788+
2789+
if parameters is not None:
2790+
data = json.dumps({"instances": instances, "parameters": parameters})
27542791
else:
2755-
metadata = None
2792+
data = json.dumps({"instances": instances})
2793+
2794+
if self._authorized_session_async is None:
2795+
self.credentials._scopes = constants.base.DEFAULT_AUTHED_SCOPES
2796+
self._authorized_session_async = aiohttp.ClientSession()
2797+
2798+
# Refresh the bearer token per call. ``AuthorizedSession`` (sync)
2799+
# handles refresh internally; in the async path we do it explicitly.
2800+
self.credentials.refresh(google_auth_requests.Request())
2801+
headers = {
2802+
"Authorization": f"Bearer {self.credentials.token}",
2803+
"Content-Type": "application/json",
2804+
}
2805+
url = f"https://{self.dedicated_endpoint_dns}/v1/{self.resource_name}:predict"
2806+
aiohttp_timeout = (
2807+
aiohttp.ClientTimeout(total=timeout) if timeout is not None else None
2808+
)
2809+
2810+
async with self._authorized_session_async.post(
2811+
url=url,
2812+
data=data,
2813+
headers=headers,
2814+
timeout=aiohttp_timeout,
2815+
) as response:
2816+
if response.status != 200:
2817+
text = await response.text()
2818+
raise ValueError(
2819+
f"Failed to make prediction request. Status code:"
2820+
f"{response.status}, response: {text}."
2821+
)
2822+
prediction_response = await response.json()
27562823

27572824
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,
2825+
predictions=prediction_response.get("predictions"),
2826+
metadata=prediction_response.get("metadata"),
2827+
deployed_model_id=prediction_response.get("deployedModelId"),
2828+
model_resource_name=prediction_response.get("model"),
2829+
model_version_id=prediction_response.get("modelVersionId"),
27662830
)
27672831

27682832
def raw_predict(

tests/unit/aiplatform/test_endpoints.py

Lines changed: 203 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,175 @@ 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("get_dedicated_endpoint_mock")
3797+
async def test_predict_async_dedicated_endpoint_integration(self):
3798+
"""Hermetic integration test: real aiohttp request against a local server.
3799+
3800+
Unlike the mock-based tests above, this exercises the real
3801+
aiohttp.ClientSession code path (connection, request encoding,
3802+
response decoding, JSON parsing, async context manager teardown) by
3803+
pointing the dedicated DNS at an in-process aiohttp.web server.
3804+
3805+
The server validates the URL path, headers, and body shape the SDK
3806+
sends, and returns a Vertex-shaped JSON response. The SDK builds the
3807+
URL as ``https://{dns}/...``; we wrap the session to downgrade the
3808+
scheme to ``http://`` so the local server can serve plain HTTP without
3809+
needing self-signed TLS plumbing in the test.
3810+
"""
3811+
captured = {}
3812+
3813+
async def predict_handler(request: aiohttp_web.Request):
3814+
captured["path"] = request.path
3815+
captured["headers"] = dict(request.headers)
3816+
captured["body"] = await request.json()
3817+
return aiohttp_web.json_response(
3818+
{
3819+
"predictions": _TEST_PREDICTION,
3820+
"metadata": _TEST_METADATA,
3821+
"deployedModelId": _TEST_DEPLOYED_MODELS[0].id,
3822+
"model": _TEST_MODEL_NAME,
3823+
"modelVersionId": "1",
3824+
}
3825+
)
3826+
3827+
app = aiohttp_web.Application()
3828+
app.router.add_post(
3829+
f"/v1/{_TEST_ENDPOINT_NAME}:predict", predict_handler
3830+
)
3831+
runner = aiohttp_web.AppRunner(app)
3832+
await runner.setup()
3833+
site = aiohttp_web.TCPSite(runner, "127.0.0.1", 0)
3834+
await site.start()
3835+
port = site._server.sockets[0].getsockname()[1]
3836+
3837+
try:
3838+
test_endpoint = models.Endpoint(_TEST_ENDPOINT_NAME)
3839+
3840+
# Force the dedicated DNS to point at the local server.
3841+
with mock.patch.object(
3842+
models.Endpoint,
3843+
"dedicated_endpoint_dns",
3844+
new_callable=mock.PropertyMock,
3845+
return_value=f"127.0.0.1:{port}",
3846+
), mock.patch.object(
3847+
auth_credentials.AnonymousCredentials, "refresh"
3848+
):
3849+
# Inject a session that downgrades https:// -> http:// so the
3850+
# local test server can serve plain HTTP. Real aiohttp does
3851+
# everything else (real socket, real request/response cycle).
3852+
real_session = aiohttp.ClientSession()
3853+
original_post = real_session.post
3854+
3855+
def http_post(url, **kwargs):
3856+
return original_post(
3857+
url.replace("https://", "http://", 1), **kwargs
3858+
)
3859+
3860+
real_session.post = http_post
3861+
test_endpoint._authorized_session_async = real_session
3862+
# Give the mocked credentials a fake token.
3863+
test_endpoint.credentials.token = "test-token"
3864+
3865+
try:
3866+
prediction = await test_endpoint.predict_async(
3867+
instances=_TEST_INSTANCES,
3868+
parameters={"param": 3.0},
3869+
)
3870+
finally:
3871+
await real_session.close()
3872+
3873+
# Verify the SDK sent the right wire request.
3874+
assert captured["path"] == f"/v1/{_TEST_ENDPOINT_NAME}:predict"
3875+
assert captured["headers"]["Content-Type"] == "application/json"
3876+
assert captured["headers"]["Authorization"] == "Bearer test-token"
3877+
assert captured["body"] == {
3878+
"instances": _TEST_INSTANCES,
3879+
"parameters": {"param": 3.0},
3880+
}
3881+
3882+
# Verify the response was parsed correctly into a Prediction.
3883+
assert prediction == models.Prediction(
3884+
predictions=_TEST_PREDICTION,
3885+
deployed_model_id=_TEST_ID,
3886+
metadata=_TEST_METADATA,
3887+
model_version_id=_TEST_VERSION_ID,
3888+
model_resource_name=_TEST_MODEL_NAME,
3889+
)
3890+
finally:
3891+
await runner.cleanup()
3892+
36903893
@pytest.mark.usefixtures("get_endpoint_mock")
36913894
def test_explain(self, predict_client_explain_mock):
36923895
test_endpoint = models.Endpoint(_TEST_ID)

0 commit comments

Comments
 (0)