validops-east-1 commited on
Commit
722c296
·
1 Parent(s): 8f9855d

feat: optimization

Browse files
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 create_session_timeout
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
- timeout = create_session_timeout()
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
- timeout = create_session_timeout()
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
- timeout = create_session_timeout()
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
- timeout = create_session_timeout()
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
- timeout = create_session_timeout()
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
- timeout = create_session_timeout()
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
- timeout = create_session_timeout()
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
- timeout = create_session_timeout()
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
- data = await _download_file(source)
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
- timeout = aiohttp.ClientTimeout(total=config.timeout_seconds)
 
 
 
397
  try:
398
- async with aiohttp.ClientSession(timeout=timeout) as session:
399
- async with session.get(url) as resp:
400
- if resp.status != 200:
401
- raise FileDownloadError(
402
- f"HTTP {resp.status} when fetching {url}"
403
- )
404
-
405
- content_length = resp.content_length
406
- if content_length and content_length > config.max_file_size_bytes:
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._client: Optional[httpx.AsyncClient] = None
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
- if self._client is None or self._client.is_closed:
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
- async with self._client_lock:
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._client: Optional[httpx.AsyncClient] = None
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
- if self._client is None or self._client.is_closed:
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
- async with self._client_lock:
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._client: Optional[httpx.AsyncClient] = None
96
- self._client_lock = asyncio.Lock()
 
 
97
  self._jwks = GoogleJWKSCache()
98
 
99
  # ------------------------------------------------------------------
@@ -101,24 +104,10 @@ class GoogleOAuthService:
101
  # ------------------------------------------------------------------
102
 
103
  async def _get_client(self) -> httpx.AsyncClient:
104
- if self._client is None or self._client.is_closed:
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
- async with self._client_lock:
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._client: httpx.AsyncClient | None = None
333
- self._lock = asyncio.Lock()
 
 
 
 
 
 
334
 
335
  async def _get_client(self) -> httpx.AsyncClient:
336
- if self._client is None or self._client.is_closed:
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
- if self._client and not self._client.is_closed:
352
- await self._client.aclose()
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
- client = get_supabase_client()
670
- if client:
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
- client = get_supabase_client()
741
- if client:
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
- client = get_supabase_client()
900
- if client is None:
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
- client = get_supabase_client()
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
- client = get_supabase_client()
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
- client = get_supabase_client()
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
- client = get_supabase_client()
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
- client = get_supabase_client()
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
- client = get_supabase_client()
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
- client = get_supabase_client()
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
- client = get_supabase_client()
1238
- if client is None:
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
- client = get_supabase_client()
1245
- if client is None:
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
- client = get_supabase_client()
1257
- if client is None:
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
- client = get_supabase_client()
1269
- if client is None:
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
- client = get_supabase_client()
1276
- if client is None:
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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 = asyncio.run(self._get_client().find_one("url_shortener_links", "short_code", short_code))
433
  if link:
434
  current_clicks = (link.get("click_count") or 0) + 1
435
- asyncio.run(self._get_client().update(
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 = asyncio.run(client.select("url_shortener_clicks", eq=("short_code", short_code)))
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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
- import asyncio
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 = asyncio.run(client.select("url_shortener_links", eq=("campaign_id", campaign_id)))
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 = asyncio.run(client.select("url_shortener_clicks", eq=("short_code", code)))
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 = asyncio.run(client.select("url_shortener_links", eq=("owner_id", owner_id)))
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 = asyncio.run(client.select(
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 aiohttp.ClientSession(timeout=timeout) as session:
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 aiohttp.ClientSession(timeout=timeout) as session:
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 aiohttp.ClientSession(timeout=timeout) as session:
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 aiohttp.ClientSession(timeout=timeout) as session:
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 aiohttp.ClientSession(timeout=timeout) as session:
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 aiohttp.ClientSession(timeout=timeout) as session:
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
- async with aiohttp.ClientSession(timeout=timeout) as session:
40
- async with session.get(url) as resp:
41
  if resp.status != 200:
42
- raise RuntimeError(f"HTTP {resp.status} when fetching {url}")
43
 
44
  if max_size_bytes and resp.content_length and resp.content_length > max_size_bytes:
45
- raise RuntimeError(
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 RuntimeError(f"Download exceeded {max_size_bytes} bytes")
 
 
 
56
  chunks.append(chunk)
57
 
58
  data = b"".join(chunks)
59
  except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
60
- raise RuntimeError(f"Download failed for {url}: {exc}") from exc
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