fix: complete remaining 8 optimization items
- HTTP connection pooling: UpstreamClient & WebsiteClient reuse httpx.Client - Deduplicate decimal_string into shared app/utils/number.py - Split scheduler transaction: snapshot write → webhook/website sync in separate sessions - Remove hardcoded 170.106.100.210 migration from database.py - Reset consecutive_failures on upstream update - Healthcheck: install curl, replace python -c with curl -f - Add .dockerignore to reduce build context - Frontend: add axios-retry with exponential backoff (5xx/network errors only)
This commit is contained in:
@@ -28,9 +28,16 @@ def get_scheduler() -> BackgroundScheduler:
|
||||
|
||||
|
||||
def _check_upstream(upstream_id: int) -> None:
|
||||
"""Full upstream check executed by scheduler (runs in thread)."""
|
||||
"""Full upstream check executed by scheduler (runs in thread).
|
||||
|
||||
Phase 1 — upstream API call + snapshot write (single transaction).
|
||||
Phase 2 — webhook/website sync (separate sessions, so a notification
|
||||
failure never rolls back the snapshot).
|
||||
"""
|
||||
settings = get_settings()
|
||||
# ── Phase 1: upstream check + DB write ──────────────────────────
|
||||
db: Session = SessionLocal()
|
||||
client = None
|
||||
try:
|
||||
upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first()
|
||||
if not upstream or not upstream.enabled:
|
||||
@@ -47,6 +54,8 @@ def _check_upstream(upstream_id: int) -> None:
|
||||
)
|
||||
|
||||
was_unhealthy = upstream.last_status == "unhealthy"
|
||||
snapshot = None
|
||||
changes = None
|
||||
|
||||
try:
|
||||
client.login()
|
||||
@@ -61,16 +70,18 @@ def _check_upstream(upstream_id: int) -> None:
|
||||
upstream.last_error = str(exc)
|
||||
upstream.last_checked_at = datetime.now(timezone.utc)
|
||||
threshold = settings.unhealthy_threshold
|
||||
if upstream.consecutive_failures >= threshold and upstream.last_status != "unhealthy":
|
||||
became_unhealthy = (
|
||||
upstream.consecutive_failures >= threshold
|
||||
and upstream.last_status != "unhealthy"
|
||||
)
|
||||
if became_unhealthy:
|
||||
upstream.last_status = "unhealthy"
|
||||
db.commit()
|
||||
webhook_service.send_status_event(
|
||||
db, upstream.id, upstream.name, upstream.base_url,
|
||||
"upstream_unhealthy", str(exc)
|
||||
)
|
||||
else:
|
||||
db.commit()
|
||||
db.commit()
|
||||
logger.warning("upstream %s check failed: %s", upstream.name, exc)
|
||||
# Phase 2: notify unhealthy in a fresh session
|
||||
if became_unhealthy:
|
||||
_notify_status(upstream.id, upstream.name, upstream.base_url,
|
||||
"upstream_unhealthy", str(exc))
|
||||
return
|
||||
|
||||
# success path
|
||||
@@ -90,29 +101,70 @@ def _check_upstream(upstream_id: int) -> None:
|
||||
captured_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_row)
|
||||
prune_snapshots(db, upstream_id, settings.snapshot_retention_count)
|
||||
|
||||
# update upstream status
|
||||
upstream.last_status = "healthy"
|
||||
upstream.last_checked_at = datetime.now(timezone.utc)
|
||||
upstream.last_error = None
|
||||
upstream.consecutive_failures = 0
|
||||
prune_snapshots(db, upstream_id, settings.snapshot_retention_count)
|
||||
db.commit()
|
||||
|
||||
if was_unhealthy:
|
||||
webhook_service.send_status_event(
|
||||
db, upstream.id, upstream.name, upstream.base_url, "upstream_recovered"
|
||||
)
|
||||
logger.info(
|
||||
"upstream %s: %d rate change(s)" if changes else "upstream %s: no changes",
|
||||
upstream.name, len(changes) if changes else 0,
|
||||
)
|
||||
|
||||
if changes:
|
||||
webhook_service.send_rate_changed(
|
||||
db, upstream.id, upstream.name, upstream.base_url, changes
|
||||
)
|
||||
website_sync.sync_affected_bindings(db, upstream.id, changes)
|
||||
logger.info("upstream %s: %d rate change(s)", upstream.name, len(changes))
|
||||
else:
|
||||
logger.debug("upstream %s: no changes", upstream.name)
|
||||
finally:
|
||||
client.close()
|
||||
db.close()
|
||||
|
||||
# ── Phase 2: notifications (independent sessions) ──────────────
|
||||
if was_unhealthy:
|
||||
_notify_status(upstream_id, upstream.name, upstream.base_url, "upstream_recovered")
|
||||
|
||||
if changes:
|
||||
_notify_rate_changed(upstream_id, upstream.name, upstream.base_url, changes)
|
||||
_sync_website_bindings(upstream_id, changes)
|
||||
|
||||
|
||||
def _notify_status(
|
||||
upstream_id: int,
|
||||
upstream_name: str,
|
||||
base_url: str,
|
||||
event: str,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
webhook_service.send_status_event(db, upstream_id, upstream_name, base_url, event, error)
|
||||
except Exception:
|
||||
logger.exception("status webhook failed for upstream %s", upstream_name)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _notify_rate_changed(
|
||||
upstream_id: int,
|
||||
upstream_name: str,
|
||||
base_url: str,
|
||||
changes: list[dict[str, Any]],
|
||||
) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
webhook_service.send_rate_changed(db, upstream_id, upstream_name, base_url, changes)
|
||||
except Exception:
|
||||
logger.exception("rate webhook failed for upstream %s", upstream_name)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _sync_website_bindings(upstream_id: int, changes: list[dict[str, Any]]) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
website_sync.sync_affected_bindings(db, upstream_id, changes)
|
||||
except Exception:
|
||||
logger.exception("website sync failed for upstream %s", upstream_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
import httpx
|
||||
|
||||
from app.utils.number import decimal_string
|
||||
|
||||
|
||||
class UpstreamError(RuntimeError):
|
||||
pass
|
||||
@@ -66,19 +67,6 @@ def _unwrap_list(value: Any) -> Optional[list[dict[str, Any]]]:
|
||||
return None
|
||||
|
||||
|
||||
def _decimal_str(value: Any) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
d = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return str(value)
|
||||
n = d.normalize()
|
||||
if n == n.to_integral():
|
||||
return str(n.quantize(Decimal("1")))
|
||||
return format(n, "f")
|
||||
|
||||
|
||||
def _group_id(group: dict[str, Any]) -> str:
|
||||
for key in ("id", "group_id", "groupId"):
|
||||
v = group.get(key)
|
||||
@@ -117,7 +105,7 @@ def _extract_rates_map(raw: Any) -> dict[str, str]:
|
||||
if isinstance(parsed, dict):
|
||||
result: dict[str, str] = {}
|
||||
for k, v in parsed.items():
|
||||
r = _decimal_str(v)
|
||||
r = decimal_string(v)
|
||||
if r:
|
||||
result[str(k)] = r
|
||||
return result
|
||||
@@ -127,7 +115,7 @@ def _extract_rates_map(raw: Any) -> dict[str, str]:
|
||||
# In case it's returned as dict directly
|
||||
result = {}
|
||||
for k, v in val.items():
|
||||
r = _decimal_str(v)
|
||||
r = decimal_string(v)
|
||||
if r:
|
||||
result[str(k)] = r
|
||||
return result
|
||||
@@ -153,13 +141,13 @@ def _extract_rates_map(raw: Any) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for k, v in candidates.items():
|
||||
if isinstance(v, dict):
|
||||
r = _decimal_str(
|
||||
r = decimal_string(
|
||||
v.get("rate_multiplier") or v.get("rateMultiplier")
|
||||
or v.get("user_rate_multiplier") or v.get("userRateMultiplier")
|
||||
or v.get("ratio")
|
||||
)
|
||||
else:
|
||||
r = _decimal_str(v)
|
||||
r = decimal_string(v)
|
||||
if r:
|
||||
result[str(k)] = r
|
||||
return result
|
||||
@@ -221,6 +209,10 @@ class UpstreamClient:
|
||||
self._token: str = ""
|
||||
self._cookies: dict[str, str] = {}
|
||||
self._new_api_user: str = ""
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
prefix = f"/{self.api_prefix}" if self.api_prefix else ""
|
||||
@@ -250,22 +242,21 @@ class UpstreamClient:
|
||||
|
||||
def _request(self, method: str, path: str, body: Any = None, auth: bool = True) -> Any:
|
||||
url = self._url(path)
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
if body is not None:
|
||||
resp = client.request(
|
||||
method,
|
||||
url,
|
||||
json=body,
|
||||
headers=self._headers(auth),
|
||||
cookies=self._cookies,
|
||||
)
|
||||
else:
|
||||
resp = client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self._headers(auth),
|
||||
cookies=self._cookies,
|
||||
)
|
||||
if body is not None:
|
||||
resp = self._client.request(
|
||||
method,
|
||||
url,
|
||||
json=body,
|
||||
headers=self._headers(auth),
|
||||
cookies=self._cookies,
|
||||
)
|
||||
else:
|
||||
resp = self._client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self._headers(auth),
|
||||
cookies=self._cookies,
|
||||
)
|
||||
self._cookies.update(dict(resp.cookies))
|
||||
resp.raise_for_status()
|
||||
ct = resp.headers.get("content-type", "")
|
||||
|
||||
@@ -6,24 +6,13 @@ from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.utils.number import decimal_string
|
||||
|
||||
|
||||
class WebsiteError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def decimal_string(value: Any) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
d = Decimal(str(value))
|
||||
except (InvalidOperation, ValueError):
|
||||
return str(value)
|
||||
n = d.normalize()
|
||||
if n == n.to_integral():
|
||||
return str(n.quantize(Decimal("1")))
|
||||
return format(n, "f")
|
||||
|
||||
|
||||
def parse_positive_decimal(value: Any) -> Decimal | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
@@ -111,6 +100,10 @@ class Sub2ApiWebsiteClient:
|
||||
self.auth_type = auth_type
|
||||
self.auth_config = auth_config
|
||||
self.timeout = timeout
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
prefix = f"/{self.api_prefix}" if self.api_prefix else ""
|
||||
@@ -130,8 +123,7 @@ class Sub2ApiWebsiteClient:
|
||||
return headers
|
||||
|
||||
def _request(self, method: str, path: str, body: Any = None) -> Any:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
resp = client.request(method, self._url(path), json=body, headers=self._headers())
|
||||
resp = self._client.request(method, self._url(path), json=body, headers=self._headers())
|
||||
resp.raise_for_status()
|
||||
if not resp.content:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user