feat: apply retry-with-backoff to all outbound API calls

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 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-23 09:51:28 -04:00
parent c5b7b9f5b8
commit 2b3413670f
3 changed files with 41 additions and 13 deletions

View File

@ -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()

View File

@ -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")

View File

@ -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,