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:
parent
c5b7b9f5b8
commit
2b3413670f
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue