ContentGuardianAgent consolidation:
- Merge 3 duplicate classes into single source in specialized/content_guardian.py
- Watchdog audit_committee() with heuristic scoring, coverage gaps, overlaps, alerts
- Remove misleading rejection_rate() helper; use acceptance_rate directly
- Integrate audit + alerts + trend signals into today_workflow_service.py
Team Activity page:
- QualityAuditPanel: health ring, per-agent critiques, coverage gaps, overlaps
- TrendSignalsPanel: opportunity cards with urgency/impact/coverage bars
- AlertBanner: persistent dismiss via POST /alerts/{id}/mark-read
- AgentHelpModal: dialog showing all 8 agents with descriptions, tools, schedule
- QualityAuditPanel action buttons: Fill gap -> /content-planning, Resolve overlap, View CTA on alerts/issues
- TrendSignalsPanel action buttons: Create content from this trend -> /blog-writer with trend context state
Onboarding system:
- Step 4 validation: no auto-pass via basic_ready; requires persona data or explicit progression
- Step 5 validation: logs warning on auto-pass without integration data
- OnboardingCompletionService: single DB session, transactional task creation, upsert pattern
- Business-without-website: nullable website_url on SIFIndexingTask and MarketTrendsTask
- DeepCompetitorAnalysisExecutor: 5-min timeout, 10-competitor cap, asyncio.wait_for
- Persona generation: async with 30s timeout, falls back to scheduler
- OnboardingProgressService.reset_onboarding(): resets session + pauses all DB tasks
- OnboardingControlService.reset_onboarding(): also cancels APScheduler jobs
- FinalStep TaskSchedulingPanel: shows scheduled/failed tasks after completion, 8s auto-redirect
- onboarding_completed agent activity event logged to feed
Documentation:
- docs-site/features/onboarding/: overview, steps, scheduler-tasks, technical-reference (4 pages)
- docs-site/mkdocs.yml: added Onboarding System nav section
- docs-site/features/sif-agents/: overview, agent-directory, committee-system, content-guardian (4 pages)
- docs-site/features/team-activity/: overview, quality-audit, trend-signals, alert-system (4 pages)
- docs-site/features/todays-workflow/: updated overview, technical-architecture, workflow-guide, api-reference
169 lines
5.8 KiB
Python
169 lines
5.8 KiB
Python
"""
|
|
Retry utilities for Wix API calls with exponential backoff.
|
|
|
|
Production-grade retry logic that respects Wix rate limits and handles
|
|
transient failures gracefully.
|
|
"""
|
|
|
|
import time
|
|
import random
|
|
from typing import Callable, TypeVar, Optional
|
|
from loguru import logger
|
|
|
|
T = TypeVar('T')
|
|
|
|
|
|
class WixAPIError(Exception):
|
|
"""Custom exception for Wix API errors with status code context."""
|
|
|
|
def __init__(self, message: str, status_code: Optional[int] = None, response_body: Optional[str] = None):
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.response_body = response_body
|
|
|
|
def is_retryable(self) -> bool:
|
|
"""Determine if this error is retryable based on status code."""
|
|
if self.status_code is None:
|
|
return True # Network errors are retryable
|
|
# 429 = rate limit, 502/503/504 = gateway errors, 500 = internal server error (sometimes transient)
|
|
return self.status_code in (429, 500, 502, 503, 504)
|
|
|
|
def is_rate_limit(self) -> bool:
|
|
"""Check if this is a rate limit error."""
|
|
return self.status_code == 429
|
|
|
|
|
|
def with_retry(
|
|
fn: Callable[[], T],
|
|
max_attempts: int = 3,
|
|
base_delay: float = 1.0,
|
|
max_delay: float = 30.0,
|
|
retryable_exceptions: tuple = (Exception,),
|
|
operation_name: str = "Wix API call"
|
|
) -> T:
|
|
"""
|
|
Execute a function with exponential backoff retry logic.
|
|
|
|
Args:
|
|
fn: Function to execute (should make the API call)
|
|
max_attempts: Maximum number of attempts (default: 3)
|
|
base_delay: Initial delay in seconds (default: 1.0)
|
|
max_delay: Maximum delay in seconds (default: 30.0)
|
|
retryable_exceptions: Tuple of exception types to retry on
|
|
operation_name: Name for logging
|
|
|
|
Returns:
|
|
Result of fn()
|
|
|
|
Raises:
|
|
WixAPIError: If all retries are exhausted
|
|
Exception: If a non-retryable exception occurs
|
|
"""
|
|
last_exception = None
|
|
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
return fn()
|
|
except WixAPIError as e:
|
|
last_exception = e
|
|
if attempt >= max_attempts:
|
|
break
|
|
if not e.is_retryable():
|
|
logger.warning(f"{operation_name}: non-retryable error (HTTP {e.status_code}), failing fast")
|
|
raise
|
|
|
|
# Calculate delay with exponential backoff and jitter
|
|
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
|
# Add jitter (±25%) to prevent thundering herd
|
|
jitter = delay * 0.25
|
|
actual_delay = delay + random.uniform(-jitter, jitter)
|
|
actual_delay = max(0.1, actual_delay) # Minimum 100ms delay
|
|
|
|
if e.is_rate_limit():
|
|
# For rate limits, use a longer base delay
|
|
actual_delay = max(actual_delay, 2.0)
|
|
logger.warning(f"{operation_name}: rate limited (429), waiting {actual_delay:.1f}s before retry {attempt + 1}/{max_attempts}")
|
|
else:
|
|
logger.warning(f"{operation_name}: attempt {attempt}/{max_attempts} failed (HTTP {e.status_code}), waiting {actual_delay:.1f}s before retry")
|
|
|
|
time.sleep(actual_delay)
|
|
|
|
except retryable_exceptions as e:
|
|
last_exception = e
|
|
if attempt >= max_attempts:
|
|
break
|
|
|
|
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
|
jitter = delay * 0.25
|
|
actual_delay = delay + random.uniform(-jitter, jitter)
|
|
actual_delay = max(0.1, actual_delay)
|
|
|
|
logger.warning(f"{operation_name}: attempt {attempt}/{max_attempts} failed ({type(e).__name__}), waiting {actual_delay:.1f}s before retry")
|
|
time.sleep(actual_delay)
|
|
|
|
# All retries exhausted
|
|
if last_exception:
|
|
if isinstance(last_exception, WixAPIError):
|
|
raise last_exception
|
|
raise WixAPIError(f"{operation_name}: failed after {max_attempts} attempts: {last_exception}")
|
|
|
|
raise WixAPIError(f"{operation_name}: failed after {max_attempts} attempts")
|
|
|
|
|
|
def wix_api_call_with_retry(
|
|
method: str,
|
|
url: str,
|
|
headers: dict,
|
|
json_payload: Optional[dict] = None,
|
|
max_attempts: int = 3
|
|
) -> dict:
|
|
"""
|
|
Convenience wrapper for making Wix API calls with retry logic.
|
|
|
|
Args:
|
|
method: HTTP method ('GET', 'POST', etc.)
|
|
url: Full API URL
|
|
headers: Request headers
|
|
json_payload: Optional JSON payload for POST/PUT
|
|
max_attempts: Maximum retry attempts
|
|
|
|
Returns:
|
|
Parsed JSON response
|
|
|
|
Raises:
|
|
WixAPIError: On failure after retries
|
|
"""
|
|
import requests
|
|
|
|
def _call():
|
|
if method.upper() == 'GET':
|
|
resp = requests.get(url, headers=headers, timeout=30)
|
|
elif method.upper() == 'POST':
|
|
resp = requests.post(url, headers=headers, json=json_payload, timeout=30)
|
|
elif method.upper() == 'PUT':
|
|
resp = requests.put(url, headers=headers, json=json_payload, timeout=30)
|
|
elif method.upper() == 'DELETE':
|
|
resp = requests.delete(url, headers=headers, timeout=30)
|
|
else:
|
|
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
|
|
if resp.status_code >= 400:
|
|
body = None
|
|
try:
|
|
body = resp.text[:500]
|
|
except:
|
|
body = str(resp.content)[:500]
|
|
raise WixAPIError(
|
|
f"Wix API {method} {url} failed: HTTP {resp.status_code}",
|
|
status_code=resp.status_code,
|
|
response_body=body
|
|
)
|
|
|
|
return resp.json()
|
|
|
|
return with_retry(
|
|
_call,
|
|
max_attempts=max_attempts,
|
|
operation_name=f"Wix {method} {url.split('/')[-1]}"
|
|
)
|