Skip to content

Commit c730e10

Browse files
authored
fix: Keep AsyncPredictor serializers in sync with wrapped predictor (#6335)
Port the V2 fix for #3100 to sagemaker-serve. AsyncPredictor copied the wrapped predictor's serializer and deserializer at construction, so overriding AsyncPredictor.deserializer did not affect the Accept header or result decoding, which read from the wrapped predictor. Make serializer and deserializer properties that delegate to the wrapped predictor. Fixes #3100
1 parent 4f3cc56 commit c730e10

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

‎sagemaker-serve/src/sagemaker/serve/predictor_async.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,40 @@ def __init__(
5252
else:
5353
self.s3_client = self.sagemaker_session.s3_client
5454

55-
self.serializer = predictor.serializer
56-
self.deserializer = predictor.deserializer
5755
self.name = name
5856
self._endpoint_config_name = None
5957
self._model_names = None
6058
self._context = None
6159
self._input_path = None
6260

61+
@property
62+
def serializer(self):
63+
"""The serializer used to encode request data uploaded to Amazon S3.
64+
65+
Reads from and writes to the wrapped predictor, so it always stays in
66+
sync with the serializer the underlying predictor uses.
67+
"""
68+
return self.predictor.serializer
69+
70+
@serializer.setter
71+
def serializer(self, serializer):
72+
"""Set the serializer on the wrapped predictor."""
73+
self.predictor.serializer = serializer
74+
75+
@property
76+
def deserializer(self):
77+
"""The deserializer used to decode the async inference result.
78+
79+
Reads from and writes to the wrapped predictor, so the ``Accept`` header
80+
and the decoding of the Amazon S3 output both honor the configured value.
81+
"""
82+
return self.predictor.deserializer
83+
84+
@deserializer.setter
85+
def deserializer(self, deserializer):
86+
"""Set the deserializer on the wrapped predictor."""
87+
self.predictor.deserializer = deserializer
88+
6389
def predict(
6490
self,
6591
data=None,

‎sagemaker-serve/tests/unit/test_predictor_async.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import io
12
import unittest
23
from unittest.mock import Mock, patch
4+
5+
from sagemaker.core.deserializers import JSONDeserializer
6+
from sagemaker.core.serializers import JSONSerializer
37
from sagemaker.serve.predictor_async import AsyncPredictor
48

59

@@ -107,5 +111,78 @@ def test_disable_data_capture(self):
107111
self.mock_predictor.disable_data_capture.assert_called_once()
108112

109113

114+
class _StubPredictor:
115+
"""Minimal predictor exposing the attributes ``AsyncPredictor`` relies on."""
116+
117+
def __init__(self, sagemaker_session, serializer, deserializer):
118+
self.endpoint_name = "test-endpoint"
119+
self.sagemaker_session = sagemaker_session
120+
self.serializer = serializer
121+
self.deserializer = deserializer
122+
123+
@property
124+
def accept(self):
125+
return self.deserializer.ACCEPT
126+
127+
def _handle_response(self, response):
128+
return self.deserializer.deserialize(response["Body"], response["ContentType"])
129+
130+
131+
class TestAsyncPredictorSerializerOverrides(unittest.TestCase):
132+
"""Overrides set on AsyncPredictor must reach the request and the result (issue #3100)."""
133+
134+
def setUp(self):
135+
self.sagemaker_session = Mock()
136+
self.sagemaker_session.default_bucket.return_value = "bucket"
137+
self.sagemaker_session.default_bucket_prefix = None
138+
self.sagemaker_session.sagemaker_runtime_client.invoke_endpoint_async.return_value = {
139+
"OutputLocation": "s3://bucket/output",
140+
}
141+
self.sagemaker_session.s3_client.get_object.return_value = {
142+
"Body": io.BytesIO(b'{"result": [1, 2, 3]}'),
143+
"ContentType": "application/json",
144+
}
145+
default_deserializer = Mock()
146+
default_deserializer.ACCEPT = ("*/*",)
147+
self.predictor = _StubPredictor(self.sagemaker_session, Mock(), default_deserializer)
148+
149+
def test_deserializer_override_is_used_for_accept_and_result(self):
150+
async_predictor = AsyncPredictor(self.predictor)
151+
152+
async_predictor.deserializer = JSONDeserializer()
153+
154+
self.assertIsInstance(self.predictor.deserializer, JSONDeserializer)
155+
156+
result = async_predictor.predict(input_path="s3://bucket/input")
157+
158+
_, kwargs = self.sagemaker_session.sagemaker_runtime_client.invoke_endpoint_async.call_args
159+
self.assertEqual(kwargs["Accept"], "application/json")
160+
self.assertEqual(result, {"result": [1, 2, 3]})
161+
162+
def test_serializer_override_is_used_for_upload(self):
163+
async_predictor = AsyncPredictor(self.predictor, name="test")
164+
165+
async_predictor.serializer = JSONSerializer()
166+
167+
self.assertIsInstance(self.predictor.serializer, JSONSerializer)
168+
169+
async_predictor.predict_async(data={"hi": "there"})
170+
171+
_, kwargs = self.sagemaker_session.s3_client.put_object.call_args
172+
self.assertEqual(kwargs["Body"], '{"hi": "there"}')
173+
self.assertEqual(kwargs["ContentType"], "application/json")
174+
175+
def test_reflects_serializers_set_on_wrapped_predictor(self):
176+
async_predictor = AsyncPredictor(self.predictor)
177+
178+
serializer = JSONSerializer()
179+
deserializer = JSONDeserializer()
180+
self.predictor.serializer = serializer
181+
self.predictor.deserializer = deserializer
182+
183+
self.assertIs(async_predictor.serializer, serializer)
184+
self.assertIs(async_predictor.deserializer, deserializer)
185+
186+
110187
if __name__ == "__main__":
111188
unittest.main()

0 commit comments

Comments
 (0)