File size: 6,448 Bytes
e98cc10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
"""
HTTP Client Pool for Medium-MCP

Singleton pattern for httpx.AsyncClient connection pooling.
Addresses Critical Gap #5: Connection Leaks.

Based on httpx official documentation best practices:
- Single AsyncClient instance for connection reuse
- Configurable limits via httpx.Limits
- Proper async cleanup with aclose()
"""

from __future__ import annotations

import logging
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, AsyncGenerator

import httpx

from src.constants import (
    DEFAULT_CONNECT_TIMEOUT,
    DEFAULT_KEEPALIVE_CONNECTIONS,
    DEFAULT_KEEPALIVE_EXPIRY,
    DEFAULT_MAX_CONNECTIONS,
    DEFAULT_READ_TIMEOUT,
    DEFAULT_USER_AGENT,
)


if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)


class HTTPClientPool:
    """
    Singleton HTTP client pool for connection reuse.
    
    This class ensures a single httpx.AsyncClient instance is shared
    across the entire application, providing:
    - Connection pooling (reuse TCP connections)
    - HTTP/2 support
    - Automatic redirect following
    - Proper resource cleanup
    
    Usage:
        pool = HTTPClientPool()
        client = await pool.get_client()
        response = await client.get("https://example.com")
        
        # Or with context manager:
        async with http_session() as client:
            response = await client.get("https://example.com")
    """

    _instance: "HTTPClientPool | None" = None
    _client: httpx.AsyncClient | None = None

    def __new__(cls) -> "HTTPClientPool":
        """Ensure only one instance exists (Singleton pattern)."""
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    async def get_client(
        self,
        *,
        max_connections: int = DEFAULT_MAX_CONNECTIONS,
        max_keepalive_connections: int = DEFAULT_KEEPALIVE_CONNECTIONS,
        keepalive_expiry: float = DEFAULT_KEEPALIVE_EXPIRY,
        connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
        read_timeout: float = DEFAULT_READ_TIMEOUT,
    ) -> httpx.AsyncClient:
        """
        Get or create the shared HTTP client.
        
        Args:
            max_connections: Maximum concurrent connections
            max_keepalive_connections: Maximum idle connections to keep
            keepalive_expiry: How long to keep idle connections (seconds)
            connect_timeout: Timeout for establishing connections
            read_timeout: Timeout for reading responses
            
        Returns:
            Configured httpx.AsyncClient instance
        """
        if self._client is None or self._client.is_closed:
            logger.info("Creating new HTTP client pool")
            
            limits = httpx.Limits(
                max_connections=max_connections,
                max_keepalive_connections=max_keepalive_connections,
                keepalive_expiry=keepalive_expiry,
            )
            
            timeout = httpx.Timeout(
                connect=connect_timeout,
                read=read_timeout,
                write=30.0,
                pool=5.0,
            )
            
            self._client = httpx.AsyncClient(
                limits=limits,
                timeout=timeout,
                http2=True,
                follow_redirects=True,
                headers={
                    "User-Agent": DEFAULT_USER_AGENT,
                    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
                    "Accept-Language": "en-US,en;q=0.9",
                    "Accept-Encoding": "gzip, deflate, br",
                    "Connection": "keep-alive",
                },
            )
            
            logger.debug(
                "HTTP client created with limits: max=%d, keepalive=%d",
                max_connections,
                max_keepalive_connections,
            )
        
        return self._client

    async def close(self) -> None:
        """Close the HTTP client and release resources."""
        if self._client is not None and not self._client.is_closed:
            logger.info("Closing HTTP client pool")
            await self._client.aclose()
            self._client = None

    @property
    def is_connected(self) -> bool:
        """Check if client is active and connected."""
        return self._client is not None and not self._client.is_closed


# Global pool instance
_pool = HTTPClientPool()


async def get_http_client() -> httpx.AsyncClient:
    """
    Get the shared HTTP client instance.
    
    This is the primary way to access the HTTP pool.
    
    Returns:
        Configured httpx.AsyncClient for making requests
        
    Example:
        client = await get_http_client()
        response = await client.get("https://medium.com/...")
    """
    return await _pool.get_client()


async def close_http_pool() -> None:
    """Close the global HTTP pool (call on shutdown)."""
    await _pool.close()


@asynccontextmanager
async def http_session() -> AsyncGenerator[httpx.AsyncClient, None]:
    """
    Context manager for HTTP operations.
    
    Provides a client with automatic error logging.
    Does NOT close the client (it's a shared pool).
    
    Example:
        async with http_session() as client:
            response = await client.get("https://example.com")
            data = response.text
    """
    client = await get_http_client()
    try:
        yield client
    except httpx.HTTPStatusError as e:
        logger.error(
            "HTTP error: status=%d url=%s",
            e.response.status_code,
            str(e.request.url),
        )
        raise
    except httpx.RequestError as e:
        logger.error(
            "HTTP request error: %s url=%s",
            type(e).__name__,
            str(e.request.url) if e.request else "unknown",
        )
        raise


@asynccontextmanager
async def new_http_client(**kwargs: object) -> AsyncGenerator[httpx.AsyncClient, None]:
    """
    Create a new temporary HTTP client (not from pool).
    
    Use this when you need isolated client settings.
    The client is closed when the context exits.
    
    Example:
        async with new_http_client(timeout=60.0) as client:
            response = await client.get("https://slow-api.example.com")
    """
    client = httpx.AsyncClient(**kwargs)  # type: ignore
    try:
        yield client
    finally:
        await client.aclose()