쿠팡 크롤 IP 를 '막힐 때까지' 쓰던 방식을 '막히기 전에 교체'로 전환한다. - 요청 예산(LPS_IP_REQUEST_BUDGET, 기본 3): IP당 요청 수가 예산에 닿으면 차단 전에 선제 회전. 실측상 5회 부근 차단 이력이 있어 보수적으로 3회. 선제 교체된 포트는 평판이 깨끗해 로테이션 복귀 시 재사용된다. - 포트 쿨다운(LPS_PORT_COOLDOWN_SEC, 기본 max(sticky,30분)): 차단 감지· 전송오류 포트는 격리하고 _port() 가 건너뛴다. 전 포트 쿨다운이면 만료 임박 포트 사용(가용성 우선). 포트 수는 config 범위에서 동적 산출. - 차단 재시도 소진 시에도 회전 예약 — 불탄 포트로 다음 검색을 하지 않음. - ip_session 테이블 신설: 세션마다 요청 수·성공/차단·종료 사유(budget/ block/proxy_error/window/idle/shutdown)를 기록. bot_detection 과 달리 무사 종료도 남아 예산 상한 튜닝의 원천 데이터가 된다(쿼리 database.md). models.py·migrations·init.sql(lps_db 섹션) 동행 갱신, dev DB 적용 완료. - 테스트 17건 추가(쿨다운·예산 판정·세션 기록·CRUD), 전체 126 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
42 lines
1.9 KiB
Python
42 lines
1.9 KiB
Python
"""IP 세션 종료 이력 CRUD — 세션당 요청 수·종료 사유를 축적(요청 예산 상한 튜닝용)."""
|
|
|
|
from sqlalchemy import text
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.enums import DBType, DBWRType
|
|
|
|
|
|
class IpSessionLog:
|
|
DB = DBType.MAIN.value
|
|
|
|
async def record(self, event: dict):
|
|
"""세션 종료 이벤트 1건 저장. 기록 실패가 검색을 막지 않도록 호출부에서 예외를 삼킨다."""
|
|
sql = text("""
|
|
INSERT INTO ip_session (source, proxy_port, requests, ok_count, blocked_count, elapsed_sec, end_reason)
|
|
VALUES (:source, :proxy_port, :requests, :ok_count, :blocked_count, :elapsed_sec, :end_reason)
|
|
""")
|
|
params = {k: event.get(k) for k in
|
|
("source", "proxy_port", "requests", "ok_count", "blocked_count", "elapsed_sec", "end_reason")}
|
|
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_WRITE.value)
|
|
try:
|
|
await s.execute(sql, params)
|
|
await s.commit()
|
|
except Exception:
|
|
await s.rollback()
|
|
raise
|
|
finally:
|
|
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_WRITE.value)
|
|
|
|
async def recent_stats(self, minutes: int = 60) -> dict:
|
|
"""최근 N분 세션 요약 — 종료 사유별 건수(모니터링·알림용). 예: {"budget": 12, "block": 1}"""
|
|
sql = text("""
|
|
SELECT end_reason, count(*) FROM ip_session
|
|
WHERE created_at > now() - make_interval(mins => :m) GROUP BY end_reason
|
|
""")
|
|
s = await DB_SESSION_MNG.start_session(self.DB, DBWRType.DB_READ.value)
|
|
try:
|
|
rows = (await s.execute(sql, {"m": minutes})).all()
|
|
return {r[0]: int(r[1]) for r in rows}
|
|
finally:
|
|
await DB_SESSION_MNG.end_session(self.DB, DBWRType.DB_READ.value)
|