File size: 10,656 Bytes
48ae4e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
"""
HTTP API Client with Retry Logic and Timeout Handling
Provides robust HTTP client for API requests
"""

import aiohttp
import asyncio
from typing import Dict, Optional, Tuple, Any
from datetime import datetime
import time
from utils.logger import setup_logger

logger = setup_logger("api_client")


class APIClientError(Exception):
    """Base exception for API client errors"""
    pass


class TimeoutError(APIClientError):
    """Timeout exception"""
    pass


class RateLimitError(APIClientError):
    """Rate limit exception"""
    def __init__(self, message: str, retry_after: Optional[int] = None):
        super().__init__(message)
        self.retry_after = retry_after


class AuthenticationError(APIClientError):
    """Authentication exception"""
    pass


class ServerError(APIClientError):
    """Server error exception"""
    pass


class APIClient:
    """
    HTTP client with retry logic, timeout handling, and connection pooling
    """

    def __init__(
        self,
        default_timeout: int = 10,
        max_connections: int = 100,
        retry_attempts: int = 3,
        retry_delay: float = 1.0
    ):
        """
        Initialize API client

        Args:
            default_timeout: Default timeout in seconds
            max_connections: Maximum concurrent connections
            retry_attempts: Maximum number of retry attempts
            retry_delay: Initial retry delay in seconds (exponential backoff)
        """
        self.default_timeout = default_timeout
        self.max_connections = max_connections
        self.retry_attempts = retry_attempts
        self.retry_delay = retry_delay

        # Connection pool configuration (lazy initialization)
        self._connector = None

        # Default headers
        self.default_headers = {
            "User-Agent": "CryptoAPIMonitor/1.0",
            "Accept": "application/json"
        }

    @property
    def connector(self):
        """Lazy initialize connector when first accessed"""
        if self._connector is None:
            self._connector = aiohttp.TCPConnector(
                limit=self.max_connections,
                limit_per_host=10,
                ttl_dns_cache=300,
                enable_cleanup_closed=True
            )
        return self._connector

    async def _make_request(
        self,
        method: str,
        url: str,
        headers: Optional[Dict] = None,
        params: Optional[Dict] = None,
        timeout: Optional[int] = None,
        **kwargs
    ) -> Tuple[int, Any, float, Optional[str]]:
        """
        Make HTTP request with error handling

        Returns:
            Tuple of (status_code, response_data, response_time_ms, error_message)
        """
        merged_headers = {**self.default_headers}
        if headers:
            merged_headers.update(headers)

        timeout_seconds = timeout or self.default_timeout
        timeout_config = aiohttp.ClientTimeout(total=timeout_seconds)

        start_time = time.time()
        error_message = None

        try:
            async with aiohttp.ClientSession(
                connector=self.connector,
                timeout=timeout_config
            ) as session:
                async with session.request(
                    method,
                    url,
                    headers=merged_headers,
                    params=params,
                    ssl=True,  # Enable SSL verification
                    **kwargs
                ) as response:
                    response_time_ms = (time.time() - start_time) * 1000
                    status_code = response.status

                    # Try to parse JSON response
                    try:
                        data = await response.json()
                    except:
                        # If not JSON, get text
                        data = await response.text()

                    return status_code, data, response_time_ms, error_message

        except asyncio.TimeoutError:
            response_time_ms = (time.time() - start_time) * 1000
            error_message = f"Request timeout after {timeout_seconds}s"
            return 0, None, response_time_ms, error_message

        except aiohttp.ClientError as e:
            response_time_ms = (time.time() - start_time) * 1000
            error_message = f"Client error: {str(e)}"
            return 0, None, response_time_ms, error_message

        except Exception as e:
            response_time_ms = (time.time() - start_time) * 1000
            error_message = f"Unexpected error: {str(e)}"
            return 0, None, response_time_ms, error_message

    async def request(
        self,
        method: str,
        url: str,
        headers: Optional[Dict] = None,
        params: Optional[Dict] = None,
        timeout: Optional[int] = None,
        retry: bool = True,
        **kwargs
    ) -> Dict[str, Any]:
        """
        Make HTTP request with retry logic

        Args:
            method: HTTP method (GET, POST, etc.)
            url: Request URL
            headers: Optional headers
            params: Optional query parameters
            timeout: Optional timeout override
            retry: Enable retry logic

        Returns:
            Dict with keys: success, status_code, data, response_time_ms, error_type, error_message
        """
        attempt = 0
        last_error = None
        current_timeout = timeout or self.default_timeout

        while attempt < (self.retry_attempts if retry else 1):
            attempt += 1

            status_code, data, response_time_ms, error_message = await self._make_request(
                method, url, headers, params, current_timeout, **kwargs
            )

            # Success
            if status_code == 200:
                return {
                    "success": True,
                    "status_code": status_code,
                    "data": data,
                    "response_time_ms": response_time_ms,
                    "error_type": None,
                    "error_message": None,
                    "retry_count": attempt - 1
                }

            # Rate limit - extract Retry-After header
            elif status_code == 429:
                last_error = "rate_limit"
                # Try to get retry-after from response
                retry_after = 60  # Default to 60 seconds

                if not retry or attempt >= self.retry_attempts:
                    return {
                        "success": False,
                        "status_code": status_code,
                        "data": None,
                        "response_time_ms": response_time_ms,
                        "error_type": "rate_limit",
                        "error_message": f"Rate limit exceeded. Retry after {retry_after}s",
                        "retry_count": attempt - 1,
                        "retry_after": retry_after
                    }

                # Wait and retry
                await asyncio.sleep(retry_after + 10)  # Add 10s buffer
                continue

            # Authentication error - don't retry
            elif status_code in [401, 403]:
                return {
                    "success": False,
                    "status_code": status_code,
                    "data": None,
                    "response_time_ms": response_time_ms,
                    "error_type": "authentication",
                    "error_message": f"Authentication failed: HTTP {status_code}",
                    "retry_count": attempt - 1
                }

            # Server error - retry with exponential backoff
            elif status_code >= 500:
                last_error = "server_error"

                if not retry or attempt >= self.retry_attempts:
                    return {
                        "success": False,
                        "status_code": status_code,
                        "data": None,
                        "response_time_ms": response_time_ms,
                        "error_type": "server_error",
                        "error_message": f"Server error: HTTP {status_code}",
                        "retry_count": attempt - 1
                    }

                # Exponential backoff: 1min, 2min, 4min
                delay = self.retry_delay * 60 * (2 ** (attempt - 1))
                await asyncio.sleep(min(delay, 240))  # Max 4 minutes
                continue

            # Timeout - retry with increased timeout
            elif error_message and "timeout" in error_message.lower():
                last_error = "timeout"

                if not retry or attempt >= self.retry_attempts:
                    return {
                        "success": False,
                        "status_code": 0,
                        "data": None,
                        "response_time_ms": response_time_ms,
                        "error_type": "timeout",
                        "error_message": error_message,
                        "retry_count": attempt - 1
                    }

                # Increase timeout by 50%
                current_timeout = int(current_timeout * 1.5)
                await asyncio.sleep(self.retry_delay)
                continue

            # Other errors
            else:
                return {
                    "success": False,
                    "status_code": status_code or 0,
                    "data": data,
                    "response_time_ms": response_time_ms,
                    "error_type": "network_error" if status_code == 0 else "http_error",
                    "error_message": error_message or f"HTTP {status_code}",
                    "retry_count": attempt - 1
                }

        # All retries exhausted
        return {
            "success": False,
            "status_code": 0,
            "data": None,
            "response_time_ms": 0,
            "error_type": last_error or "unknown",
            "error_message": "All retry attempts exhausted",
            "retry_count": self.retry_attempts
        }

    async def get(self, url: str, **kwargs) -> Dict[str, Any]:
        """GET request"""
        return await self.request("GET", url, **kwargs)

    async def post(self, url: str, **kwargs) -> Dict[str, Any]:
        """POST request"""
        return await self.request("POST", url, **kwargs)

    async def close(self):
        """Close connector"""
        if self.connector:
            await self.connector.close()


# Global client instance
_client = None


def get_client() -> APIClient:
    """Get global API client instance"""
    global _client
    if _client is None:
        _client = APIClient()
    return _client