fix: 대규모 Alpaca 백필 시 uvicorn 이벤트 루프 포화 방지

동시 Alpaca intraday 요청이 50개 이상 쌓이면 BaseHTTPMiddleware의
task 스케줄링 오버헤드로 이벤트 루프가 응답불능 상태가 되는 현상 수정.

- alpaca.py: _INTRADAY_SEMAPHORE(10) 추가 — /intraday, /intraday/today
  양쪽 핸들러를 감쌈. 11번째 이후 요청은 세마포어 대기(비용 없음)
- docker-compose.yml: --limit-concurrency 100 추가 — 100개 초과 시
  uvicorn이 503 반환, health 엔드포인트 보호

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent dd3e38fbf5
commit e25f7295a6

@ -2,6 +2,7 @@
Alpaca Market Data endpoints standalone price data via Alpaca API Alpaca Market Data endpoints standalone price data via Alpaca API
""" """
import asyncio
from datetime import date, datetime, timezone, timedelta from datetime import date, datetime, timezone, timedelta
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -11,6 +12,11 @@ from fastapi import APIRouter, HTTPException, Query
_ET = ZoneInfo("America/New_York") _ET = ZoneInfo("America/New_York")
_MARKET_CLOSE_HOUR = 16 # 4:00 PM ET _MARKET_CLOSE_HOUR = 16 # 4:00 PM ET
# Limit concurrent Alpaca intraday processing to prevent event-loop saturation
# under bulk backfill workloads. Callers beyond this limit wait on the semaphore
# (cheap asyncio wait) rather than flooding httpx connections and DB sessions.
_INTRADAY_SEMAPHORE = asyncio.Semaphore(10)
def _market_closed_for(d: date) -> bool: def _market_closed_for(d: date) -> bool:
"""Return True if the US equity market session for date d has ended.""" """Return True if the US equity market session for date d has ended."""
@ -120,21 +126,22 @@ async def get_alpaca_intraday_multi(
start_dt = datetime.combine(_start, datetime.min.time()).replace(tzinfo=timezone.utc) start_dt = datetime.combine(_start, datetime.min.time()).replace(tzinfo=timezone.utc)
end_dt = datetime.combine(_end, datetime.max.time()).replace(tzinfo=timezone.utc) end_dt = datetime.combine(_end, datetime.max.time()).replace(tzinfo=timezone.utc)
try: async with _INTRADAY_SEMAPHORE:
data = await svc.get_or_fetch_multi_bars( try:
symbols, start_dt, end_dt, interval, force_refresh, feed="sip" data = await svc.get_or_fetch_multi_bars(
) symbols, start_dt, end_dt, interval, force_refresh, feed="sip"
except Exception as e:
err = str(e)
detail = f"Alpaca API error: {err}"
if "502" in err or "Bad Gateway" in err:
detail = (
f"Alpaca 502 Bad Gateway — 요청당 심볼 수 초과 가능성. "
f"내부 배치 크기: 100개/요청. 원인: {err}"
) )
raise HTTPException(status_code=502, detail=detail) except Exception as e:
finally: err = str(e)
await svc.client.close() detail = f"Alpaca API error: {err}"
if "502" in err or "Bad Gateway" in err:
detail = (
f"Alpaca 502 Bad Gateway — 요청당 심볼 수 초과 가능성. "
f"내부 배치 크기: 100개/요청. 원인: {err}"
)
raise HTTPException(status_code=502, detail=detail)
finally:
await svc.client.close()
bars = { bars = {
ticker: [ ticker: [
@ -193,21 +200,22 @@ async def get_alpaca_intraday_today(
start_dt = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc) start_dt = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
end_dt = datetime.combine(today, datetime.max.time()).replace(tzinfo=timezone.utc) end_dt = datetime.combine(today, datetime.max.time()).replace(tzinfo=timezone.utc)
try: async with _INTRADAY_SEMAPHORE:
data = await svc.get_or_fetch_multi_bars( try:
symbols, start_dt, end_dt, interval, force_refresh=True, feed="iex" data = await svc.get_or_fetch_multi_bars(
) symbols, start_dt, end_dt, interval, force_refresh=True, feed="iex"
except Exception as e:
err = str(e)
detail = f"Alpaca API error: {err}"
if "502" in err or "Bad Gateway" in err:
detail = (
f"Alpaca 502 Bad Gateway — 요청당 심볼 수 초과 가능성. "
f"내부 배치 크기: 100개/요청. 원인: {err}"
) )
raise HTTPException(status_code=502, detail=detail) except Exception as e:
finally: err = str(e)
await svc.client.close() detail = f"Alpaca API error: {err}"
if "502" in err or "Bad Gateway" in err:
detail = (
f"Alpaca 502 Bad Gateway — 요청당 심볼 수 초과 가능성. "
f"내부 배치 크기: 100개/요청. 원인: {err}"
)
raise HTTPException(status_code=502, detail=detail)
finally:
await svc.client.close()
bars = { bars = {
ticker: [ ticker: [

@ -66,7 +66,7 @@ services:
- ./yfinance_plus:/app/yfinance_plus # Mount yfinance_plus for development - ./yfinance_plus:/app/yfinance_plus # Mount yfinance_plus for development
- ./data:/app/data # For data files - ./data:/app/data # For data files
restart: unless-stopped restart: unless-stopped
command: ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "18000", "--reload"] command: ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "18000", "--reload", "--limit-concurrency", "100"]
# Frontend Application # Frontend Application
frontend: frontend:

Loading…
Cancel
Save