@@ -201,3 +201,116 @@ def test_successful_command_not_retried(self, mock_subprocess_run):
201201
202202 assert mock_subprocess_run .call_count == 1
203203 assert result is True
204+
205+
206+ def _make_api_runner (** kwargs ):
207+ """Create an APIDbtRunner with deps/packages stubbed out."""
208+ defaults = dict (
209+ project_dir = "fake_project" ,
210+ profiles_dir = "fake_profiles" ,
211+ target = None ,
212+ raise_on_failure = False ,
213+ run_deps_if_needed = False ,
214+ )
215+ defaults .update (kwargs )
216+ from elementary .clients .dbt .api_dbt_runner import APIDbtRunner
217+
218+ with mock .patch .object (APIDbtRunner , "_run_deps_if_needed" ):
219+ return APIDbtRunner (** defaults )
220+
221+
222+ @_ZERO_WAIT
223+ class TestAPIDbtRunnerTransientDetection :
224+ """Test that APIDbtRunner surfaces exception text for transient error detection.
225+
226+ The dbt Python API (APIDbtRunner) only captures JinjaLogInfo and
227+ RunningOperationCaughtError events into ``output``. Transient errors
228+ like RemoteDisconnected appear as ``res.exception`` — not in the
229+ captured output. Without surfacing this, the retry logic has nothing
230+ to match against and never fires.
231+ """
232+
233+ @mock .patch (
234+ "elementary.clients.dbt.api_dbt_runner.with_chdir" ,
235+ return_value = mock .MagicMock (
236+ __enter__ = mock .MagicMock (), __exit__ = mock .MagicMock ()
237+ ),
238+ )
239+ @mock .patch ("elementary.clients.dbt.api_dbt_runner.dbtRunner" )
240+ def test_transient_exception_triggers_retry (self , mock_dbt_runner_cls , _mock_chdir ):
241+ """A transient exception in res.exception should be retried."""
242+ # Simulate dbtRunnerResult with a transient exception.
243+ fail_result = mock .MagicMock ()
244+ fail_result .success = False
245+ fail_result .exception = ConnectionError (
246+ "('Connection aborted.', "
247+ "RemoteDisconnected('Remote end closed connection without response'))"
248+ )
249+
250+ success_result = mock .MagicMock ()
251+ success_result .success = True
252+ success_result .exception = None
253+
254+ # dbtRunner().invoke returns fail first, then success.
255+ mock_dbt_instance = mock .MagicMock ()
256+ mock_dbt_instance .invoke .side_effect = [fail_result , success_result ]
257+ mock_dbt_runner_cls .return_value = mock_dbt_instance
258+
259+ runner = _make_api_runner (raise_on_failure = False )
260+ result = runner .seed ()
261+
262+ assert mock_dbt_instance .invoke .call_count == 2
263+ assert result is True
264+
265+ @mock .patch (
266+ "elementary.clients.dbt.api_dbt_runner.with_chdir" ,
267+ return_value = mock .MagicMock (
268+ __enter__ = mock .MagicMock (), __exit__ = mock .MagicMock ()
269+ ),
270+ )
271+ @mock .patch ("elementary.clients.dbt.api_dbt_runner.dbtRunner" )
272+ def test_non_transient_exception_not_retried (
273+ self , mock_dbt_runner_cls , _mock_chdir
274+ ):
275+ """A non-transient exception should NOT be retried."""
276+ fail_result = mock .MagicMock ()
277+ fail_result .success = False
278+ fail_result .exception = Exception ("Compilation Error in model foo" )
279+
280+ mock_dbt_instance = mock .MagicMock ()
281+ mock_dbt_instance .invoke .return_value = fail_result
282+ mock_dbt_runner_cls .return_value = mock_dbt_instance
283+
284+ runner = _make_api_runner (raise_on_failure = False )
285+ result = runner .seed ()
286+
287+ assert mock_dbt_instance .invoke .call_count == 1
288+ assert result is False
289+
290+ @mock .patch (
291+ "elementary.clients.dbt.api_dbt_runner.with_chdir" ,
292+ return_value = mock .MagicMock (
293+ __enter__ = mock .MagicMock (), __exit__ = mock .MagicMock ()
294+ ),
295+ )
296+ @mock .patch ("elementary.clients.dbt.api_dbt_runner.dbtRunner" )
297+ def test_transient_exception_exhausts_retries (
298+ self , mock_dbt_runner_cls , _mock_chdir
299+ ):
300+ """After exhausting retries, the last failed result is returned."""
301+ fail_result = mock .MagicMock ()
302+ fail_result .success = False
303+ fail_result .exception = ConnectionError (
304+ "('Connection aborted.', "
305+ "RemoteDisconnected('Remote end closed connection without response'))"
306+ )
307+
308+ mock_dbt_instance = mock .MagicMock ()
309+ mock_dbt_instance .invoke .return_value = fail_result
310+ mock_dbt_runner_cls .return_value = mock_dbt_instance
311+
312+ runner = _make_api_runner (raise_on_failure = False )
313+ result = runner .seed ()
314+
315+ assert mock_dbt_instance .invoke .call_count == _TRANSIENT_MAX_RETRIES
316+ assert result is False
0 commit comments