DEVX-143: fix: add retry logic to TeaCLI for transient HTTP errors (502/503/504/429)
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run

This commit was merged in pull request #225.
This commit is contained in:
2026-07-17 00:44:27 +00:00
parent 368c87aabf
commit a02bf6d70e
2 changed files with 105 additions and 21 deletions
+58 -20
View File
@@ -40,21 +40,35 @@ Usage::
from __future__ import annotations
import json
import logging
import shutil
import subprocess # nosec B404
from typing import Any
import click
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.config import GITEA_API_URL
from devx.config import GITEA_API_URL, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
from devx.i18n import _
from devx.tokens import get_ci_token
logger = logging.getLogger("gitea_cli")
class TeaCLIError(Exception):
"""Raised when a tea CLI command fails."""
class _TransientTeaError(TeaCLIError):
"""Tea CLI error caused by a transient HTTP status (502/503/504/429)."""
def configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from CI_GITEA_API_TOKEN and DEVX_GITEA_API_URL.
@@ -140,6 +154,10 @@ class TeaCLI:
def _run(self, args: list[str], json_output: bool = True) -> str:
"""Run a tea command and return stdout.
Retries up to ``MAX_RETRIES`` times on transient HTTP errors
(502/503/504/429) detected in stderr/stdout, with exponential
backoff. Non-transient errors fail immediately.
Args:
args: Command arguments (without the leading ``tea``).
json_output: If True, append ``--output json`` to the command.
@@ -148,30 +166,50 @@ class TeaCLI:
stdout as a string.
Raises:
TeaCLIError: If the command fails.
TeaCLIError: If the command fails after retries are exhausted.
"""
cmd = [self._tea, *args]
if json_output:
cmd.extend(["--output", "json"])
def _execute() -> str:
try:
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError as e:
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
if result.returncode != 0:
parts = [
f"tea command failed (rc={result.returncode}): {' '.join(args)}",
f"stdout: {result.stdout.strip()}" if result.stdout.strip() else "",
f"stderr: {result.stderr.strip()}" if result.stderr.strip() else "",
]
msg = "\n".join(p for p in parts if p)
combined = f"{result.stdout} {result.stderr}".lower()
if any(str(code) in combined for code in RETRY_STATUS_CODES):
raise _TransientTeaError(msg)
raise TeaCLIError(msg)
return result.stdout.strip()
retry_decorator = retry(
stop=stop_after_attempt(MAX_RETRIES),
wait=wait_exponential(
multiplier=RETRY_BACKOFF_BASE,
min=RETRY_BACKOFF_BASE,
max=RETRY_BACKOFF_BASE**MAX_RETRIES,
),
retry=retry_if_exception_type(_TransientTeaError),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
try:
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError as e:
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
if result.returncode != 0:
# tea writes some errors to stdout (for example, "no available
# login"), so include both stdout and stderr for debugging.
parts = [
f"tea command failed (rc={result.returncode}): {' '.join(args)}",
f"stdout: {result.stdout.strip()}" if result.stdout.strip() else "",
f"stderr: {result.stderr.strip()}" if result.stderr.strip() else "",
]
raise TeaCLIError("\n".join(p for p in parts if p))
return result.stdout.strip()
return retry_decorator(_execute)()
except _TransientTeaError as e:
raise TeaCLIError(str(e)) from e
def _run_raw(self, args: list[str]) -> str:
"""Run a tea command without JSON output and return stdout."""