Skip to content

Commit cd575fa

Browse files
fix einstein predict response object
1 parent 68d4f9f commit cd575fa

2 files changed

Lines changed: 246 additions & 35 deletions

File tree

src/datacustomcode/einstein_predictions/spark_default.py

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
_STATUS_SUCCESS = "SUCCESS"
4040
_STATUS_ERROR = "ERROR"
4141

42+
# HTTP status considered a successful prediction call.
43+
_HTTP_OK = 200
44+
4245

4346
class DefaultSparkEinsteinPredictions(SparkEinsteinPredictions):
4447

@@ -104,6 +107,8 @@ def einstein_predict_col(
104107

105108
def _predict(values_row: Any) -> Dict[str, Optional[str]]:
106109
if values_row is None:
110+
# An entirely null features struct is not the normal per-feature null
111+
# case; surface it directly rather than masking it (local debuggability).
107112
return {
108113
"status": _STATUS_ERROR,
109114
"response": None,
@@ -181,6 +186,21 @@ def _call_predictions(
181186
return predictions.predict(request)
182187

183188

189+
def _null_feature_name(features: Dict[str, Any]) -> Optional[str]:
190+
"""Return the name of the first null feature value, or ``None``."""
191+
for name, value in features.items():
192+
if value is None:
193+
return name
194+
return None
195+
196+
197+
def _null_feature_message(name: str) -> str:
198+
return (
199+
f"Feature '{name}' has null value. Use coalesce() or when() to handle "
200+
f"nulls before calling einstein_predict."
201+
)
202+
203+
184204
def _invoke_predictions(
185205
predictions: "EinsteinPredictions",
186206
model_api_name: str,
@@ -190,18 +210,42 @@ def _invoke_predictions(
190210
) -> Dict[str, Any]:
191211
from datacustomcode.einstein_predictions.errors import EinsteinPredictionsCallError
192212

193-
response = _call_predictions(
194-
predictions, model_api_name, prediction_type, features, settings
195-
)
196-
if not response.is_success:
197-
error_code = _extract_error_code(response)
213+
null_feature = _null_feature_name(features)
214+
if null_feature is not None:
215+
message = _null_feature_message(null_feature)
216+
raise EinsteinPredictionsCallError(
217+
f"Einstein Predictions call failed: {message}",
218+
status=None,
219+
error_code=None,
220+
error_message=message,
221+
)
222+
223+
try:
224+
response = _call_predictions(
225+
predictions, model_api_name, prediction_type, features, settings
226+
)
227+
except EinsteinPredictionsCallError:
228+
raise
229+
except Exception as exc:
230+
# Transport/build failures: surface the real error (no masking) so local runs stay
231+
# debuggable. error_code stays None since there is no HTTP status.
232+
raise EinsteinPredictionsCallError(
233+
f"Einstein Predictions call failed: {exc}",
234+
status=None,
235+
error_code=None,
236+
error_message=str(exc),
237+
)
238+
239+
if response.status_code != _HTTP_OK:
240+
error_message = (
241+
json.dumps(response.data) if response.data is not None else None
242+
)
198243
raise EinsteinPredictionsCallError(
199244
f"Einstein Predictions call failed: "
200-
f"status_code={response.status_code}, "
201-
f"error_code={error_code!r}, message={response.data!r}",
245+
f"status_code={response.status_code}, message={error_message!r}",
202246
status=response.status_code,
203-
error_code=error_code,
204-
error_message=str(response.data) if response.data else None,
247+
error_code=str(response.status_code),
248+
error_message=error_message,
205249
)
206250
return response.data or {}
207251

@@ -213,27 +257,46 @@ def _invoke_predictions_as_struct(
213257
features: Dict[str, Any],
214258
settings: Optional[Dict[str, Any]],
215259
) -> Dict[str, Optional[str]]:
216-
response = _call_predictions(
217-
predictions, model_api_name, prediction_type, features, settings
218-
)
219-
if not response.is_success:
260+
# (a) Customer-actionable data condition — surface the actionable message directly.
261+
null_feature = _null_feature_name(features)
262+
if null_feature is not None:
220263
return {
221264
"status": _STATUS_ERROR,
222265
"response": None,
223-
"error_code": _extract_error_code(response),
224-
"error_message": str(response.data) if response.data else None,
266+
"error_code": None,
267+
"error_message": _null_feature_message(null_feature),
225268
}
226-
return {
227-
"status": _STATUS_SUCCESS,
228-
"response": json.dumps(response.data) if response.data is not None else None,
229-
"error_code": None,
230-
"error_message": None,
231-
}
232269

270+
# (b) Transport/build failures — surface the real error (no masking) so local runs stay
271+
# debuggable. error_code stays None since there is no HTTP status.
272+
try:
273+
response = _call_predictions(
274+
predictions, model_api_name, prediction_type, features, settings
275+
)
276+
except Exception as exc:
277+
return {
278+
"status": _STATUS_ERROR,
279+
"response": None,
280+
"error_code": None,
281+
"error_message": str(exc),
282+
}
233283

234-
def _extract_error_code(response: "PredictionResponse") -> Optional[str]:
235-
if response.data:
236-
error_code = response.data.get("errorCode")
237-
if error_code is not None:
238-
return str(error_code)
239-
return None
284+
if response.status_code == _HTTP_OK:
285+
return {
286+
"status": _STATUS_SUCCESS,
287+
"response": (
288+
json.dumps(response.data) if response.data is not None else None
289+
),
290+
"error_code": None,
291+
"error_message": None,
292+
}
293+
294+
# (c) Non-200 SFAP HTTP error: error_code = status code, error_message = data JSON.
295+
return {
296+
"status": _STATUS_ERROR,
297+
"response": None,
298+
"error_code": str(response.status_code),
299+
"error_message": (
300+
json.dumps(response.data) if response.data is not None else None
301+
),
302+
}

tests/test_spark_einstein_predictions.py

Lines changed: 156 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -206,14 +206,16 @@ def test_udf_returns_error_struct_for_null_row(self, mock_struct, mock_udf):
206206
out = udf_fn(None)
207207
assert out["status"] == _STATUS_ERROR
208208
assert out["response"] is None
209+
assert out["error_code"] is None
209210
assert "null" in out["error_message"].lower()
210211
mock_inner.predict.assert_not_called()
211212

212213
@patch("pyspark.sql.functions.udf")
213214
@patch("pyspark.sql.functions.struct")
214215
def test_udf_returns_error_struct_on_http_error(self, mock_struct, mock_udf):
215216
"""Per-row errors are returned as ``status="ERROR"`` structs so one bad
216-
row does not abort the Spark job."""
217+
row does not abort the Spark job. ``error_code`` is the HTTP status code
218+
and ``error_message`` is the SFAP body as JSON."""
217219
mock_struct.return_value = MagicMock()
218220
mock_udf.return_value = MagicMock()
219221
mock_inner = MagicMock()
@@ -233,8 +235,99 @@ def test_udf_returns_error_struct_on_http_error(self, mock_struct, mock_udf):
233235

234236
assert out["status"] == _STATUS_ERROR
235237
assert out["response"] is None
236-
assert out["error_code"] == "UNAVAILABLE"
237-
assert out["error_message"] is not None
238+
assert out["error_code"] == "503"
239+
assert json.loads(out["error_message"]) == {"errorCode": "UNAVAILABLE"}
240+
241+
@patch("pyspark.sql.functions.udf")
242+
@patch("pyspark.sql.functions.struct")
243+
def test_udf_returns_specific_error_for_null_feature(self, mock_struct, mock_udf):
244+
"""A null feature value is a customer-actionable data condition: it is
245+
surfaced with error_code None and an actionable message, never coerced
246+
to the string "None"."""
247+
mock_struct.return_value = MagicMock()
248+
mock_udf.return_value = MagicMock()
249+
mock_inner = MagicMock()
250+
predictions = DefaultSparkEinsteinPredictions(einstein_predictions=mock_inner)
251+
252+
predictions.einstein_predict_col(
253+
"model1", PredictionType.REGRESSION, {"beds": MagicMock()}
254+
)
255+
256+
udf_fn = mock_udf.call_args.args[0]
257+
row = MagicMock()
258+
row.asDict.return_value = {"beds": None}
259+
out = udf_fn(row)
260+
261+
assert out["status"] == _STATUS_ERROR
262+
assert out["response"] is None
263+
assert out["error_code"] is None
264+
assert "beds" in out["error_message"]
265+
assert "coalesce" in out["error_message"]
266+
mock_inner.predict.assert_not_called()
267+
268+
@patch("pyspark.sql.functions.udf")
269+
@patch("pyspark.sql.functions.struct")
270+
def test_udf_returns_generic_error_on_transport_failure(
271+
self, mock_struct, mock_udf
272+
):
273+
"""Transport/build exceptions are logged and surfaced with error_code None
274+
and the exception text as error_message so local runs stay debuggable."""
275+
mock_struct.return_value = MagicMock()
276+
mock_udf.return_value = MagicMock()
277+
mock_inner = MagicMock()
278+
mock_inner.predict.side_effect = RuntimeError("connection refused to 10.0.0.1")
279+
predictions = DefaultSparkEinsteinPredictions(einstein_predictions=mock_inner)
280+
281+
predictions.einstein_predict_col(
282+
"model1", PredictionType.REGRESSION, {"beds": MagicMock()}
283+
)
284+
285+
udf_fn = mock_udf.call_args.args[0]
286+
row = MagicMock()
287+
row.asDict.return_value = {"beds": 3.0}
288+
out = udf_fn(row)
289+
290+
assert out["status"] == _STATUS_ERROR
291+
assert out["response"] is None
292+
assert out["error_code"] is None
293+
assert out["error_message"] == "connection refused to 10.0.0.1"
294+
295+
@patch("pyspark.sql.functions.udf")
296+
@patch("pyspark.sql.functions.struct")
297+
def test_udf_passes_through_prediction_failure_as_success(
298+
self, mock_struct, mock_udf
299+
):
300+
"""A 200 response carrying a PredictionFailure stays SUCCESS; the failure
301+
is passed through in ``response`` for the script to handle."""
302+
mock_struct.return_value = MagicMock()
303+
mock_udf.return_value = MagicMock()
304+
failure_body = {
305+
"results": [
306+
{
307+
"type": "PredictionFailure",
308+
"error": {
309+
"message": "no match",
310+
"predictionErrorCode": "PREDICTION_ERROR_CODE_NO_MATCH",
311+
},
312+
}
313+
]
314+
}
315+
mock_inner = MagicMock()
316+
mock_inner.predict.return_value = _success_response(failure_body)
317+
predictions = DefaultSparkEinsteinPredictions(einstein_predictions=mock_inner)
318+
319+
predictions.einstein_predict_col(
320+
"model1", PredictionType.BINARY_CLASSIFICATION, {"beds": MagicMock()}
321+
)
322+
323+
udf_fn = mock_udf.call_args.args[0]
324+
row = MagicMock()
325+
row.asDict.return_value = {"beds": 3.0}
326+
out = udf_fn(row)
327+
328+
assert out["status"] == _STATUS_SUCCESS
329+
assert json.loads(out["response"]) == failure_body
330+
assert out["error_code"] is None
238331

239332

240333
class TestInvokePredictions:
@@ -260,9 +353,37 @@ def test_raises_call_error_on_error_response(self):
260353
)
261354

262355
assert excinfo.value.status == 503
263-
assert excinfo.value.error_code == "UNAVAILABLE"
356+
assert excinfo.value.error_code == "503"
357+
assert excinfo.value.error_message == json.dumps({"errorCode": "UNAVAILABLE"})
264358
assert "503" in str(excinfo.value)
265-
assert "UNAVAILABLE" in str(excinfo.value)
359+
360+
def test_raises_specific_error_on_null_feature(self):
361+
mock_inner = MagicMock()
362+
363+
with pytest.raises(EinsteinPredictionsCallError) as excinfo:
364+
_invoke_predictions(
365+
mock_inner, "model", PredictionType.REGRESSION, {"x": None}, None
366+
)
367+
368+
assert excinfo.value.status is None
369+
assert excinfo.value.error_code is None
370+
assert "x" in str(excinfo.value.error_message)
371+
assert "coalesce" in str(excinfo.value.error_message)
372+
mock_inner.predict.assert_not_called()
373+
374+
def test_raises_generic_error_on_transport_failure(self):
375+
mock_inner = MagicMock()
376+
mock_inner.predict.side_effect = RuntimeError("connection refused to 10.0.0.1")
377+
378+
with pytest.raises(EinsteinPredictionsCallError) as excinfo:
379+
_invoke_predictions(
380+
mock_inner, "model", PredictionType.REGRESSION, {"x": 1.0}, None
381+
)
382+
383+
assert excinfo.value.status is None
384+
assert excinfo.value.error_code is None
385+
assert excinfo.value.error_message == "connection refused to 10.0.0.1"
386+
assert "connection refused" in str(excinfo.value)
266387

267388

268389
class TestInvokePredictionsAsStruct:
@@ -295,8 +416,35 @@ def test_error_returns_error_struct_without_raising(self):
295416

296417
assert out["status"] == _STATUS_ERROR
297418
assert out["response"] is None
298-
assert out["error_code"] == "UNAVAILABLE"
299-
assert out["error_message"] is not None
419+
assert out["error_code"] == "503"
420+
assert json.loads(out["error_message"]) == {"errorCode": "UNAVAILABLE"}
421+
422+
def test_null_feature_returns_specific_error_struct(self):
423+
mock_inner = MagicMock()
424+
425+
out = _invoke_predictions_as_struct(
426+
mock_inner, "model", PredictionType.REGRESSION, {"x": None}, None
427+
)
428+
429+
assert out["status"] == _STATUS_ERROR
430+
assert out["response"] is None
431+
assert out["error_code"] is None
432+
assert "x" in out["error_message"]
433+
assert "None" != out["error_message"]
434+
mock_inner.predict.assert_not_called()
435+
436+
def test_transport_failure_returns_generic_error_struct(self):
437+
mock_inner = MagicMock()
438+
mock_inner.predict.side_effect = RuntimeError("connection refused to 10.0.0.1")
439+
440+
out = _invoke_predictions_as_struct(
441+
mock_inner, "model", PredictionType.REGRESSION, {"x": 1.0}, None
442+
)
443+
444+
assert out["status"] == _STATUS_ERROR
445+
assert out["response"] is None
446+
assert out["error_code"] is None
447+
assert out["error_message"] == "connection refused to 10.0.0.1"
300448

301449

302450
class TestDefaultSparkEinsteinPredictionsErrorHandling:
@@ -316,4 +464,4 @@ def test_raises_on_error_response(self):
316464
)
317465

318466
assert excinfo.value.status == 429
319-
assert excinfo.value.error_code == "RATE_LIMITED"
467+
assert excinfo.value.error_code == "429"

0 commit comments

Comments
 (0)