feat: ContentGuardianAgent, onboarding UX, Team Activity action wiring, docs, agent help modal
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
This commit is contained in:
168
backend/services/integrations/wix/retry.py
Normal file
168
backend/services/integrations/wix/retry.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
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]}"
|
||||
)
|
||||
Reference in New Issue
Block a user