@@ -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
240333class 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
268389class 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
302450class 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