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,