From 2b3413670f074f9830e216ac9d6ac2f82a99697f Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Thu, 23 Apr 2026 09:51:28 -0400 Subject: [PATCH] feat: apply retry-with-backoff to all outbound API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RetryableHTTPError and raise_for_retryable_status() to retry.py, then wire up @retry_with_backoff across all network-touching functions: - All 5 public Adobe Sign API functions in adobe_api.py - upload_template() and find_existing_template() in upload_docusign_template.py raise_for_retryable_status() distinguishes transient errors (429, 500, 502, 503, 504) from auth/client errors — only transient errors are retried. Auth refresh functions are intentionally left undecorated since a 401 there means bad credentials, not a transient failure. Backoff: 1s → 2s → 4s, max 16s, max 3 retries (131 tests passing). Co-Authored-By: Claude Sonnet 4.6 --- src/adobe_api.py | 21 ++++++++++++++++----- src/upload_docusign_template.py | 18 ++++++++++-------- src/utils/retry.py | 15 +++++++++++++++ 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/adobe_api.py b/src/adobe_api.py index 6f1d230..4029ab3 100644 --- a/src/adobe_api.py +++ b/src/adobe_api.py @@ -1,9 +1,15 @@ import os +import sys import requests from dotenv import load_dotenv, set_key load_dotenv() +sys.path.insert(0, os.path.dirname(__file__)) +from utils.retry import RetryableHTTPError, raise_for_retryable_status, retry_with_backoff + +_RETRY = dict(max_retries=3, base_delay=1.0, max_delay=16.0, retryable_exceptions=(RetryableHTTPError,)) + SHARD = "eu2" TOKEN_URL = f"https://api.{SHARD}.adobesign.com/oauth/v2/token" # initial auth code exchange REFRESH_URL = f"https://api.{SHARD}.adobesign.com/oauth/v2/refresh" # token refresh (non-standard separate endpoint) @@ -36,6 +42,7 @@ def _refresh_access_token(): return new_token +@retry_with_backoff(**_RETRY) def adobe_api_post_multipart(endpoint, files, data=None): """Upload a file via multipart/form-data (e.g. transient documents).""" token = os.getenv("ADOBE_ACCESS_TOKEN") @@ -47,10 +54,11 @@ def adobe_api_post_multipart(endpoint, files, data=None): token = _refresh_access_token() headers["Authorization"] = f"Bearer {token}" resp = requests.post(url, headers=headers, files=files, data=data or {}) - resp.raise_for_status() + raise_for_retryable_status(resp) return resp.json() +@retry_with_backoff(**_RETRY) def adobe_api_post_json(endpoint, body): """POST JSON body to an Adobe Sign endpoint.""" token = os.getenv("ADOBE_ACCESS_TOKEN") @@ -66,10 +74,11 @@ def adobe_api_post_json(endpoint, body): token = _refresh_access_token() headers["Authorization"] = f"Bearer {token}" resp = requests.post(url, headers=headers, json=body) - resp.raise_for_status() + raise_for_retryable_status(resp) return resp.json() +@retry_with_backoff(**_RETRY) def adobe_api_put_json(endpoint, body): """PUT JSON body to an Adobe Sign endpoint.""" token = os.getenv("ADOBE_ACCESS_TOKEN") @@ -85,10 +94,11 @@ def adobe_api_put_json(endpoint, body): token = _refresh_access_token() headers["Authorization"] = f"Bearer {token}" resp = requests.put(url, headers=headers, json=body) - resp.raise_for_status() + raise_for_retryable_status(resp) return resp.json() +@retry_with_backoff(**_RETRY) def adobe_api_get_bytes(endpoint): """Download binary content (e.g. PDF files) from the Adobe Sign API.""" token = os.getenv("ADOBE_ACCESS_TOKEN") @@ -103,10 +113,11 @@ def adobe_api_get_bytes(endpoint): headers["Authorization"] = f"Bearer {token}" resp = requests.get(url, headers=headers) - resp.raise_for_status() + raise_for_retryable_status(resp) return resp.content +@retry_with_backoff(**_RETRY) def adobe_api_get(endpoint, params=None): token = os.getenv("ADOBE_ACCESS_TOKEN") base_url = os.getenv("ADOBE_SIGN_BASE_URL", f"https://api.{SHARD}.adobesign.com/api/rest/v6") @@ -125,7 +136,7 @@ def adobe_api_get(endpoint, params=None): headers["Authorization"] = f"Bearer {token}" resp = requests.get(url, headers=headers, params=params) - resp.raise_for_status() + raise_for_retryable_status(resp) return resp.json() diff --git a/src/upload_docusign_template.py b/src/upload_docusign_template.py index 5fe0706..ec025d1 100644 --- a/src/upload_docusign_template.py +++ b/src/upload_docusign_template.py @@ -34,6 +34,9 @@ load_dotenv() sys.path.insert(0, os.path.dirname(__file__)) from docusign_auth import get_access_token +from utils.retry import RetryableHTTPError, raise_for_retryable_status, retry_with_backoff + +_RETRY = dict(max_retries=3, base_delay=1.0, max_delay=16.0, retryable_exceptions=(RetryableHTTPError,)) def _make_headers(token: str) -> dict: @@ -68,6 +71,10 @@ def find_existing_template( headers.update(_refresh_token_once(headers)) resp = requests.get(url, headers=headers, params={"search_text": name, "count": 100}) + # Raise on 429/5xx so the enclosing upload_template retry decorator can handle it. + # For other non-2xx errors, treat as "no match found" rather than a fatal error. + if resp.status_code in {429, 500, 502, 503, 504}: + raise_for_retryable_status(resp) if not resp.ok: return None @@ -84,6 +91,7 @@ def find_existing_template( return exact[0]["templateId"] +@retry_with_backoff(**_RETRY) def upload_template(file_path: str, force_create: bool = False) -> str: """ Upsert a template JSON file to DocuSign. @@ -123,10 +131,7 @@ def upload_template(file_path: str, force_create: bool = False) -> str: headers = _refresh_token_once(headers) resp = requests.put(url, headers=headers, json=template) - if not resp.ok: - print(f"ERROR: Update failed ({resp.status_code})") - print(resp.text) - sys.exit(1) + raise_for_retryable_status(resp) print(f"Template updated: {existing_id}") return existing_id @@ -139,10 +144,7 @@ def upload_template(file_path: str, force_create: bool = False) -> str: headers = _refresh_token_once(headers) resp = requests.post(url, headers=headers, json=template) - if not resp.ok: - print(f"ERROR: Upload failed ({resp.status_code})") - print(resp.text) - sys.exit(1) + raise_for_retryable_status(resp) result = resp.json() template_id = result.get("templateId") diff --git a/src/utils/retry.py b/src/utils/retry.py index b9e7350..eed03b5 100644 --- a/src/utils/retry.py +++ b/src/utils/retry.py @@ -21,6 +21,21 @@ T = TypeVar("T") _RETRYABLE_STATUS = {429, 500, 502, 503, 504} +class RetryableHTTPError(Exception): + """Raised for HTTP status codes that warrant a retry (429, 500, 502, 503, 504).""" + + +def raise_for_retryable_status(resp) -> None: + """ + Raise RetryableHTTPError for retryable status codes; call raise_for_status() for + all others. Use this instead of resp.raise_for_status() in functions decorated with + @retry_with_backoff(retryable_exceptions=(RetryableHTTPError,)). + """ + if resp.status_code in _RETRYABLE_STATUS: + raise RetryableHTTPError(f"HTTP {resp.status_code} from {resp.url} — will retry") + resp.raise_for_status() + + def retry_with_backoff( max_retries: int = 3, base_delay: float = 1.0,