# =================================================================== # core/http_client.py # FGTools HTTP 클라이언트 유틸리티 모듈 # =================================================================== # 자동 재시도, 타임아웃, 연결 풀링을 지원하는 HTTP 클라이언트입니다. # requests 라이브러리 기반으로 안정적인 API 호출을 제공합니다. # =================================================================== """ HTTP 클라이언트 유틸리티 모듈 자동 재시도, 지수 백오프, 타임아웃 설정을 지원하는 안정적인 HTTP 클라이언트를 제공합니다. 사용 예시: from core.http_client import create_retry_session, get_json # 재시도 세션 사용 session = create_retry_session(retries=3) response = session.get("https://api.example.com/data") # 간편 JSON 요청 data = get_json("https://api.example.com/data", params={"key": "value"}) """ import time import logging from typing import Any, Dict, Optional, Callable from functools import wraps import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from .logging_utils import get_logger logger = get_logger(__name__) # 기본 설정 상수 DEFAULT_TIMEOUT = 30 DEFAULT_RETRIES = 3 DEFAULT_BACKOFF_FACTOR = 0.5 RETRY_STATUS_CODES = [429, 500, 502, 503, 504] def create_retry_session( retries: int = DEFAULT_RETRIES, backoff_factor: float = DEFAULT_BACKOFF_FACTOR, status_forcelist: Optional[list] = None, timeout: int = DEFAULT_TIMEOUT ) -> requests.Session: """ 자동 재시도를 지원하는 requests 세션 생성 실패한 요청을 지수 백오프 방식으로 자동 재시도합니다. 연결 풀링을 통해 다수의 요청을 효율적으로 처리합니다. Args: retries: 최대 재시도 횟수 backoff_factor: 재시도 간격 배수 (0.5면 0.5, 1, 2초...) status_forcelist: 재시도할 HTTP 상태 코드 목록 timeout: 요청 타임아웃 (초) Returns: 설정된 requests.Session 인스턴스 사용 예시: session = create_retry_session(retries=5, timeout=60) response = session.get("https://api.example.com/data") """ if status_forcelist is None: status_forcelist = RETRY_STATUS_CODES session = requests.Session() # 재시도 전략 설정 retry_strategy = Retry( total=retries, read=retries, connect=retries, backoff_factor=backoff_factor, status_forcelist=status_forcelist, allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"], raise_on_status=False ) # HTTP/HTTPS 어댑터에 재시도 전략 적용 adapter = HTTPAdapter( max_retries=retry_strategy, pool_connections=10, pool_maxsize=10 ) session.mount("http://", adapter) session.mount("https://", adapter) # 기본 타임아웃 설정 (request 메서드 래핑) original_request = session.request def request_with_timeout(*args, **kwargs): kwargs.setdefault('timeout', timeout) return original_request(*args, **kwargs) session.request = request_with_timeout return session def retry_on_exception( max_retries: int = DEFAULT_RETRIES, delay: float = 1.0, backoff: float = 2.0, exceptions: tuple = (Exception,) ) -> Callable: """ 함수 실행 실패 시 재시도하는 데코레이터 지정된 예외가 발생하면 지수 백오프 방식으로 재시도합니다. Args: max_retries: 최대 재시도 횟수 delay: 초기 재시도 지연 시간 (초) backoff: 재시도마다 지연 시간 배수 exceptions: 재시도할 예외 타입들 Returns: 데코레이터 함수 사용 예시: @retry_on_exception(max_retries=3, delay=1.0) def fetch_data(): return requests.get("https://api.example.com").json() """ def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs) -> Any: last_exception = None for attempt in range(max_retries): try: return func(*args, **kwargs) except exceptions as e: last_exception = e if attempt < max_retries - 1: wait_time = delay * (backoff ** attempt) logger.warning( f"{func.__name__} 재시도 {attempt + 1}/{max_retries} " f"({wait_time:.1f}초 대기): {e}" ) time.sleep(wait_time) else: logger.error(f"{func.__name__} 모든 재시도 실패: {e}") raise last_exception return wrapper return decorator def get_json( url: str, params: Optional[Dict] = None, headers: Optional[Dict] = None, timeout: int = DEFAULT_TIMEOUT, retries: int = DEFAULT_RETRIES ) -> Optional[Dict]: """ JSON API 요청 간편 함수 GET 요청을 보내고 JSON 응답을 파싱하여 반환합니다. 실패 시 None을 반환하고 에러를 로깅합니다. Args: url: 요청 URL params: 쿼리 파라미터 headers: 요청 헤더 timeout: 타임아웃 (초) retries: 최대 재시도 횟수 Returns: JSON 응답 딕셔너리 또는 None """ session = create_retry_session(retries=retries, timeout=timeout) try: response = session.get(url, params=params, headers=headers) response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: logger.error(f"HTTP 요청 실패: {url} - {e}") return None except ValueError as e: logger.error(f"JSON 파싱 실패: {url} - {e}") return None finally: session.close() def post_json( url: str, data: Optional[Dict] = None, json_data: Optional[Dict] = None, headers: Optional[Dict] = None, timeout: int = DEFAULT_TIMEOUT, retries: int = DEFAULT_RETRIES ) -> Optional[Dict]: """ JSON POST 요청 간편 함수 POST 요청을 보내고 JSON 응답을 파싱하여 반환합니다. Args: url: 요청 URL data: form 데이터 json_data: JSON 데이터 headers: 요청 헤더 timeout: 타임아웃 (초) retries: 최대 재시도 횟수 Returns: JSON 응답 딕셔너리 또는 None """ session = create_retry_session(retries=retries, timeout=timeout) try: response = session.post(url, data=data, json=json_data, headers=headers) response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: logger.error(f"HTTP POST 요청 실패: {url} - {e}") return None except ValueError as e: logger.error(f"JSON 파싱 실패: {url} - {e}") return None finally: session.close() class APIClient: """ API 클라이언트 기본 클래스 특정 API 서비스에 대한 클라이언트를 구현할 때 상속하여 사용합니다. 공통적인 인증, 베이스 URL, 에러 처리 로직을 제공합니다. 사용 예시: class WeatherAPIClient(APIClient): def __init__(self, api_key): super().__init__("https://api.weather.go.kr") self.api_key = api_key def get_forecast(self, city): return self.get("/forecast", params={"city": city, "key": self.api_key}) """ def __init__( self, base_url: str, default_headers: Optional[Dict] = None, timeout: int = DEFAULT_TIMEOUT, retries: int = DEFAULT_RETRIES ): """ Args: base_url: API 기본 URL default_headers: 기본 요청 헤더 timeout: 기본 타임아웃 retries: 기본 재시도 횟수 """ self.base_url = base_url.rstrip('/') self.default_headers = default_headers or {} self.timeout = timeout self.session = create_retry_session(retries=retries, timeout=timeout) def _build_url(self, endpoint: str) -> str: """엔드포인트를 포함한 전체 URL 생성""" if endpoint.startswith('http'): return endpoint return f"{self.base_url}/{endpoint.lstrip('/')}" def _merge_headers(self, headers: Optional[Dict]) -> Dict: """기본 헤더와 요청 헤더 병합""" merged = self.default_headers.copy() if headers: merged.update(headers) return merged def get( self, endpoint: str, params: Optional[Dict] = None, headers: Optional[Dict] = None ) -> Optional[requests.Response]: """GET 요청""" url = self._build_url(endpoint) merged_headers = self._merge_headers(headers) try: response = self.session.get(url, params=params, headers=merged_headers) return response except requests.exceptions.RequestException as e: logger.error(f"GET 요청 실패: {url} - {e}") return None def post( self, endpoint: str, data: Optional[Dict] = None, json_data: Optional[Dict] = None, headers: Optional[Dict] = None ) -> Optional[requests.Response]: """POST 요청""" url = self._build_url(endpoint) merged_headers = self._merge_headers(headers) try: response = self.session.post( url, data=data, json=json_data, headers=merged_headers ) return response except requests.exceptions.RequestException as e: logger.error(f"POST 요청 실패: {url} - {e}") return None def close(self): """세션 종료""" self.session.close() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() return False