Spaces:
Running
Running
Commit ·
722c296
1
Parent(s): 8f9855d
feat: optimization
Browse files- app/api/server.py +2 -0
- app/services/chat_service.py +9 -17
- app/services/csv_analysis_service.py +1 -10
- app/services/dataset_metadata_service.py +15 -37
- app/services/gcs_service.py +4 -19
- app/services/google_maps_service.py +4 -19
- app/services/google_oauth_service.py +7 -18
- app/services/scheduler_service.py +55 -78
- app/services/url_shortener_service.py +75 -55
- app/services/web_search_service.py +13 -12
- app/utils/http_utils.py +129 -8
app/api/server.py
CHANGED
|
@@ -123,6 +123,8 @@ async def lifespan(app: FastAPI):
|
|
| 123 |
await close_gcs_service()
|
| 124 |
from app.services.media_storage_service import close_storage_service
|
| 125 |
await close_storage_service()
|
|
|
|
|
|
|
| 126 |
from app.services.supabase import get_supabase_client
|
| 127 |
client = get_supabase_client()
|
| 128 |
if client:
|
|
|
|
| 123 |
await close_gcs_service()
|
| 124 |
from app.services.media_storage_service import close_storage_service
|
| 125 |
await close_storage_service()
|
| 126 |
+
from app.utils.http_utils import close_shared_aiohttp_sessions
|
| 127 |
+
await close_shared_aiohttp_sessions()
|
| 128 |
from app.services.supabase import get_supabase_client
|
| 129 |
client = get_supabase_client()
|
| 130 |
if client:
|
app/services/chat_service.py
CHANGED
|
@@ -22,7 +22,7 @@ from app.config import (
|
|
| 22 |
OPENROUTER_MIMIKA_MODEL,
|
| 23 |
get_settings,
|
| 24 |
)
|
| 25 |
-
from app.utils.http_utils import
|
| 26 |
from app.utils.json_utils import extract_single_json
|
| 27 |
from app.utils.schema_utils import generate_schema_prompt, validate_against_schema
|
| 28 |
|
|
@@ -78,8 +78,7 @@ async def _call_llm_api(
|
|
| 78 |
"stream": stream,
|
| 79 |
}
|
| 80 |
|
| 81 |
-
|
| 82 |
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 83 |
async with session.post(
|
| 84 |
f"{base_url}{path}",
|
| 85 |
json=payload,
|
|
@@ -392,8 +391,7 @@ async def call_meganova(
|
|
| 392 |
}
|
| 393 |
|
| 394 |
try:
|
| 395 |
-
|
| 396 |
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 397 |
async with session.post(
|
| 398 |
f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
|
| 399 |
json=payload,
|
|
@@ -474,8 +472,7 @@ async def call_aion_labs(
|
|
| 474 |
logger.info("[aion] Attempt %s/%s", attempt + 1, total_tries)
|
| 475 |
|
| 476 |
try:
|
| 477 |
-
|
| 478 |
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 479 |
async with session.post(
|
| 480 |
f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
|
| 481 |
json=payload,
|
|
@@ -728,8 +725,7 @@ async def _stream_meganova(
|
|
| 728 |
}
|
| 729 |
|
| 730 |
try:
|
| 731 |
-
|
| 732 |
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 733 |
async with session.post(
|
| 734 |
f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
|
| 735 |
json=payload,
|
|
@@ -777,8 +773,7 @@ async def _stream_meganova_no_redis(
|
|
| 777 |
}
|
| 778 |
|
| 779 |
try:
|
| 780 |
-
|
| 781 |
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 782 |
async with session.post(
|
| 783 |
f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
|
| 784 |
json=payload,
|
|
@@ -830,8 +825,7 @@ async def _stream_aion_labs(
|
|
| 830 |
}
|
| 831 |
|
| 832 |
try:
|
| 833 |
-
|
| 834 |
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 835 |
async with session.post(
|
| 836 |
f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
|
| 837 |
json=payload,
|
|
@@ -876,8 +870,7 @@ async def _stream_aion_labs_no_redis(
|
|
| 876 |
}
|
| 877 |
|
| 878 |
try:
|
| 879 |
-
|
| 880 |
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 881 |
async with session.post(
|
| 882 |
f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
|
| 883 |
json=payload,
|
|
@@ -915,8 +908,7 @@ async def _stream_openrouter_mimika(
|
|
| 915 |
}
|
| 916 |
|
| 917 |
try:
|
| 918 |
-
|
| 919 |
-
async with aiohttp.ClientSession(timeout=timeout) as session:
|
| 920 |
async with session.post(
|
| 921 |
f"{OPENROUTER_MIMIKA_BASE_URL}{OPENROUTER_MIMIKA_CHAT_PATH}",
|
| 922 |
json=payload,
|
|
|
|
| 22 |
OPENROUTER_MIMIKA_MODEL,
|
| 23 |
get_settings,
|
| 24 |
)
|
| 25 |
+
from app.utils.http_utils import shared_aiohttp_session
|
| 26 |
from app.utils.json_utils import extract_single_json
|
| 27 |
from app.utils.schema_utils import generate_schema_prompt, validate_against_schema
|
| 28 |
|
|
|
|
| 78 |
"stream": stream,
|
| 79 |
}
|
| 80 |
|
| 81 |
+
async with shared_aiohttp_session() as session:
|
|
|
|
| 82 |
async with session.post(
|
| 83 |
f"{base_url}{path}",
|
| 84 |
json=payload,
|
|
|
|
| 391 |
}
|
| 392 |
|
| 393 |
try:
|
| 394 |
+
async with shared_aiohttp_session() as session:
|
|
|
|
| 395 |
async with session.post(
|
| 396 |
f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
|
| 397 |
json=payload,
|
|
|
|
| 472 |
logger.info("[aion] Attempt %s/%s", attempt + 1, total_tries)
|
| 473 |
|
| 474 |
try:
|
| 475 |
+
async with shared_aiohttp_session() as session:
|
|
|
|
| 476 |
async with session.post(
|
| 477 |
f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
|
| 478 |
json=payload,
|
|
|
|
| 725 |
}
|
| 726 |
|
| 727 |
try:
|
| 728 |
+
async with shared_aiohttp_session() as session:
|
|
|
|
| 729 |
async with session.post(
|
| 730 |
f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
|
| 731 |
json=payload,
|
|
|
|
| 773 |
}
|
| 774 |
|
| 775 |
try:
|
| 776 |
+
async with shared_aiohttp_session() as session:
|
|
|
|
| 777 |
async with session.post(
|
| 778 |
f"{MEGANOVA_BASE_URL}{MEGANOVA_CHAT_PATH}",
|
| 779 |
json=payload,
|
|
|
|
| 825 |
}
|
| 826 |
|
| 827 |
try:
|
| 828 |
+
async with shared_aiohttp_session() as session:
|
|
|
|
| 829 |
async with session.post(
|
| 830 |
f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
|
| 831 |
json=payload,
|
|
|
|
| 870 |
}
|
| 871 |
|
| 872 |
try:
|
| 873 |
+
async with shared_aiohttp_session() as session:
|
|
|
|
| 874 |
async with session.post(
|
| 875 |
f"{AION_LABS_BASE_URL}{AION_LABS_CHAT_PATH}",
|
| 876 |
json=payload,
|
|
|
|
| 908 |
}
|
| 909 |
|
| 910 |
try:
|
| 911 |
+
async with shared_aiohttp_session() as session:
|
|
|
|
| 912 |
async with session.post(
|
| 913 |
f"{OPENROUTER_MIMIKA_BASE_URL}{OPENROUTER_MIMIKA_CHAT_PATH}",
|
| 914 |
json=payload,
|
app/services/csv_analysis_service.py
CHANGED
|
@@ -9,7 +9,6 @@ import tempfile
|
|
| 9 |
import time
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 12 |
-
from urllib.parse import unquote, urlparse
|
| 13 |
|
| 14 |
from app.services.code_executor_service import CodeSanitizer
|
| 15 |
from app.services.dataset_metadata_service import extract_metadata
|
|
@@ -30,17 +29,9 @@ class CSVAnalysisError(Exception):
|
|
| 30 |
pass
|
| 31 |
|
| 32 |
|
| 33 |
-
async def _download_file(url: str) -> bytes:
|
| 34 |
-
data, _ = await download_url(url, timeout_seconds=_DOWNLOAD_TIMEOUT)
|
| 35 |
-
return data
|
| 36 |
-
|
| 37 |
-
|
| 38 |
async def _resolve_source(source: Union[str, bytes]) -> Tuple[bytes, Optional[str]]:
|
| 39 |
if isinstance(source, str) and source.lower().startswith(("http://", "https://")):
|
| 40 |
-
|
| 41 |
-
parsed = urlparse(source)
|
| 42 |
-
filename = unquote(Path(parsed.path).name) if parsed.path else None
|
| 43 |
-
return data, filename
|
| 44 |
elif isinstance(source, bytes):
|
| 45 |
return source, None
|
| 46 |
else:
|
|
|
|
| 9 |
import time
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
|
|
| 12 |
|
| 13 |
from app.services.code_executor_service import CodeSanitizer
|
| 14 |
from app.services.dataset_metadata_service import extract_metadata
|
|
|
|
| 29 |
pass
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
async def _resolve_source(source: Union[str, bytes]) -> Tuple[bytes, Optional[str]]:
|
| 33 |
if isinstance(source, str) and source.lower().startswith(("http://", "https://")):
|
| 34 |
+
return await download_url(source, timeout_seconds=_DOWNLOAD_TIMEOUT)
|
|
|
|
|
|
|
|
|
|
| 35 |
elif isinstance(source, bytes):
|
| 36 |
return source, None
|
| 37 |
else:
|
app/services/dataset_metadata_service.py
CHANGED
|
@@ -24,9 +24,6 @@ from typing import (
|
|
| 24 |
Tuple,
|
| 25 |
Union,
|
| 26 |
)
|
| 27 |
-
from urllib.parse import unquote, urlparse
|
| 28 |
-
|
| 29 |
-
import aiohttp
|
| 30 |
import chardet
|
| 31 |
import numpy as np
|
| 32 |
import openpyxl # noqa: F401 – needed as engine for .xlsx
|
|
@@ -34,6 +31,7 @@ import pandas as pd
|
|
| 34 |
import xlrd # noqa: F401 – needed as engine for .xls
|
| 35 |
|
| 36 |
from app.core.thread_pool import thread_pool
|
|
|
|
| 37 |
|
| 38 |
logger = logging.getLogger(__name__)
|
| 39 |
logger.setLevel(logging.DEBUG)
|
|
@@ -45,7 +43,6 @@ logger.setLevel(logging.DEBUG)
|
|
| 45 |
_SAMPLE_BYTES_FOR_DETECTION: int = 65_536 # 64 KiB for encoding/delimiter sniffing
|
| 46 |
_DEFAULT_SAMPLE_ROWS: int = 5
|
| 47 |
_MAX_FILE_SIZE: int = 2 * 1024 * 1024 * 1024 # 2 GiB
|
| 48 |
-
_DOWNLOAD_CHUNK_SIZE: int = 256 * 1024 # 256 KiB streaming chunks
|
| 49 |
_DEFAULT_TIMEOUT_SECONDS: int = 120
|
| 50 |
|
| 51 |
# Magic bytes for binary file-type detection
|
|
@@ -392,40 +389,21 @@ async def _download_from_url(
|
|
| 392 |
url: str,
|
| 393 |
config: ExtractionConfig,
|
| 394 |
) -> Tuple[bytes, Optional[str]]:
|
| 395 |
-
"""Stream-download a remote file with size guard and timeout.
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
| 397 |
try:
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
raise FileTooLargeError(
|
| 408 |
-
f"Remote file advertises {content_length} bytes, "
|
| 409 |
-
f"limit is {config.max_file_size_bytes}"
|
| 410 |
-
)
|
| 411 |
-
|
| 412 |
-
chunks: List[bytes] = []
|
| 413 |
-
total = 0
|
| 414 |
-
async for chunk in resp.content.iter_chunked(_DOWNLOAD_CHUNK_SIZE):
|
| 415 |
-
total += len(chunk)
|
| 416 |
-
if total > config.max_file_size_bytes:
|
| 417 |
-
raise FileTooLargeError(
|
| 418 |
-
f"Download exceeded {config.max_file_size_bytes} bytes"
|
| 419 |
-
)
|
| 420 |
-
chunks.append(chunk)
|
| 421 |
-
|
| 422 |
-
data = b"".join(chunks)
|
| 423 |
-
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
| 424 |
-
raise FileDownloadError(f"Download failed for {url}: {exc}") from exc
|
| 425 |
-
|
| 426 |
-
parsed = urlparse(url)
|
| 427 |
-
filename = unquote(Path(parsed.path).name) if parsed.path else None
|
| 428 |
-
return data, filename
|
| 429 |
|
| 430 |
|
| 431 |
def _is_url(source: Any) -> bool:
|
|
|
|
| 24 |
Tuple,
|
| 25 |
Union,
|
| 26 |
)
|
|
|
|
|
|
|
|
|
|
| 27 |
import chardet
|
| 28 |
import numpy as np
|
| 29 |
import openpyxl # noqa: F401 – needed as engine for .xlsx
|
|
|
|
| 31 |
import xlrd # noqa: F401 – needed as engine for .xls
|
| 32 |
|
| 33 |
from app.core.thread_pool import thread_pool
|
| 34 |
+
from app.utils.http_utils import DownloadError, download_url
|
| 35 |
|
| 36 |
logger = logging.getLogger(__name__)
|
| 37 |
logger.setLevel(logging.DEBUG)
|
|
|
|
| 43 |
_SAMPLE_BYTES_FOR_DETECTION: int = 65_536 # 64 KiB for encoding/delimiter sniffing
|
| 44 |
_DEFAULT_SAMPLE_ROWS: int = 5
|
| 45 |
_MAX_FILE_SIZE: int = 2 * 1024 * 1024 * 1024 # 2 GiB
|
|
|
|
| 46 |
_DEFAULT_TIMEOUT_SECONDS: int = 120
|
| 47 |
|
| 48 |
# Magic bytes for binary file-type detection
|
|
|
|
| 389 |
url: str,
|
| 390 |
config: ExtractionConfig,
|
| 391 |
) -> Tuple[bytes, Optional[str]]:
|
| 392 |
+
"""Stream-download a remote file with size guard and timeout.
|
| 393 |
+
|
| 394 |
+
Delegates to the shared :func:`app.utils.http_utils.download_url` helper
|
| 395 |
+
and maps its errors onto the dataset-specific exceptions.
|
| 396 |
+
"""
|
| 397 |
try:
|
| 398 |
+
return await download_url(
|
| 399 |
+
url,
|
| 400 |
+
timeout_seconds=config.timeout_seconds,
|
| 401 |
+
max_size_bytes=config.max_file_size_bytes,
|
| 402 |
+
)
|
| 403 |
+
except DownloadError as exc:
|
| 404 |
+
if exc.is_size_error:
|
| 405 |
+
raise FileTooLargeError(str(exc)) from exc
|
| 406 |
+
raise FileDownloadError(str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
|
| 408 |
|
| 409 |
def _is_url(source: Any) -> bool:
|
app/services/gcs_service.py
CHANGED
|
@@ -15,6 +15,7 @@ from pydantic import BaseModel, ValidationError
|
|
| 15 |
|
| 16 |
from app.config import get_settings
|
| 17 |
from app.core.logger import get_logger
|
|
|
|
| 18 |
|
| 19 |
_logger = get_logger(__name__)
|
| 20 |
_settings = get_settings()
|
|
@@ -101,8 +102,7 @@ class GCSService:
|
|
| 101 |
"""Asynchronous, connection-pooled client for the Google Cloud Storage JSON API."""
|
| 102 |
|
| 103 |
def __init__(self) -> None:
|
| 104 |
-
self.
|
| 105 |
-
self._client_lock = asyncio.Lock()
|
| 106 |
self._token_cache = _TokenCache()
|
| 107 |
|
| 108 |
# ------------------------------------------------------------------
|
|
@@ -110,25 +110,10 @@ class GCSService:
|
|
| 110 |
# ------------------------------------------------------------------
|
| 111 |
|
| 112 |
async def _get_client(self) -> httpx.AsyncClient:
|
| 113 |
-
|
| 114 |
-
async with self._client_lock:
|
| 115 |
-
if self._client is None or self._client.is_closed:
|
| 116 |
-
self._client = httpx.AsyncClient(
|
| 117 |
-
timeout=_settings.gcs_timeout,
|
| 118 |
-
follow_redirects=True,
|
| 119 |
-
limits=httpx.Limits(
|
| 120 |
-
max_connections=100,
|
| 121 |
-
max_keepalive_connections=20,
|
| 122 |
-
keepalive_expiry=30,
|
| 123 |
-
),
|
| 124 |
-
)
|
| 125 |
-
return self._client
|
| 126 |
|
| 127 |
async def close(self) -> None:
|
| 128 |
-
|
| 129 |
-
if self._client is not None and not self._client.is_closed:
|
| 130 |
-
await self._client.aclose()
|
| 131 |
-
self._client = None
|
| 132 |
|
| 133 |
# ------------------------------------------------------------------
|
| 134 |
# Credential resolution (JSON body / JSON string / URL / file / env)
|
|
|
|
| 15 |
|
| 16 |
from app.config import get_settings
|
| 17 |
from app.core.logger import get_logger
|
| 18 |
+
from app.utils.http_utils import SharedAsyncClient
|
| 19 |
|
| 20 |
_logger = get_logger(__name__)
|
| 21 |
_settings = get_settings()
|
|
|
|
| 102 |
"""Asynchronous, connection-pooled client for the Google Cloud Storage JSON API."""
|
| 103 |
|
| 104 |
def __init__(self) -> None:
|
| 105 |
+
self._http = SharedAsyncClient(timeout=_settings.gcs_timeout)
|
|
|
|
| 106 |
self._token_cache = _TokenCache()
|
| 107 |
|
| 108 |
# ------------------------------------------------------------------
|
|
|
|
| 110 |
# ------------------------------------------------------------------
|
| 111 |
|
| 112 |
async def _get_client(self) -> httpx.AsyncClient:
|
| 113 |
+
return await self._http.get()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
async def close(self) -> None:
|
| 116 |
+
await self._http.close()
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
# ------------------------------------------------------------------
|
| 119 |
# Credential resolution (JSON body / JSON string / URL / file / env)
|
app/services/google_maps_service.py
CHANGED
|
@@ -12,6 +12,7 @@ from urllib.parse import unquote, urlencode
|
|
| 12 |
import httpx
|
| 13 |
|
| 14 |
from app.config import get_settings
|
|
|
|
| 15 |
|
| 16 |
_logger = logging.getLogger(__name__)
|
| 17 |
_settings = get_settings()
|
|
@@ -75,8 +76,7 @@ class GoogleMapsService:
|
|
| 75 |
self._places_base_url: str = _settings.google_maps_places_base_url
|
| 76 |
self._timeout: int = _settings.google_maps_timeout
|
| 77 |
self._max_retries: int = _settings.google_maps_max_retries
|
| 78 |
-
self.
|
| 79 |
-
self._client_lock = asyncio.Lock()
|
| 80 |
|
| 81 |
# -----------------------------------------------------------------------
|
| 82 |
# HTTP client management
|
|
@@ -84,26 +84,11 @@ class GoogleMapsService:
|
|
| 84 |
|
| 85 |
async def _get_client(self) -> httpx.AsyncClient:
|
| 86 |
"""Return the shared connection-pooled AsyncClient, creating it lazily."""
|
| 87 |
-
|
| 88 |
-
async with self._client_lock:
|
| 89 |
-
if self._client is None or self._client.is_closed:
|
| 90 |
-
self._client = httpx.AsyncClient(
|
| 91 |
-
timeout=self._timeout,
|
| 92 |
-
follow_redirects=True,
|
| 93 |
-
limits=httpx.Limits(
|
| 94 |
-
max_connections=100,
|
| 95 |
-
max_keepalive_connections=20,
|
| 96 |
-
keepalive_expiry=30,
|
| 97 |
-
),
|
| 98 |
-
)
|
| 99 |
-
return self._client
|
| 100 |
|
| 101 |
async def close(self) -> None:
|
| 102 |
"""Close the shared AsyncClient and release pooled connections."""
|
| 103 |
-
|
| 104 |
-
if self._client is not None and not self._client.is_closed:
|
| 105 |
-
await self._client.aclose()
|
| 106 |
-
self._client = None
|
| 107 |
|
| 108 |
# -----------------------------------------------------------------------
|
| 109 |
# Geocoding — still uses the legacy Google Geocoding API
|
|
|
|
| 12 |
import httpx
|
| 13 |
|
| 14 |
from app.config import get_settings
|
| 15 |
+
from app.utils.http_utils import SharedAsyncClient
|
| 16 |
|
| 17 |
_logger = logging.getLogger(__name__)
|
| 18 |
_settings = get_settings()
|
|
|
|
| 76 |
self._places_base_url: str = _settings.google_maps_places_base_url
|
| 77 |
self._timeout: int = _settings.google_maps_timeout
|
| 78 |
self._max_retries: int = _settings.google_maps_max_retries
|
| 79 |
+
self._http = SharedAsyncClient(timeout=self._timeout)
|
|
|
|
| 80 |
|
| 81 |
# -----------------------------------------------------------------------
|
| 82 |
# HTTP client management
|
|
|
|
| 84 |
|
| 85 |
async def _get_client(self) -> httpx.AsyncClient:
|
| 86 |
"""Return the shared connection-pooled AsyncClient, creating it lazily."""
|
| 87 |
+
return await self._http.get()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
async def close(self) -> None:
|
| 90 |
"""Close the shared AsyncClient and release pooled connections."""
|
| 91 |
+
await self._http.close()
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
# -----------------------------------------------------------------------
|
| 94 |
# Geocoding — still uses the legacy Google Geocoding API
|
app/services/google_oauth_service.py
CHANGED
|
@@ -11,6 +11,7 @@ import jwt
|
|
| 11 |
from app.config import get_settings
|
| 12 |
from app.core.logger import get_logger
|
| 13 |
from app.models.schemas import GoogleOAuthUserInfo
|
|
|
|
| 14 |
|
| 15 |
_logger = get_logger(__name__)
|
| 16 |
_settings = get_settings()
|
|
@@ -92,8 +93,10 @@ class GoogleOAuthService:
|
|
| 92 |
"""Abstraction over the Google OAuth 2.0 / OpenID Connect flow."""
|
| 93 |
|
| 94 |
def __init__(self) -> None:
|
| 95 |
-
self.
|
| 96 |
-
|
|
|
|
|
|
|
| 97 |
self._jwks = GoogleJWKSCache()
|
| 98 |
|
| 99 |
# ------------------------------------------------------------------
|
|
@@ -101,24 +104,10 @@ class GoogleOAuthService:
|
|
| 101 |
# ------------------------------------------------------------------
|
| 102 |
|
| 103 |
async def _get_client(self) -> httpx.AsyncClient:
|
| 104 |
-
|
| 105 |
-
async with self._client_lock:
|
| 106 |
-
if self._client is None or self._client.is_closed:
|
| 107 |
-
self._client = httpx.AsyncClient(
|
| 108 |
-
timeout=httpx.Timeout(_settings.google_oauth_timeout),
|
| 109 |
-
limits=httpx.Limits(
|
| 110 |
-
max_connections=100,
|
| 111 |
-
max_keepalive_connections=20,
|
| 112 |
-
keepalive_expiry=30,
|
| 113 |
-
),
|
| 114 |
-
)
|
| 115 |
-
return self._client
|
| 116 |
|
| 117 |
async def close(self) -> None:
|
| 118 |
-
|
| 119 |
-
if self._client is not None and not self._client.is_closed:
|
| 120 |
-
await self._client.aclose()
|
| 121 |
-
self._client = None
|
| 122 |
|
| 123 |
# ------------------------------------------------------------------
|
| 124 |
# Authorization URL (Step 1)
|
|
|
|
| 11 |
from app.config import get_settings
|
| 12 |
from app.core.logger import get_logger
|
| 13 |
from app.models.schemas import GoogleOAuthUserInfo
|
| 14 |
+
from app.utils.http_utils import SharedAsyncClient
|
| 15 |
|
| 16 |
_logger = get_logger(__name__)
|
| 17 |
_settings = get_settings()
|
|
|
|
| 93 |
"""Abstraction over the Google OAuth 2.0 / OpenID Connect flow."""
|
| 94 |
|
| 95 |
def __init__(self) -> None:
|
| 96 |
+
self._http = SharedAsyncClient(
|
| 97 |
+
timeout=httpx.Timeout(_settings.google_oauth_timeout),
|
| 98 |
+
follow_redirects=False,
|
| 99 |
+
)
|
| 100 |
self._jwks = GoogleJWKSCache()
|
| 101 |
|
| 102 |
# ------------------------------------------------------------------
|
|
|
|
| 104 |
# ------------------------------------------------------------------
|
| 105 |
|
| 106 |
async def _get_client(self) -> httpx.AsyncClient:
|
| 107 |
+
return await self._http.get()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
async def close(self) -> None:
|
| 110 |
+
await self._http.close()
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
# ------------------------------------------------------------------
|
| 113 |
# Authorization URL (Step 1)
|
app/services/scheduler_service.py
CHANGED
|
@@ -23,6 +23,7 @@ from croniter import croniter
|
|
| 23 |
from app.config import get_settings
|
| 24 |
from app.core.logger import get_logger
|
| 25 |
from app.services.supabase import SupabaseClient, get_supabase_client
|
|
|
|
| 26 |
|
| 27 |
logger = get_logger(__name__)
|
| 28 |
settings = get_settings()
|
|
@@ -329,28 +330,21 @@ class HttpExecutionEngine:
|
|
| 329 |
_MAX_RESPONSE_PREVIEW = 500
|
| 330 |
|
| 331 |
def __init__(self) -> None:
|
| 332 |
-
self.
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
async def _get_client(self) -> httpx.AsyncClient:
|
| 336 |
-
|
| 337 |
-
async with self._lock:
|
| 338 |
-
if self._client is None or self._client.is_closed:
|
| 339 |
-
self._client = httpx.AsyncClient(
|
| 340 |
-
follow_redirects=True,
|
| 341 |
-
limits=httpx.Limits(
|
| 342 |
-
max_connections=200,
|
| 343 |
-
max_keepalive_connections=50,
|
| 344 |
-
keepalive_expiry=30,
|
| 345 |
-
),
|
| 346 |
-
timeout=httpx.Timeout(settings.max_http_timeout),
|
| 347 |
-
)
|
| 348 |
-
return self._client
|
| 349 |
|
| 350 |
async def close(self) -> None:
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
logger.info("HTTP client closed")
|
| 354 |
|
| 355 |
async def execute(self, job: dict[str, Any]) -> HttpExecutionResult:
|
| 356 |
retry_on_status: list[int] = job.get("retry_on_status") or [429, 500, 502, 503, 504]
|
|
@@ -506,6 +500,26 @@ class SchedulerService:
|
|
| 506 |
self._maintenance_task: asyncio.Task[None] | None = None
|
| 507 |
self._repo: SchedulerRepository | None = None
|
| 508 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
# -----------------------------------------------------------------------
|
| 510 |
# Redis connection management
|
| 511 |
# -----------------------------------------------------------------------
|
|
@@ -666,9 +680,8 @@ class SchedulerService:
|
|
| 666 |
logger.info("Scheduler started (mode=%s, instance=%s)", "redis" if self._use_redis else "memory", self._instance_id)
|
| 667 |
|
| 668 |
try:
|
| 669 |
-
|
| 670 |
-
if
|
| 671 |
-
repo = SchedulerRepository(client)
|
| 672 |
jobs = await repo.get_active_jobs()
|
| 673 |
restored = 0
|
| 674 |
for job in jobs:
|
|
@@ -737,9 +750,8 @@ class SchedulerService:
|
|
| 737 |
await self._async_redis.ping()
|
| 738 |
except Exception:
|
| 739 |
logger.error("Redis ping failed in maintenance loop")
|
| 740 |
-
|
| 741 |
-
if
|
| 742 |
-
repo = SchedulerRepository(client)
|
| 743 |
purged = await repo.purge_old_history(settings.history_retention_days)
|
| 744 |
if purged:
|
| 745 |
logger.info("Purged %d old history records", purged)
|
|
@@ -896,12 +908,11 @@ class SchedulerService:
|
|
| 896 |
"job_id": job_id, "trigger_reason": trigger_reason.value,
|
| 897 |
})
|
| 898 |
|
| 899 |
-
|
| 900 |
-
if
|
| 901 |
logger.error("Supabase not available, cannot execute job %s", job_id)
|
| 902 |
return
|
| 903 |
|
| 904 |
-
repo = SchedulerRepository(client)
|
| 905 |
job = await repo.get_job_by_id(job_id)
|
| 906 |
if not job:
|
| 907 |
logger.error("Job not found: %s", job_id)
|
|
@@ -1006,11 +1017,7 @@ class SchedulerService:
|
|
| 1006 |
# -----------------------------------------------------------------------
|
| 1007 |
|
| 1008 |
async def create_job(self, body: dict[str, Any]) -> dict[str, Any]:
|
| 1009 |
-
|
| 1010 |
-
if client is None:
|
| 1011 |
-
raise RuntimeError("Supabase not available")
|
| 1012 |
-
|
| 1013 |
-
repo = SchedulerRepository(client)
|
| 1014 |
|
| 1015 |
existing = await repo.get_job_by_name(body["name"])
|
| 1016 |
if existing:
|
|
@@ -1078,11 +1085,7 @@ class SchedulerService:
|
|
| 1078 |
return await repo.get_job_by_id(job_id) or created
|
| 1079 |
|
| 1080 |
async def update_job(self, job_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
| 1081 |
-
|
| 1082 |
-
if client is None:
|
| 1083 |
-
raise RuntimeError("Supabase not available")
|
| 1084 |
-
|
| 1085 |
-
repo = SchedulerRepository(client)
|
| 1086 |
job = await repo.get_job_by_id(job_id)
|
| 1087 |
if not job:
|
| 1088 |
raise KeyError(f"Job '{job_id}' not found")
|
|
@@ -1149,11 +1152,7 @@ class SchedulerService:
|
|
| 1149 |
return updated
|
| 1150 |
|
| 1151 |
async def delete_job(self, job_id: str) -> None:
|
| 1152 |
-
|
| 1153 |
-
if client is None:
|
| 1154 |
-
raise RuntimeError("Supabase not available")
|
| 1155 |
-
|
| 1156 |
-
repo = SchedulerRepository(client)
|
| 1157 |
job = await repo.get_job_by_id(job_id)
|
| 1158 |
if not job:
|
| 1159 |
raise KeyError(f"Job '{job_id}' not found")
|
|
@@ -1163,11 +1162,7 @@ class SchedulerService:
|
|
| 1163 |
logger.info("Job deleted: %s", job_id)
|
| 1164 |
|
| 1165 |
async def hard_delete_job(self, job_id: str) -> None:
|
| 1166 |
-
|
| 1167 |
-
if client is None:
|
| 1168 |
-
raise RuntimeError("Supabase not available")
|
| 1169 |
-
|
| 1170 |
-
repo = SchedulerRepository(client)
|
| 1171 |
job = await repo.get_job_by_id(job_id)
|
| 1172 |
if not job:
|
| 1173 |
raise KeyError(f"Job '{job_id}' not found")
|
|
@@ -1177,11 +1172,7 @@ class SchedulerService:
|
|
| 1177 |
logger.info("Job hard deleted: %s", job_id)
|
| 1178 |
|
| 1179 |
async def pause_job(self, job_id: str) -> dict[str, Any]:
|
| 1180 |
-
|
| 1181 |
-
if client is None:
|
| 1182 |
-
raise RuntimeError("Supabase not available")
|
| 1183 |
-
|
| 1184 |
-
repo = SchedulerRepository(client)
|
| 1185 |
job = await repo.get_job_by_id(job_id)
|
| 1186 |
if not job:
|
| 1187 |
raise KeyError(f"Job '{job_id}' not found")
|
|
@@ -1195,11 +1186,7 @@ class SchedulerService:
|
|
| 1195 |
return updated or job
|
| 1196 |
|
| 1197 |
async def resume_job(self, job_id: str) -> dict[str, Any]:
|
| 1198 |
-
|
| 1199 |
-
if client is None:
|
| 1200 |
-
raise RuntimeError("Supabase not available")
|
| 1201 |
-
|
| 1202 |
-
repo = SchedulerRepository(client)
|
| 1203 |
job = await repo.get_job_by_id(job_id)
|
| 1204 |
if not job:
|
| 1205 |
raise KeyError(f"Job '{job_id}' not found")
|
|
@@ -1213,11 +1200,7 @@ class SchedulerService:
|
|
| 1213 |
return updated or job
|
| 1214 |
|
| 1215 |
async def run_job_now(self, job_id: str) -> None:
|
| 1216 |
-
|
| 1217 |
-
if client is None:
|
| 1218 |
-
raise RuntimeError("Supabase not available")
|
| 1219 |
-
|
| 1220 |
-
repo = SchedulerRepository(client)
|
| 1221 |
job = await repo.get_job_by_id(job_id)
|
| 1222 |
if not job:
|
| 1223 |
raise KeyError(f"Job '{job_id}' not found")
|
|
@@ -1234,17 +1217,15 @@ class SchedulerService:
|
|
| 1234 |
page: int = 1,
|
| 1235 |
page_size: int = 20,
|
| 1236 |
) -> tuple[list[dict[str, Any]], int]:
|
| 1237 |
-
|
| 1238 |
-
if
|
| 1239 |
return [], 0
|
| 1240 |
-
repo = SchedulerRepository(client)
|
| 1241 |
return await repo.list_jobs(status=status, tags=tags, page=page, page_size=page_size)
|
| 1242 |
|
| 1243 |
async def get_job(self, job_id: str) -> dict[str, Any] | None:
|
| 1244 |
-
|
| 1245 |
-
if
|
| 1246 |
return None
|
| 1247 |
-
repo = SchedulerRepository(client)
|
| 1248 |
return await repo.get_job_by_id(job_id)
|
| 1249 |
|
| 1250 |
async def get_job_history(
|
|
@@ -1253,10 +1234,9 @@ class SchedulerService:
|
|
| 1253 |
page: int = 1,
|
| 1254 |
page_size: int = 20,
|
| 1255 |
) -> tuple[list[dict[str, Any]], int]:
|
| 1256 |
-
|
| 1257 |
-
if
|
| 1258 |
return [], 0
|
| 1259 |
-
repo = SchedulerRepository(client)
|
| 1260 |
return await repo.get_history(job_id=job_id, page=page, page_size=page_size)
|
| 1261 |
|
| 1262 |
async def get_execution_history(
|
|
@@ -1265,18 +1245,15 @@ class SchedulerService:
|
|
| 1265 |
page_size: int = 50,
|
| 1266 |
status: str | None = None,
|
| 1267 |
) -> tuple[list[dict[str, Any]], int]:
|
| 1268 |
-
|
| 1269 |
-
if
|
| 1270 |
return [], 0
|
| 1271 |
-
repo = SchedulerRepository(client)
|
| 1272 |
return await repo.get_history_all(page=page, page_size=page_size, status_filter=status)
|
| 1273 |
|
| 1274 |
async def get_metrics(self) -> dict[str, Any]:
|
| 1275 |
-
|
| 1276 |
-
if
|
| 1277 |
return {"error": "Supabase not available"}
|
| 1278 |
-
|
| 1279 |
-
repo = SchedulerRepository(client)
|
| 1280 |
total_jobs = await repo.count_jobs()
|
| 1281 |
active_jobs = await repo.count_jobs(status=JobStatus.ACTIVE.value)
|
| 1282 |
paused_jobs = await repo.count_jobs(status=JobStatus.PAUSED.value)
|
|
|
|
| 23 |
from app.config import get_settings
|
| 24 |
from app.core.logger import get_logger
|
| 25 |
from app.services.supabase import SupabaseClient, get_supabase_client
|
| 26 |
+
from app.utils.http_utils import SharedAsyncClient
|
| 27 |
|
| 28 |
logger = get_logger(__name__)
|
| 29 |
settings = get_settings()
|
|
|
|
| 330 |
_MAX_RESPONSE_PREVIEW = 500
|
| 331 |
|
| 332 |
def __init__(self) -> None:
|
| 333 |
+
self._http = SharedAsyncClient(
|
| 334 |
+
timeout=httpx.Timeout(settings.max_http_timeout),
|
| 335 |
+
limits=httpx.Limits(
|
| 336 |
+
max_connections=200,
|
| 337 |
+
max_keepalive_connections=50,
|
| 338 |
+
keepalive_expiry=30,
|
| 339 |
+
),
|
| 340 |
+
)
|
| 341 |
|
| 342 |
async def _get_client(self) -> httpx.AsyncClient:
|
| 343 |
+
return await self._http.get()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
async def close(self) -> None:
|
| 346 |
+
await self._http.close()
|
| 347 |
+
logger.info("HTTP client closed")
|
|
|
|
| 348 |
|
| 349 |
async def execute(self, job: dict[str, Any]) -> HttpExecutionResult:
|
| 350 |
retry_on_status: list[int] = job.get("retry_on_status") or [429, 500, 502, 503, 504]
|
|
|
|
| 500 |
self._maintenance_task: asyncio.Task[None] | None = None
|
| 501 |
self._repo: SchedulerRepository | None = None
|
| 502 |
|
| 503 |
+
# -----------------------------------------------------------------------
|
| 504 |
+
# Supabase repository access
|
| 505 |
+
# -----------------------------------------------------------------------
|
| 506 |
+
|
| 507 |
+
@staticmethod
|
| 508 |
+
def _get_repo() -> SchedulerRepository:
|
| 509 |
+
"""Return a Supabase-backed repository or raise if Supabase is unavailable."""
|
| 510 |
+
client = get_supabase_client()
|
| 511 |
+
if client is None:
|
| 512 |
+
raise RuntimeError("Supabase not available")
|
| 513 |
+
return SchedulerRepository(client)
|
| 514 |
+
|
| 515 |
+
@staticmethod
|
| 516 |
+
def _get_optional_repo() -> SchedulerRepository | None:
|
| 517 |
+
"""Return a Supabase-backed repository or None if Supabase is unavailable."""
|
| 518 |
+
client = get_supabase_client()
|
| 519 |
+
if client is None:
|
| 520 |
+
return None
|
| 521 |
+
return SchedulerRepository(client)
|
| 522 |
+
|
| 523 |
# -----------------------------------------------------------------------
|
| 524 |
# Redis connection management
|
| 525 |
# -----------------------------------------------------------------------
|
|
|
|
| 680 |
logger.info("Scheduler started (mode=%s, instance=%s)", "redis" if self._use_redis else "memory", self._instance_id)
|
| 681 |
|
| 682 |
try:
|
| 683 |
+
repo = self._get_optional_repo()
|
| 684 |
+
if repo is not None:
|
|
|
|
| 685 |
jobs = await repo.get_active_jobs()
|
| 686 |
restored = 0
|
| 687 |
for job in jobs:
|
|
|
|
| 750 |
await self._async_redis.ping()
|
| 751 |
except Exception:
|
| 752 |
logger.error("Redis ping failed in maintenance loop")
|
| 753 |
+
repo = self._get_optional_repo()
|
| 754 |
+
if repo is not None:
|
|
|
|
| 755 |
purged = await repo.purge_old_history(settings.history_retention_days)
|
| 756 |
if purged:
|
| 757 |
logger.info("Purged %d old history records", purged)
|
|
|
|
| 908 |
"job_id": job_id, "trigger_reason": trigger_reason.value,
|
| 909 |
})
|
| 910 |
|
| 911 |
+
repo = self._get_optional_repo()
|
| 912 |
+
if repo is None:
|
| 913 |
logger.error("Supabase not available, cannot execute job %s", job_id)
|
| 914 |
return
|
| 915 |
|
|
|
|
| 916 |
job = await repo.get_job_by_id(job_id)
|
| 917 |
if not job:
|
| 918 |
logger.error("Job not found: %s", job_id)
|
|
|
|
| 1017 |
# -----------------------------------------------------------------------
|
| 1018 |
|
| 1019 |
async def create_job(self, body: dict[str, Any]) -> dict[str, Any]:
|
| 1020 |
+
repo = self._get_repo()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1021 |
|
| 1022 |
existing = await repo.get_job_by_name(body["name"])
|
| 1023 |
if existing:
|
|
|
|
| 1085 |
return await repo.get_job_by_id(job_id) or created
|
| 1086 |
|
| 1087 |
async def update_job(self, job_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
| 1088 |
+
repo = self._get_repo()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1089 |
job = await repo.get_job_by_id(job_id)
|
| 1090 |
if not job:
|
| 1091 |
raise KeyError(f"Job '{job_id}' not found")
|
|
|
|
| 1152 |
return updated
|
| 1153 |
|
| 1154 |
async def delete_job(self, job_id: str) -> None:
|
| 1155 |
+
repo = self._get_repo()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1156 |
job = await repo.get_job_by_id(job_id)
|
| 1157 |
if not job:
|
| 1158 |
raise KeyError(f"Job '{job_id}' not found")
|
|
|
|
| 1162 |
logger.info("Job deleted: %s", job_id)
|
| 1163 |
|
| 1164 |
async def hard_delete_job(self, job_id: str) -> None:
|
| 1165 |
+
repo = self._get_repo()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1166 |
job = await repo.get_job_by_id(job_id)
|
| 1167 |
if not job:
|
| 1168 |
raise KeyError(f"Job '{job_id}' not found")
|
|
|
|
| 1172 |
logger.info("Job hard deleted: %s", job_id)
|
| 1173 |
|
| 1174 |
async def pause_job(self, job_id: str) -> dict[str, Any]:
|
| 1175 |
+
repo = self._get_repo()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1176 |
job = await repo.get_job_by_id(job_id)
|
| 1177 |
if not job:
|
| 1178 |
raise KeyError(f"Job '{job_id}' not found")
|
|
|
|
| 1186 |
return updated or job
|
| 1187 |
|
| 1188 |
async def resume_job(self, job_id: str) -> dict[str, Any]:
|
| 1189 |
+
repo = self._get_repo()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1190 |
job = await repo.get_job_by_id(job_id)
|
| 1191 |
if not job:
|
| 1192 |
raise KeyError(f"Job '{job_id}' not found")
|
|
|
|
| 1200 |
return updated or job
|
| 1201 |
|
| 1202 |
async def run_job_now(self, job_id: str) -> None:
|
| 1203 |
+
repo = self._get_repo()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1204 |
job = await repo.get_job_by_id(job_id)
|
| 1205 |
if not job:
|
| 1206 |
raise KeyError(f"Job '{job_id}' not found")
|
|
|
|
| 1217 |
page: int = 1,
|
| 1218 |
page_size: int = 20,
|
| 1219 |
) -> tuple[list[dict[str, Any]], int]:
|
| 1220 |
+
repo = self._get_optional_repo()
|
| 1221 |
+
if repo is None:
|
| 1222 |
return [], 0
|
|
|
|
| 1223 |
return await repo.list_jobs(status=status, tags=tags, page=page, page_size=page_size)
|
| 1224 |
|
| 1225 |
async def get_job(self, job_id: str) -> dict[str, Any] | None:
|
| 1226 |
+
repo = self._get_optional_repo()
|
| 1227 |
+
if repo is None:
|
| 1228 |
return None
|
|
|
|
| 1229 |
return await repo.get_job_by_id(job_id)
|
| 1230 |
|
| 1231 |
async def get_job_history(
|
|
|
|
| 1234 |
page: int = 1,
|
| 1235 |
page_size: int = 20,
|
| 1236 |
) -> tuple[list[dict[str, Any]], int]:
|
| 1237 |
+
repo = self._get_optional_repo()
|
| 1238 |
+
if repo is None:
|
| 1239 |
return [], 0
|
|
|
|
| 1240 |
return await repo.get_history(job_id=job_id, page=page, page_size=page_size)
|
| 1241 |
|
| 1242 |
async def get_execution_history(
|
|
|
|
| 1245 |
page_size: int = 50,
|
| 1246 |
status: str | None = None,
|
| 1247 |
) -> tuple[list[dict[str, Any]], int]:
|
| 1248 |
+
repo = self._get_optional_repo()
|
| 1249 |
+
if repo is None:
|
| 1250 |
return [], 0
|
|
|
|
| 1251 |
return await repo.get_history_all(page=page, page_size=page_size, status_filter=status)
|
| 1252 |
|
| 1253 |
async def get_metrics(self) -> dict[str, Any]:
|
| 1254 |
+
repo = self._get_optional_repo()
|
| 1255 |
+
if repo is None:
|
| 1256 |
return {"error": "Supabase not available"}
|
|
|
|
|
|
|
| 1257 |
total_jobs = await repo.count_jobs()
|
| 1258 |
active_jobs = await repo.count_jobs(status=JobStatus.ACTIVE.value)
|
| 1259 |
paused_jobs = await repo.count_jobs(status=JobStatus.PAUSED.value)
|
app/services/url_shortener_service.py
CHANGED
|
@@ -312,6 +312,52 @@ def _fire_click_webhook(webhook_url: str, short_code: str, long_url: str,
|
|
| 312 |
urllib.request.urlopen(req, timeout=5)
|
| 313 |
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
# ---------------------------------------------------------------------------
|
| 316 |
# Supabase-backed Storage
|
| 317 |
# ---------------------------------------------------------------------------
|
|
@@ -337,8 +383,7 @@ class Storage:
|
|
| 337 |
|
| 338 |
def create_owner(self, owner_id: str, name: str, api_key_hash: str, plan: str = "free"):
|
| 339 |
with self._lock:
|
| 340 |
-
|
| 341 |
-
asyncio.run(self._get_client().insert("url_shortener_owners", {
|
| 342 |
"owner_id": owner_id,
|
| 343 |
"name": name,
|
| 344 |
"api_key_hash": api_key_hash,
|
|
@@ -347,23 +392,19 @@ class Storage:
|
|
| 347 |
}))
|
| 348 |
|
| 349 |
def get_owner_by_key_hash(self, api_key_hash: str) -> Optional[dict]:
|
| 350 |
-
|
| 351 |
-
return asyncio.run(self._get_client().find_one("url_shortener_owners", "api_key_hash", api_key_hash))
|
| 352 |
|
| 353 |
def get_owner(self, owner_id: str) -> Optional[dict]:
|
| 354 |
-
|
| 355 |
-
return asyncio.run(self._get_client().find_one("url_shortener_owners", "owner_id", owner_id))
|
| 356 |
|
| 357 |
def update_owner_plan(self, owner_id: str, plan: str):
|
| 358 |
with self._lock:
|
| 359 |
-
|
| 360 |
-
asyncio.run(self._get_client().update("url_shortener_owners", "owner_id", owner_id, {"plan": plan}))
|
| 361 |
|
| 362 |
# ---------- Links ----------
|
| 363 |
|
| 364 |
def code_exists(self, short_code: str) -> bool:
|
| 365 |
-
|
| 366 |
-
row = asyncio.run(self._get_client().find_one("url_shortener_links", "short_code", short_code, columns="short_code"))
|
| 367 |
return row is not None
|
| 368 |
|
| 369 |
def insert_link(self, **kwargs):
|
|
@@ -389,36 +430,30 @@ class Storage:
|
|
| 389 |
"webhook_url": kwargs.get("webhook_url"),
|
| 390 |
"geo_targeting": kwargs.get("geo_targeting"),
|
| 391 |
}
|
| 392 |
-
|
| 393 |
-
asyncio.run(self._get_client().insert("url_shortener_links", data))
|
| 394 |
|
| 395 |
def update_link(self, short_code: str, **updates):
|
| 396 |
with self._lock:
|
| 397 |
-
|
| 398 |
-
asyncio.run(self._get_client().update("url_shortener_links", "short_code", short_code, updates))
|
| 399 |
|
| 400 |
def get_link(self, short_code: str) -> Optional[dict]:
|
| 401 |
-
|
| 402 |
-
return asyncio.run(self._get_client().find_one("url_shortener_links", "short_code", short_code))
|
| 403 |
|
| 404 |
def list_links_for_owner(self, owner_id: str) -> List[dict]:
|
| 405 |
-
|
| 406 |
-
return asyncio.run(self._get_client().select(
|
| 407 |
"url_shortener_links", eq=("owner_id", owner_id), order=("created_at", True),
|
| 408 |
))
|
| 409 |
|
| 410 |
def deactivate_link(self, short_code: str):
|
| 411 |
with self._lock:
|
| 412 |
-
|
| 413 |
-
asyncio.run(self._get_client().update("url_shortener_links", "short_code", short_code, {"is_active": 0}))
|
| 414 |
|
| 415 |
def record_click(self, short_code: str, referrer: Optional[str] = None,
|
| 416 |
user_agent: Optional[str] = None, ip_address: Optional[str] = None):
|
| 417 |
now = _now_iso()
|
| 418 |
parsed = _parse_user_agent(user_agent or "")
|
| 419 |
with self._lock:
|
| 420 |
-
|
| 421 |
-
asyncio.run(self._get_client().insert("url_shortener_clicks", {
|
| 422 |
"short_code": short_code,
|
| 423 |
"referrer": referrer,
|
| 424 |
"user_agent": user_agent,
|
|
@@ -429,18 +464,17 @@ class Storage:
|
|
| 429 |
"os": parsed["os"],
|
| 430 |
"clicked_at": now,
|
| 431 |
}))
|
| 432 |
-
link =
|
| 433 |
if link:
|
| 434 |
current_clicks = (link.get("click_count") or 0) + 1
|
| 435 |
-
|
| 436 |
"url_shortener_links", "short_code", short_code,
|
| 437 |
{"click_count": current_clicks, "last_accessed_at": now},
|
| 438 |
))
|
| 439 |
|
| 440 |
def get_click_analytics(self, short_code: str) -> Dict[str, Any]:
|
| 441 |
-
import asyncio
|
| 442 |
client = self._get_client()
|
| 443 |
-
all_clicks =
|
| 444 |
total_val = len(all_clicks)
|
| 445 |
browsers: Dict[str, int] = {}
|
| 446 |
devices: Dict[str, int] = {}
|
|
@@ -471,14 +505,12 @@ class Storage:
|
|
| 471 |
}
|
| 472 |
|
| 473 |
def get_link_count_for_owner(self, owner_id: str) -> int:
|
| 474 |
-
|
| 475 |
-
links = asyncio.run(self._get_client().select("url_shortener_links", eq=("owner_id", owner_id)))
|
| 476 |
return len(links)
|
| 477 |
|
| 478 |
def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = ""):
|
| 479 |
with self._lock:
|
| 480 |
-
|
| 481 |
-
asyncio.run(self._get_client().insert("url_shortener_audit_log", {
|
| 482 |
"timestamp": _now_iso(),
|
| 483 |
"owner_id": owner_id,
|
| 484 |
"action": action,
|
|
@@ -487,8 +519,7 @@ class Storage:
|
|
| 487 |
}))
|
| 488 |
|
| 489 |
def export_links_csv(self, owner_id: str) -> str:
|
| 490 |
-
|
| 491 |
-
rows = asyncio.run(self._get_client().select(
|
| 492 |
"url_shortener_links", eq=("owner_id", owner_id), order=("created_at", True),
|
| 493 |
))
|
| 494 |
buf = io.StringIO()
|
|
@@ -500,8 +531,7 @@ class Storage:
|
|
| 500 |
return buf.getvalue()
|
| 501 |
|
| 502 |
def export_clicks_csv(self, short_code: str) -> str:
|
| 503 |
-
|
| 504 |
-
rows = asyncio.run(self._get_client().select(
|
| 505 |
"url_shortener_clicks", eq=("short_code", short_code), order=("clicked_at", True),
|
| 506 |
))
|
| 507 |
buf = io.StringIO()
|
|
@@ -513,15 +543,13 @@ class Storage:
|
|
| 513 |
return buf.getvalue()
|
| 514 |
|
| 515 |
def list_links_by_campaign(self, campaign_id: str) -> List[dict]:
|
| 516 |
-
|
| 517 |
-
return asyncio.run(self._get_client().select(
|
| 518 |
"url_shortener_links", eq=("campaign_id", campaign_id), order=("created_at", True),
|
| 519 |
))
|
| 520 |
|
| 521 |
def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None):
|
| 522 |
with self._lock:
|
| 523 |
-
|
| 524 |
-
asyncio.run(self._get_client().insert("url_shortener_campaigns", {
|
| 525 |
"campaign_id": campaign_id,
|
| 526 |
"owner_id": owner_id,
|
| 527 |
"name": name,
|
|
@@ -530,41 +558,35 @@ class Storage:
|
|
| 530 |
}))
|
| 531 |
|
| 532 |
def get_campaign(self, campaign_id: str) -> Optional[dict]:
|
| 533 |
-
|
| 534 |
-
return asyncio.run(self._get_client().find_one("url_shortener_campaigns", "campaign_id", campaign_id))
|
| 535 |
|
| 536 |
def list_campaigns_for_owner(self, owner_id: str) -> List[dict]:
|
| 537 |
-
|
| 538 |
-
return asyncio.run(self._get_client().select(
|
| 539 |
"url_shortener_campaigns", eq=("owner_id", owner_id), order=("created_at", True),
|
| 540 |
))
|
| 541 |
|
| 542 |
def deactivate_campaign(self, campaign_id: str):
|
| 543 |
with self._lock:
|
| 544 |
-
|
| 545 |
-
asyncio.run(self._get_client().update("url_shortener_campaigns", "campaign_id", campaign_id, {"is_active": 0}))
|
| 546 |
|
| 547 |
def get_campaign_link_count(self, campaign_id: str) -> int:
|
| 548 |
-
|
| 549 |
-
links = asyncio.run(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
| 550 |
return len(links)
|
| 551 |
|
| 552 |
def get_campaign_total_clicks(self, campaign_id: str) -> int:
|
| 553 |
-
|
| 554 |
-
links = asyncio.run(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
| 555 |
return sum(l.get("click_count") or 0 for l in links)
|
| 556 |
|
| 557 |
def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
|
| 558 |
-
import asyncio
|
| 559 |
client = self._get_client()
|
| 560 |
-
codes =
|
| 561 |
total = 0
|
| 562 |
browsers: Dict[str, int] = {}
|
| 563 |
devices: Dict[str, int] = {}
|
| 564 |
os_data: Dict[str, int] = {}
|
| 565 |
for row in codes:
|
| 566 |
code = row["short_code"]
|
| 567 |
-
clicks =
|
| 568 |
total += len(clicks)
|
| 569 |
for c in clicks:
|
| 570 |
b = c.get("browser")
|
|
@@ -582,9 +604,8 @@ class Storage:
|
|
| 582 |
row = self.get_owner(owner_id)
|
| 583 |
if row is None:
|
| 584 |
return {}
|
| 585 |
-
import asyncio
|
| 586 |
client = self._get_client()
|
| 587 |
-
links =
|
| 588 |
link_count = len(links)
|
| 589 |
total_clicks = sum(l.get("click_count") or 0 for l in links)
|
| 590 |
active = sum(1 for l in links if l.get("is_active"))
|
|
@@ -949,11 +970,10 @@ class URLShortenerService:
|
|
| 949 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
| 950 |
if row["owner_id"] != owner_id:
|
| 951 |
raise AuthorizationError("You do not own this link")
|
| 952 |
-
import asyncio
|
| 953 |
client = get_supabase_client()
|
| 954 |
if client is None:
|
| 955 |
return []
|
| 956 |
-
rows =
|
| 957 |
"url_shortener_clicks", eq=("short_code", short_code),
|
| 958 |
))
|
| 959 |
return list(rows)[:limit]
|
|
|
|
| 312 |
urllib.request.urlopen(req, timeout=5)
|
| 313 |
|
| 314 |
|
| 315 |
+
_shared_loop: Optional[asyncio.AbstractEventLoop] = None
|
| 316 |
+
_shared_loop_lock = threading.Lock()
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _run_async(coro: Any) -> Any:
|
| 320 |
+
"""Run a coroutine on a single shared background event loop.
|
| 321 |
+
|
| 322 |
+
Previously this module called ``asyncio.run()`` ~25 times per request,
|
| 323 |
+
creating and tearing down a fresh event loop (with its own thread pool)
|
| 324 |
+
for every Supabase call. Reusing one long-lived daemon loop avoids that
|
| 325 |
+
repeated construction and lets connections/executors persist.
|
| 326 |
+
|
| 327 |
+
The loop is created inside the worker thread itself so it is bound to the
|
| 328 |
+
thread that runs it (required on Windows where ``new_event_loop()`` builds
|
| 329 |
+
a ProactorEventLoop with an I/O completion port).
|
| 330 |
+
"""
|
| 331 |
+
global _shared_loop
|
| 332 |
+
with _shared_loop_lock:
|
| 333 |
+
if _shared_loop is None or _shared_loop.is_closed() or not _shared_loop.is_running():
|
| 334 |
+
created_loop: Optional[asyncio.AbstractEventLoop] = None
|
| 335 |
+
thread_error: Optional[BaseException] = None
|
| 336 |
+
ready = threading.Event()
|
| 337 |
+
|
| 338 |
+
def _run_forever() -> None:
|
| 339 |
+
nonlocal created_loop, thread_error
|
| 340 |
+
try:
|
| 341 |
+
created_loop = asyncio.new_event_loop()
|
| 342 |
+
asyncio.set_event_loop(created_loop)
|
| 343 |
+
ready.set()
|
| 344 |
+
created_loop.run_forever()
|
| 345 |
+
except BaseException as exc: # pragma: no cover - loop teardown path
|
| 346 |
+
thread_error = exc
|
| 347 |
+
ready.set()
|
| 348 |
+
|
| 349 |
+
threading.Thread(
|
| 350 |
+
target=_run_forever,
|
| 351 |
+
name="url-shortener-loop",
|
| 352 |
+
daemon=True,
|
| 353 |
+
).start()
|
| 354 |
+
ready.wait()
|
| 355 |
+
if thread_error is not None:
|
| 356 |
+
raise thread_error
|
| 357 |
+
_shared_loop = created_loop
|
| 358 |
+
return asyncio.run_coroutine_threadsafe(coro, _shared_loop).result()
|
| 359 |
+
|
| 360 |
+
|
| 361 |
# ---------------------------------------------------------------------------
|
| 362 |
# Supabase-backed Storage
|
| 363 |
# ---------------------------------------------------------------------------
|
|
|
|
| 383 |
|
| 384 |
def create_owner(self, owner_id: str, name: str, api_key_hash: str, plan: str = "free"):
|
| 385 |
with self._lock:
|
| 386 |
+
_run_async(self._get_client().insert("url_shortener_owners", {
|
|
|
|
| 387 |
"owner_id": owner_id,
|
| 388 |
"name": name,
|
| 389 |
"api_key_hash": api_key_hash,
|
|
|
|
| 392 |
}))
|
| 393 |
|
| 394 |
def get_owner_by_key_hash(self, api_key_hash: str) -> Optional[dict]:
|
| 395 |
+
return _run_async(self._get_client().find_one("url_shortener_owners", "api_key_hash", api_key_hash))
|
|
|
|
| 396 |
|
| 397 |
def get_owner(self, owner_id: str) -> Optional[dict]:
|
| 398 |
+
return _run_async(self._get_client().find_one("url_shortener_owners", "owner_id", owner_id))
|
|
|
|
| 399 |
|
| 400 |
def update_owner_plan(self, owner_id: str, plan: str):
|
| 401 |
with self._lock:
|
| 402 |
+
_run_async(self._get_client().update("url_shortener_owners", "owner_id", owner_id, {"plan": plan}))
|
|
|
|
| 403 |
|
| 404 |
# ---------- Links ----------
|
| 405 |
|
| 406 |
def code_exists(self, short_code: str) -> bool:
|
| 407 |
+
row = _run_async(self._get_client().find_one("url_shortener_links", "short_code", short_code, columns="short_code"))
|
|
|
|
| 408 |
return row is not None
|
| 409 |
|
| 410 |
def insert_link(self, **kwargs):
|
|
|
|
| 430 |
"webhook_url": kwargs.get("webhook_url"),
|
| 431 |
"geo_targeting": kwargs.get("geo_targeting"),
|
| 432 |
}
|
| 433 |
+
_run_async(self._get_client().insert("url_shortener_links", data))
|
|
|
|
| 434 |
|
| 435 |
def update_link(self, short_code: str, **updates):
|
| 436 |
with self._lock:
|
| 437 |
+
_run_async(self._get_client().update("url_shortener_links", "short_code", short_code, updates))
|
|
|
|
| 438 |
|
| 439 |
def get_link(self, short_code: str) -> Optional[dict]:
|
| 440 |
+
return _run_async(self._get_client().find_one("url_shortener_links", "short_code", short_code))
|
|
|
|
| 441 |
|
| 442 |
def list_links_for_owner(self, owner_id: str) -> List[dict]:
|
| 443 |
+
return _run_async(self._get_client().select(
|
|
|
|
| 444 |
"url_shortener_links", eq=("owner_id", owner_id), order=("created_at", True),
|
| 445 |
))
|
| 446 |
|
| 447 |
def deactivate_link(self, short_code: str):
|
| 448 |
with self._lock:
|
| 449 |
+
_run_async(self._get_client().update("url_shortener_links", "short_code", short_code, {"is_active": 0}))
|
|
|
|
| 450 |
|
| 451 |
def record_click(self, short_code: str, referrer: Optional[str] = None,
|
| 452 |
user_agent: Optional[str] = None, ip_address: Optional[str] = None):
|
| 453 |
now = _now_iso()
|
| 454 |
parsed = _parse_user_agent(user_agent or "")
|
| 455 |
with self._lock:
|
| 456 |
+
_run_async(self._get_client().insert("url_shortener_clicks", {
|
|
|
|
| 457 |
"short_code": short_code,
|
| 458 |
"referrer": referrer,
|
| 459 |
"user_agent": user_agent,
|
|
|
|
| 464 |
"os": parsed["os"],
|
| 465 |
"clicked_at": now,
|
| 466 |
}))
|
| 467 |
+
link = _run_async(self._get_client().find_one("url_shortener_links", "short_code", short_code))
|
| 468 |
if link:
|
| 469 |
current_clicks = (link.get("click_count") or 0) + 1
|
| 470 |
+
_run_async(self._get_client().update(
|
| 471 |
"url_shortener_links", "short_code", short_code,
|
| 472 |
{"click_count": current_clicks, "last_accessed_at": now},
|
| 473 |
))
|
| 474 |
|
| 475 |
def get_click_analytics(self, short_code: str) -> Dict[str, Any]:
|
|
|
|
| 476 |
client = self._get_client()
|
| 477 |
+
all_clicks = _run_async(client.select("url_shortener_clicks", eq=("short_code", short_code)))
|
| 478 |
total_val = len(all_clicks)
|
| 479 |
browsers: Dict[str, int] = {}
|
| 480 |
devices: Dict[str, int] = {}
|
|
|
|
| 505 |
}
|
| 506 |
|
| 507 |
def get_link_count_for_owner(self, owner_id: str) -> int:
|
| 508 |
+
links = _run_async(self._get_client().select("url_shortener_links", eq=("owner_id", owner_id)))
|
|
|
|
| 509 |
return len(links)
|
| 510 |
|
| 511 |
def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = ""):
|
| 512 |
with self._lock:
|
| 513 |
+
_run_async(self._get_client().insert("url_shortener_audit_log", {
|
|
|
|
| 514 |
"timestamp": _now_iso(),
|
| 515 |
"owner_id": owner_id,
|
| 516 |
"action": action,
|
|
|
|
| 519 |
}))
|
| 520 |
|
| 521 |
def export_links_csv(self, owner_id: str) -> str:
|
| 522 |
+
rows = _run_async(self._get_client().select(
|
|
|
|
| 523 |
"url_shortener_links", eq=("owner_id", owner_id), order=("created_at", True),
|
| 524 |
))
|
| 525 |
buf = io.StringIO()
|
|
|
|
| 531 |
return buf.getvalue()
|
| 532 |
|
| 533 |
def export_clicks_csv(self, short_code: str) -> str:
|
| 534 |
+
rows = _run_async(self._get_client().select(
|
|
|
|
| 535 |
"url_shortener_clicks", eq=("short_code", short_code), order=("clicked_at", True),
|
| 536 |
))
|
| 537 |
buf = io.StringIO()
|
|
|
|
| 543 |
return buf.getvalue()
|
| 544 |
|
| 545 |
def list_links_by_campaign(self, campaign_id: str) -> List[dict]:
|
| 546 |
+
return _run_async(self._get_client().select(
|
|
|
|
| 547 |
"url_shortener_links", eq=("campaign_id", campaign_id), order=("created_at", True),
|
| 548 |
))
|
| 549 |
|
| 550 |
def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None):
|
| 551 |
with self._lock:
|
| 552 |
+
_run_async(self._get_client().insert("url_shortener_campaigns", {
|
|
|
|
| 553 |
"campaign_id": campaign_id,
|
| 554 |
"owner_id": owner_id,
|
| 555 |
"name": name,
|
|
|
|
| 558 |
}))
|
| 559 |
|
| 560 |
def get_campaign(self, campaign_id: str) -> Optional[dict]:
|
| 561 |
+
return _run_async(self._get_client().find_one("url_shortener_campaigns", "campaign_id", campaign_id))
|
|
|
|
| 562 |
|
| 563 |
def list_campaigns_for_owner(self, owner_id: str) -> List[dict]:
|
| 564 |
+
return _run_async(self._get_client().select(
|
|
|
|
| 565 |
"url_shortener_campaigns", eq=("owner_id", owner_id), order=("created_at", True),
|
| 566 |
))
|
| 567 |
|
| 568 |
def deactivate_campaign(self, campaign_id: str):
|
| 569 |
with self._lock:
|
| 570 |
+
_run_async(self._get_client().update("url_shortener_campaigns", "campaign_id", campaign_id, {"is_active": 0}))
|
|
|
|
| 571 |
|
| 572 |
def get_campaign_link_count(self, campaign_id: str) -> int:
|
| 573 |
+
links = _run_async(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
|
|
|
| 574 |
return len(links)
|
| 575 |
|
| 576 |
def get_campaign_total_clicks(self, campaign_id: str) -> int:
|
| 577 |
+
links = _run_async(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
|
|
|
| 578 |
return sum(l.get("click_count") or 0 for l in links)
|
| 579 |
|
| 580 |
def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
|
|
|
|
| 581 |
client = self._get_client()
|
| 582 |
+
codes = _run_async(client.select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
| 583 |
total = 0
|
| 584 |
browsers: Dict[str, int] = {}
|
| 585 |
devices: Dict[str, int] = {}
|
| 586 |
os_data: Dict[str, int] = {}
|
| 587 |
for row in codes:
|
| 588 |
code = row["short_code"]
|
| 589 |
+
clicks = _run_async(client.select("url_shortener_clicks", eq=("short_code", code)))
|
| 590 |
total += len(clicks)
|
| 591 |
for c in clicks:
|
| 592 |
b = c.get("browser")
|
|
|
|
| 604 |
row = self.get_owner(owner_id)
|
| 605 |
if row is None:
|
| 606 |
return {}
|
|
|
|
| 607 |
client = self._get_client()
|
| 608 |
+
links = _run_async(client.select("url_shortener_links", eq=("owner_id", owner_id)))
|
| 609 |
link_count = len(links)
|
| 610 |
total_clicks = sum(l.get("click_count") or 0 for l in links)
|
| 611 |
active = sum(1 for l in links if l.get("is_active"))
|
|
|
|
| 970 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
| 971 |
if row["owner_id"] != owner_id:
|
| 972 |
raise AuthorizationError("You do not own this link")
|
|
|
|
| 973 |
client = get_supabase_client()
|
| 974 |
if client is None:
|
| 975 |
return []
|
| 976 |
+
rows = _run_async(client.select(
|
| 977 |
"url_shortener_clicks", eq=("short_code", short_code),
|
| 978 |
))
|
| 979 |
return list(rows)[:limit]
|
app/services/web_search_service.py
CHANGED
|
@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional
|
|
| 7 |
import aiohttp
|
| 8 |
|
| 9 |
from app.config import get_settings
|
|
|
|
| 10 |
|
| 11 |
_logger = logging.getLogger(__name__)
|
| 12 |
_settings = get_settings()
|
|
@@ -60,8 +61,8 @@ class WebSearchService:
|
|
| 60 |
for attempt in range(1 + self._max_retries):
|
| 61 |
try:
|
| 62 |
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
| 63 |
-
async with
|
| 64 |
-
async with session.get(url, params=params) as resp:
|
| 65 |
if resp.status == 403:
|
| 66 |
last_error = (
|
| 67 |
"SearXNG instance returned 403 Forbidden. "
|
|
@@ -145,8 +146,8 @@ class WebSearchService:
|
|
| 145 |
url = f"{self._base_url}/config"
|
| 146 |
try:
|
| 147 |
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
| 148 |
-
async with
|
| 149 |
-
async with session.get(url) as resp:
|
| 150 |
if resp.status != 200:
|
| 151 |
return {
|
| 152 |
"success": False,
|
|
@@ -184,8 +185,8 @@ class WebSearchService:
|
|
| 184 |
params = {"q": query}
|
| 185 |
try:
|
| 186 |
timeout = aiohttp.ClientTimeout(total=10)
|
| 187 |
-
async with
|
| 188 |
-
async with session.get(url, params=params) as resp:
|
| 189 |
if resp.status != 200:
|
| 190 |
return {"success": False, "error": f"HTTP {resp.status} from SearXNG autocompleter"}
|
| 191 |
data = await resp.json()
|
|
@@ -205,8 +206,8 @@ class WebSearchService:
|
|
| 205 |
url = f"{self._base_url}/engine_descriptions.json"
|
| 206 |
try:
|
| 207 |
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
| 208 |
-
async with
|
| 209 |
-
async with session.get(url) as resp:
|
| 210 |
if resp.status != 200:
|
| 211 |
return {"success": False, "error": f"HTTP {resp.status}"}
|
| 212 |
data = await resp.json()
|
|
@@ -218,8 +219,8 @@ class WebSearchService:
|
|
| 218 |
url = f"{self._base_url}/stats"
|
| 219 |
try:
|
| 220 |
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
| 221 |
-
async with
|
| 222 |
-
async with session.get(url) as resp:
|
| 223 |
if resp.status != 200:
|
| 224 |
return {"success": False, "error": f"HTTP {resp.status}"}
|
| 225 |
data = await resp.json()
|
|
@@ -231,8 +232,8 @@ class WebSearchService:
|
|
| 231 |
url = f"{self._base_url}/healthz"
|
| 232 |
try:
|
| 233 |
timeout = aiohttp.ClientTimeout(total=10)
|
| 234 |
-
async with
|
| 235 |
-
async with session.get(url) as resp:
|
| 236 |
return {
|
| 237 |
"success": resp.status == 200,
|
| 238 |
"status": "healthy" if resp.status == 200 else f"unhealthy (HTTP {resp.status})",
|
|
|
|
| 7 |
import aiohttp
|
| 8 |
|
| 9 |
from app.config import get_settings
|
| 10 |
+
from app.utils.http_utils import shared_aiohttp_session
|
| 11 |
|
| 12 |
_logger = logging.getLogger(__name__)
|
| 13 |
_settings = get_settings()
|
|
|
|
| 61 |
for attempt in range(1 + self._max_retries):
|
| 62 |
try:
|
| 63 |
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
| 64 |
+
async with shared_aiohttp_session() as session:
|
| 65 |
+
async with session.get(url, params=params, timeout=timeout) as resp:
|
| 66 |
if resp.status == 403:
|
| 67 |
last_error = (
|
| 68 |
"SearXNG instance returned 403 Forbidden. "
|
|
|
|
| 146 |
url = f"{self._base_url}/config"
|
| 147 |
try:
|
| 148 |
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
| 149 |
+
async with shared_aiohttp_session() as session:
|
| 150 |
+
async with session.get(url, timeout=timeout) as resp:
|
| 151 |
if resp.status != 200:
|
| 152 |
return {
|
| 153 |
"success": False,
|
|
|
|
| 185 |
params = {"q": query}
|
| 186 |
try:
|
| 187 |
timeout = aiohttp.ClientTimeout(total=10)
|
| 188 |
+
async with shared_aiohttp_session() as session:
|
| 189 |
+
async with session.get(url, params=params, timeout=timeout) as resp:
|
| 190 |
if resp.status != 200:
|
| 191 |
return {"success": False, "error": f"HTTP {resp.status} from SearXNG autocompleter"}
|
| 192 |
data = await resp.json()
|
|
|
|
| 206 |
url = f"{self._base_url}/engine_descriptions.json"
|
| 207 |
try:
|
| 208 |
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
| 209 |
+
async with shared_aiohttp_session() as session:
|
| 210 |
+
async with session.get(url, timeout=timeout) as resp:
|
| 211 |
if resp.status != 200:
|
| 212 |
return {"success": False, "error": f"HTTP {resp.status}"}
|
| 213 |
data = await resp.json()
|
|
|
|
| 219 |
url = f"{self._base_url}/stats"
|
| 220 |
try:
|
| 221 |
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
| 222 |
+
async with shared_aiohttp_session() as session:
|
| 223 |
+
async with session.get(url, timeout=timeout) as resp:
|
| 224 |
if resp.status != 200:
|
| 225 |
return {"success": False, "error": f"HTTP {resp.status}"}
|
| 226 |
data = await resp.json()
|
|
|
|
| 232 |
url = f"{self._base_url}/healthz"
|
| 233 |
try:
|
| 234 |
timeout = aiohttp.ClientTimeout(total=10)
|
| 235 |
+
async with shared_aiohttp_session() as session:
|
| 236 |
+
async with session.get(url, timeout=timeout) as resp:
|
| 237 |
return {
|
| 238 |
"success": resp.status == 200,
|
| 239 |
"status": "healthy" if resp.status == 200 else f"unhealthy (HTTP {resp.status})",
|
app/utils/http_utils.py
CHANGED
|
@@ -2,11 +2,13 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import logging
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
-
from typing import List, Optional, Tuple
|
| 7 |
from urllib.parse import unquote, urlparse
|
| 8 |
|
| 9 |
import aiohttp
|
|
|
|
| 10 |
|
| 11 |
from app.config import get_settings
|
| 12 |
|
|
@@ -14,6 +16,64 @@ _logger = logging.getLogger(__name__)
|
|
| 14 |
_settings = get_settings()
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
def create_session_timeout(total_seconds: Optional[float] = None) -> aiohttp.ClientTimeout:
|
| 18 |
"""Create an aiohttp timeout using the configured request timeout."""
|
| 19 |
if total_seconds is None:
|
|
@@ -21,6 +81,63 @@ def create_session_timeout(total_seconds: Optional[float] = None) -> aiohttp.Cli
|
|
| 21 |
return aiohttp.ClientTimeout(total=total_seconds)
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
async def download_url(
|
| 25 |
url: str,
|
| 26 |
*,
|
|
@@ -36,15 +153,16 @@ async def download_url(
|
|
| 36 |
"""
|
| 37 |
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
|
| 38 |
try:
|
| 39 |
-
|
| 40 |
-
|
| 41 |
if resp.status != 200:
|
| 42 |
-
raise
|
| 43 |
|
| 44 |
if max_size_bytes and resp.content_length and resp.content_length > max_size_bytes:
|
| 45 |
-
raise
|
| 46 |
f"Remote file advertises {resp.content_length} bytes, "
|
| 47 |
-
f"limit is {max_size_bytes}"
|
|
|
|
| 48 |
)
|
| 49 |
|
| 50 |
chunks: List[bytes] = []
|
|
@@ -52,12 +170,15 @@ async def download_url(
|
|
| 52 |
async for chunk in resp.content.iter_chunked(chunk_size):
|
| 53 |
total += len(chunk)
|
| 54 |
if max_size_bytes and total > max_size_bytes:
|
| 55 |
-
raise
|
|
|
|
|
|
|
|
|
|
| 56 |
chunks.append(chunk)
|
| 57 |
|
| 58 |
data = b"".join(chunks)
|
| 59 |
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
| 60 |
-
raise
|
| 61 |
|
| 62 |
parsed = urlparse(url)
|
| 63 |
filename = unquote(Path(parsed.path).name) if parsed.path else None
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import logging
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
from pathlib import Path
|
| 7 |
+
from typing import Any, AsyncIterator, Dict, List, Optional, Tuple
|
| 8 |
from urllib.parse import unquote, urlparse
|
| 9 |
|
| 10 |
import aiohttp
|
| 11 |
+
import httpx
|
| 12 |
|
| 13 |
from app.config import get_settings
|
| 14 |
|
|
|
|
| 16 |
_settings = get_settings()
|
| 17 |
|
| 18 |
|
| 19 |
+
class DownloadError(RuntimeError):
|
| 20 |
+
"""Raised when a remote download fails (HTTP error, timeout, or size limit).
|
| 21 |
+
|
| 22 |
+
``is_size_error`` distinguishes size-limit violations so callers can map
|
| 23 |
+
the failure to the appropriate domain exception.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, message: str, *, is_size_error: bool = False) -> None:
|
| 27 |
+
super().__init__(message)
|
| 28 |
+
self.is_size_error = is_size_error
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class SharedAsyncClient:
|
| 32 |
+
"""Thread-safe, lazily-created, connection-pooled ``httpx.AsyncClient``.
|
| 33 |
+
|
| 34 |
+
Several services (GCS, Google Maps, Google OAuth, scheduler) previously
|
| 35 |
+
duplicated the same double-checked-lock initialisation and shutdown logic.
|
| 36 |
+
This helper centralises that pattern so a single instance is created on
|
| 37 |
+
first use and safely closed exactly once on shutdown.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
*,
|
| 43 |
+
timeout: Any = 30.0,
|
| 44 |
+
limits: Optional[httpx.Limits] = None,
|
| 45 |
+
follow_redirects: bool = True,
|
| 46 |
+
) -> None:
|
| 47 |
+
self._client: Optional[httpx.AsyncClient] = None
|
| 48 |
+
self._lock = asyncio.Lock()
|
| 49 |
+
self._timeout = timeout
|
| 50 |
+
self._limits = limits or httpx.Limits(
|
| 51 |
+
max_connections=100,
|
| 52 |
+
max_keepalive_connections=20,
|
| 53 |
+
keepalive_expiry=30,
|
| 54 |
+
)
|
| 55 |
+
self._follow_redirects = follow_redirects
|
| 56 |
+
|
| 57 |
+
async def get(self) -> httpx.AsyncClient:
|
| 58 |
+
"""Return the shared client, creating it lazily on first use."""
|
| 59 |
+
if self._client is None or self._client.is_closed:
|
| 60 |
+
async with self._lock:
|
| 61 |
+
if self._client is None or self._client.is_closed:
|
| 62 |
+
self._client = httpx.AsyncClient(
|
| 63 |
+
timeout=self._timeout,
|
| 64 |
+
follow_redirects=self._follow_redirects,
|
| 65 |
+
limits=self._limits,
|
| 66 |
+
)
|
| 67 |
+
return self._client
|
| 68 |
+
|
| 69 |
+
async def close(self) -> None:
|
| 70 |
+
"""Close the underlying client and release pooled connections."""
|
| 71 |
+
async with self._lock:
|
| 72 |
+
if self._client is not None and not self._client.is_closed:
|
| 73 |
+
await self._client.aclose()
|
| 74 |
+
self._client = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
def create_session_timeout(total_seconds: Optional[float] = None) -> aiohttp.ClientTimeout:
|
| 78 |
"""Create an aiohttp timeout using the configured request timeout."""
|
| 79 |
if total_seconds is None:
|
|
|
|
| 81 |
return aiohttp.ClientTimeout(total=total_seconds)
|
| 82 |
|
| 83 |
|
| 84 |
+
class SharedAiohttpSession:
|
| 85 |
+
"""Reuse a single ``aiohttp.ClientSession`` per event loop.
|
| 86 |
+
|
| 87 |
+
aiohttp sessions are bound to the loop they are created on, so this caches
|
| 88 |
+
one session per event loop. In the long-lived server loop this removes the
|
| 89 |
+
per-request session creation (and the associated TCP/TLS handshake) from hot
|
| 90 |
+
paths such as chat completions and web search.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
def __init__(self) -> None:
|
| 94 |
+
self._sessions: Dict[int, aiohttp.ClientSession] = {}
|
| 95 |
+
|
| 96 |
+
async def get(self, timeout: Optional[aiohttp.ClientTimeout] = None) -> aiohttp.ClientSession:
|
| 97 |
+
loop = asyncio.get_running_loop()
|
| 98 |
+
loop_id = id(loop)
|
| 99 |
+
session = self._sessions.get(loop_id)
|
| 100 |
+
if session is None or session.closed:
|
| 101 |
+
session = aiohttp.ClientSession(timeout=timeout or create_session_timeout())
|
| 102 |
+
self._sessions[loop_id] = session
|
| 103 |
+
return session
|
| 104 |
+
|
| 105 |
+
async def close_all(self) -> None:
|
| 106 |
+
sessions = list(self._sessions.values())
|
| 107 |
+
self._sessions.clear()
|
| 108 |
+
for session in sessions:
|
| 109 |
+
if session is not None and not session.closed:
|
| 110 |
+
await session.close()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
_shared_aiohttp_session = SharedAiohttpSession()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def get_shared_aiohttp_session(
|
| 117 |
+
timeout: Optional[aiohttp.ClientTimeout] = None,
|
| 118 |
+
) -> aiohttp.ClientSession:
|
| 119 |
+
"""Return the loop-bound shared aiohttp session, created lazily on first use."""
|
| 120 |
+
return await _shared_aiohttp_session.get(timeout)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@asynccontextmanager
|
| 124 |
+
async def shared_aiohttp_session(
|
| 125 |
+
timeout: Optional[aiohttp.ClientTimeout] = None,
|
| 126 |
+
) -> AsyncIterator[aiohttp.ClientSession]:
|
| 127 |
+
"""Context manager that yields the shared session but does NOT close it on exit.
|
| 128 |
+
|
| 129 |
+
Drop-in replacement for ``async with aiohttp.ClientSession(...)`` so call
|
| 130 |
+
sites keep the same structure while reusing one pooled session per loop.
|
| 131 |
+
"""
|
| 132 |
+
session = await get_shared_aiohttp_session(timeout)
|
| 133 |
+
yield session
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
async def close_shared_aiohttp_sessions() -> None:
|
| 137 |
+
"""Close all cached sessions. Call once on application shutdown."""
|
| 138 |
+
await _shared_aiohttp_session.close_all()
|
| 139 |
+
|
| 140 |
+
|
| 141 |
async def download_url(
|
| 142 |
url: str,
|
| 143 |
*,
|
|
|
|
| 153 |
"""
|
| 154 |
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
|
| 155 |
try:
|
| 156 |
+
session = await get_shared_aiohttp_session()
|
| 157 |
+
async with session.get(url, timeout=timeout) as resp:
|
| 158 |
if resp.status != 200:
|
| 159 |
+
raise DownloadError(f"HTTP {resp.status} when fetching {url}")
|
| 160 |
|
| 161 |
if max_size_bytes and resp.content_length and resp.content_length > max_size_bytes:
|
| 162 |
+
raise DownloadError(
|
| 163 |
f"Remote file advertises {resp.content_length} bytes, "
|
| 164 |
+
f"limit is {max_size_bytes}",
|
| 165 |
+
is_size_error=True,
|
| 166 |
)
|
| 167 |
|
| 168 |
chunks: List[bytes] = []
|
|
|
|
| 170 |
async for chunk in resp.content.iter_chunked(chunk_size):
|
| 171 |
total += len(chunk)
|
| 172 |
if max_size_bytes and total > max_size_bytes:
|
| 173 |
+
raise DownloadError(
|
| 174 |
+
f"Download exceeded {max_size_bytes} bytes",
|
| 175 |
+
is_size_error=True,
|
| 176 |
+
)
|
| 177 |
chunks.append(chunk)
|
| 178 |
|
| 179 |
data = b"".join(chunks)
|
| 180 |
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
| 181 |
+
raise DownloadError(f"Download failed for {url}: {exc}") from exc
|
| 182 |
|
| 183 |
parsed = urlparse(url)
|
| 184 |
filename = unquote(Path(parsed.path).name) if parsed.path else None
|