Skip to content

Commit a27063c

Browse files
feat: add automatic retry for transient dbt command errors
Add per-adapter transient error detection and automatic retry logic using tenacity to CommandLineDbtRunner._run_command. - New module transient_errors.py with per-adapter error patterns for BigQuery, Snowflake, Redshift, Databricks, Athena, Dremio, Postgres, Trino, and ClickHouse, plus common connection error patterns. - _execute_inner_command wraps _inner_run_command with tenacity retry (3 attempts, exponential backoff 10-60s). - Only retries when output matches a known transient error pattern for the active adapter. Non-transient failures propagate immediately. - Handles both raise_on_failure=True (DbtCommandError) and raise_on_failure=False (result.success=False) code paths. - Added tenacity>=8.0,<10.0 to pyproject.toml dependencies. Co-Authored-By: Itamar Hartstein <haritamar@gmail.com>
1 parent c65cd98 commit a27063c

3 files changed

Lines changed: 256 additions & 9 deletions

File tree

elementary/clients/dbt/command_line_dbt_runner.py

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,38 @@
55
from typing import Any, Dict, List, Optional
66

77
import yaml
8+
from tenacity import (
9+
RetryCallState,
10+
retry,
11+
retry_if_exception,
12+
stop_after_attempt,
13+
wait_exponential,
14+
)
815

916
from elementary.clients.dbt.base_dbt_runner import BaseDbtRunner
1017
from elementary.clients.dbt.dbt_log import parse_dbt_output
18+
from elementary.clients.dbt.transient_errors import is_transient_error
1119
from elementary.exceptions.exceptions import DbtCommandError, DbtLsCommandError
1220
from elementary.monitor.dbt_project_utils import is_dbt_package_up_to_date
1321
from elementary.utils.env_vars import is_debug
1422
from elementary.utils.log import get_logger
1523

1624
logger = get_logger(__name__)
1725

26+
# Retry configuration for transient errors.
27+
_TRANSIENT_MAX_RETRIES = 3
28+
_TRANSIENT_WAIT_MULTIPLIER = 10 # seconds
29+
_TRANSIENT_WAIT_MAX = 60 # seconds
30+
31+
32+
class DbtTransientError(Exception):
33+
"""Raised internally to signal a transient dbt failure that should be retried."""
34+
35+
def __init__(self, result: "DbtCommandResult", message: str) -> None:
36+
super().__init__(message)
37+
self.result = result
38+
39+
1840
MACRO_RESULT_PATTERN = re.compile(
1941
"Elementary: --ELEMENTARY-MACRO-OUTPUT-START--(.*)--ELEMENTARY-MACRO-OUTPUT-END--"
2042
)
@@ -112,23 +134,111 @@ def _run_command(
112134
else:
113135
logger.debug(log_msg)
114136

115-
result = self._inner_run_command(
116-
dbt_command_args,
137+
result = self._execute_inner_command(
138+
dbt_command_args=dbt_command_args,
139+
log_command_args=log_command_args,
117140
capture_output=capture_output,
118141
quiet=quiet,
119142
log_output=log_output,
120143
log_format=log_format,
121144
)
122145

123-
if capture_output and result.output:
124-
logger.debug(
125-
f"Result bytes size for command '{log_command_args}' is {len(result.output)}"
146+
return result
147+
148+
def _execute_inner_command(
149+
self,
150+
dbt_command_args: List[str],
151+
log_command_args: List[str],
152+
capture_output: bool,
153+
quiet: bool,
154+
log_output: bool,
155+
log_format: str,
156+
) -> DbtCommandResult:
157+
"""Execute ``_inner_run_command`` with automatic retries for transient errors.
158+
159+
This method wraps the actual command execution, checks the result
160+
for known transient error patterns (per adapter), and retries using
161+
``tenacity`` when appropriate. Non-transient failures are returned
162+
immediately without retrying.
163+
"""
164+
165+
def _before_retry(retry_state: RetryCallState) -> None:
166+
attempt = retry_state.attempt_number
167+
logger.warning(
168+
"Transient error detected for dbt command '%s' "
169+
"(attempt %d/%d). Retrying...",
170+
" ".join(log_command_args),
171+
attempt,
172+
_TRANSIENT_MAX_RETRIES,
126173
)
127-
if log_output or is_debug():
128-
for log in parse_dbt_output(result.output, log_format):
129-
logger.info(log.msg)
130174

131-
return result
175+
@retry(
176+
retry=retry_if_exception(lambda exc: isinstance(exc, DbtTransientError)),
177+
stop=stop_after_attempt(_TRANSIENT_MAX_RETRIES),
178+
wait=wait_exponential(
179+
multiplier=_TRANSIENT_WAIT_MULTIPLIER, max=_TRANSIENT_WAIT_MAX
180+
),
181+
before_sleep=_before_retry,
182+
reraise=True,
183+
)
184+
def _attempt() -> DbtCommandResult:
185+
try:
186+
result = self._inner_run_command(
187+
dbt_command_args,
188+
capture_output=capture_output,
189+
quiet=quiet,
190+
log_output=log_output,
191+
log_format=log_format,
192+
)
193+
except DbtCommandError as exc:
194+
# DbtCommandError is raised when raise_on_failure=True and
195+
# the command exits with a non-zero return code. Check
196+
# whether the error is transient before propagating.
197+
if is_transient_error(self.target, output=str(exc), stderr=None):
198+
raise DbtTransientError(
199+
result=DbtCommandResult(
200+
success=False, output=str(exc), stderr=None
201+
),
202+
message=(f"Transient error during dbt command: {exc}"),
203+
) from exc
204+
raise
205+
206+
if capture_output and result.output:
207+
logger.debug(
208+
"Result bytes size for command '%s' is %d",
209+
log_command_args,
210+
len(result.output),
211+
)
212+
if log_output or is_debug():
213+
for log in parse_dbt_output(result.output, log_format):
214+
logger.info(log.msg)
215+
216+
# Command completed but may have failed (raise_on_failure=False).
217+
if not result.success and is_transient_error(
218+
self.target, output=result.output, stderr=result.stderr
219+
):
220+
raise DbtTransientError(
221+
result=result,
222+
message=(
223+
f"Transient error during dbt command: "
224+
f"{''.join(log_command_args)}"
225+
),
226+
)
227+
228+
return result
229+
230+
try:
231+
return _attempt()
232+
except DbtTransientError as exc:
233+
# All retry attempts exhausted — return the last failed result
234+
# so callers that check ``result.success`` still work.
235+
logger.error(
236+
"dbt command '%s' failed after %d attempts due to "
237+
"transient errors. Returning last failure.",
238+
" ".join(log_command_args),
239+
_TRANSIENT_MAX_RETRIES,
240+
)
241+
return exc.result
132242

133243
def deps(self, quiet: bool = False, capture_output: bool = True) -> bool:
134244
result = self._run_command(
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Per-adapter transient error patterns for automatic retry.
2+
3+
Each adapter may produce transient errors that are safe to retry. This
4+
module centralises those patterns so that the runner can decide whether a
5+
failed dbt command should be retried transparently.
6+
7+
To add patterns for a new adapter, append a new entry to
8+
``_ADAPTER_PATTERNS`` with the *target name* as key and a tuple of
9+
**plain, lowercase** substrings that appear in the error output.
10+
Matching is case-insensitive substring search so regex is not needed.
11+
"""
12+
13+
from typing import Dict, Optional, Sequence, Tuple
14+
15+
# ---------------------------------------------------------------------------
16+
# Per-adapter transient error substrings (all lowercase).
17+
#
18+
# A command failure is considered *transient* when the dbt output
19+
# (stdout + stderr, lowercased) contains **any** of the substrings
20+
# listed for the active adapter **or** in the ``_COMMON`` list.
21+
# ---------------------------------------------------------------------------
22+
23+
_COMMON: Tuple[str, ...] = (
24+
# Generic connection / HTTP errors that any adapter can surface.
25+
"connection reset by peer",
26+
"connection was closed",
27+
"remotedisconnected",
28+
"connectionerror",
29+
"brokenpipeerror",
30+
"connection aborted",
31+
"connection refused",
32+
"read timed out",
33+
)
34+
35+
_ADAPTER_PATTERNS: Dict[str, Tuple[str, ...]] = {
36+
"bigquery": (
37+
# Streaming-buffer delay after a streaming insert.
38+
"streaming data from",
39+
"is temporarily unavailable",
40+
# Generic transient backend error (500).
41+
"retrying may solve the problem",
42+
"backenderror",
43+
# Rate-limit / quota errors.
44+
"exceeded rate limits",
45+
"rateLimitExceeded".lower(),
46+
"quota exceeded",
47+
# Internal errors surfaced as 503 / "internal error".
48+
"internal error encountered",
49+
"503",
50+
),
51+
"snowflake": (
52+
"could not connect to snowflake backend",
53+
"authentication token has expired",
54+
"incident",
55+
"service is unavailable",
56+
),
57+
"redshift": (
58+
"connection timed out",
59+
"could not connect to the server",
60+
"ssl syscall error",
61+
),
62+
"databricks": (
63+
"temporarily_unavailable",
64+
"504 gateway timeout",
65+
"502 bad gateway",
66+
"service unavailable",
67+
),
68+
"databricks_catalog": (
69+
"temporarily_unavailable",
70+
"504 gateway timeout",
71+
"502 bad gateway",
72+
"service unavailable",
73+
),
74+
"athena": (
75+
"throttlingexception",
76+
"toomanyrequestsexception",
77+
"service unavailable",
78+
),
79+
"dremio": (
80+
"remotedisconnected",
81+
"connection was closed",
82+
),
83+
"postgres": (
84+
"could not connect to server",
85+
"connection timed out",
86+
"server closed the connection unexpectedly",
87+
"ssl syscall error",
88+
),
89+
"trino": (
90+
"service unavailable",
91+
"server returned http response code: 503",
92+
),
93+
"clickhouse": (
94+
"connection timed out",
95+
"broken pipe",
96+
),
97+
}
98+
99+
100+
def is_transient_error(
101+
target: Optional[str],
102+
output: Optional[str] = None,
103+
stderr: Optional[str] = None,
104+
) -> bool:
105+
"""Return ``True`` if *output*/*stderr* contain a known transient error.
106+
107+
Parameters
108+
----------
109+
target:
110+
The dbt target name (e.g. ``"bigquery"``, ``"snowflake"``).
111+
When ``None`` only the common patterns are checked.
112+
output:
113+
The captured stdout of the dbt command (may be ``None``).
114+
stderr:
115+
The captured stderr of the dbt command (may be ``None``).
116+
"""
117+
haystack = _build_haystack(output, stderr)
118+
if not haystack:
119+
return False
120+
121+
patterns: Sequence[str] = _COMMON
122+
if target is not None:
123+
adapter_patterns = _ADAPTER_PATTERNS.get(target.lower(), ())
124+
patterns = (*_COMMON, *adapter_patterns)
125+
126+
return any(pattern in haystack for pattern in patterns)
127+
128+
129+
def _build_haystack(output: Optional[str] = None, stderr: Optional[str] = None) -> str:
130+
"""Concatenate and lowercase *output* + *stderr* for matching."""
131+
parts = []
132+
if output:
133+
parts.append(output)
134+
if stderr:
135+
parts.append(stderr)
136+
return "\n".join(parts).lower()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ packaging = ">=20.9"
4242
azure-storage-blob = ">=12.11.0"
4343
pymsteams = ">=0.2.2,<1.0.0"
4444
tabulate = ">= 0.9.0"
45+
tenacity = ">=8.0,<10.0"
4546
pytz = ">= 2025.1"
4647

4748
dbt-snowflake = {version = ">=0.20,<2.0.0", optional = true}

0 commit comments

Comments
 (0)