mcp-ticketer 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

@@ -0,0 +1,430 @@
1
+ """Base HTTP client with retry, rate limiting, and error handling."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Dict, Any, Optional, List, Union, Callable
6
+ from datetime import datetime, timedelta
7
+ from enum import Enum
8
+ import time
9
+
10
+ import httpx
11
+ from httpx import AsyncClient, HTTPStatusError, TimeoutException
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class HTTPMethod(str, Enum):
17
+ """HTTP methods."""
18
+ GET = "GET"
19
+ POST = "POST"
20
+ PUT = "PUT"
21
+ PATCH = "PATCH"
22
+ DELETE = "DELETE"
23
+
24
+
25
+ class RetryConfig:
26
+ """Configuration for retry logic."""
27
+
28
+ def __init__(
29
+ self,
30
+ max_retries: int = 3,
31
+ initial_delay: float = 1.0,
32
+ max_delay: float = 60.0,
33
+ exponential_base: float = 2.0,
34
+ jitter: bool = True,
35
+ retry_on_status: Optional[List[int]] = None,
36
+ retry_on_exceptions: Optional[List[type]] = None
37
+ ):
38
+ self.max_retries = max_retries
39
+ self.initial_delay = initial_delay
40
+ self.max_delay = max_delay
41
+ self.exponential_base = exponential_base
42
+ self.jitter = jitter
43
+ self.retry_on_status = retry_on_status or [429, 502, 503, 504]
44
+ self.retry_on_exceptions = retry_on_exceptions or [TimeoutException, httpx.ConnectTimeout, httpx.ReadTimeout]
45
+
46
+
47
+ class RateLimiter:
48
+ """Token bucket rate limiter."""
49
+
50
+ def __init__(self, max_requests: int, time_window: float):
51
+ """Initialize rate limiter.
52
+
53
+ Args:
54
+ max_requests: Maximum number of requests allowed
55
+ time_window: Time window in seconds
56
+ """
57
+ self.max_requests = max_requests
58
+ self.time_window = time_window
59
+ self.tokens = max_requests
60
+ self.last_update = time.time()
61
+ self._lock = asyncio.Lock()
62
+
63
+ async def acquire(self) -> None:
64
+ """Acquire a token for making a request."""
65
+ async with self._lock:
66
+ now = time.time()
67
+
68
+ # Refill tokens based on time passed
69
+ time_passed = now - self.last_update
70
+ self.tokens = min(
71
+ self.max_requests,
72
+ self.tokens + (time_passed / self.time_window) * self.max_requests
73
+ )
74
+ self.last_update = now
75
+
76
+ if self.tokens >= 1:
77
+ self.tokens -= 1
78
+ return
79
+
80
+ # Wait until we can get a token
81
+ wait_time = (1 - self.tokens) * (self.time_window / self.max_requests)
82
+ await asyncio.sleep(wait_time)
83
+ self.tokens = 0
84
+
85
+
86
+ class BaseHTTPClient:
87
+ """Base HTTP client with retry logic, rate limiting, and error handling."""
88
+
89
+ def __init__(
90
+ self,
91
+ base_url: str,
92
+ headers: Optional[Dict[str, str]] = None,
93
+ auth: Optional[Union[httpx.Auth, tuple]] = None,
94
+ timeout: float = 30.0,
95
+ retry_config: Optional[RetryConfig] = None,
96
+ rate_limiter: Optional[RateLimiter] = None,
97
+ verify_ssl: bool = True,
98
+ follow_redirects: bool = True
99
+ ):
100
+ """Initialize HTTP client.
101
+
102
+ Args:
103
+ base_url: Base URL for requests
104
+ headers: Default headers
105
+ auth: Authentication (httpx.Auth or (username, password) tuple)
106
+ timeout: Request timeout in seconds
107
+ retry_config: Retry configuration
108
+ rate_limiter: Rate limiter instance
109
+ verify_ssl: Whether to verify SSL certificates
110
+ follow_redirects: Whether to follow redirects
111
+ """
112
+ self.base_url = base_url.rstrip("/")
113
+ self.default_headers = headers or {}
114
+ self.auth = auth
115
+ self.timeout = timeout
116
+ self.retry_config = retry_config or RetryConfig()
117
+ self.rate_limiter = rate_limiter
118
+ self.verify_ssl = verify_ssl
119
+ self.follow_redirects = follow_redirects
120
+
121
+ # Statistics
122
+ self.stats = {
123
+ "requests_made": 0,
124
+ "retries_performed": 0,
125
+ "rate_limit_waits": 0,
126
+ "errors": 0,
127
+ }
128
+
129
+ self._client: Optional[AsyncClient] = None
130
+
131
+ async def _get_client(self) -> AsyncClient:
132
+ """Get or create HTTP client instance."""
133
+ if self._client is None:
134
+ self._client = AsyncClient(
135
+ base_url=self.base_url,
136
+ headers=self.default_headers,
137
+ auth=self.auth,
138
+ timeout=self.timeout,
139
+ verify=self.verify_ssl,
140
+ follow_redirects=self.follow_redirects
141
+ )
142
+ return self._client
143
+
144
+ async def _calculate_delay(self, attempt: int, response: Optional[httpx.Response] = None) -> float:
145
+ """Calculate delay for retry attempt."""
146
+ if response and response.status_code == 429:
147
+ # Use Retry-After header if available
148
+ retry_after = response.headers.get("Retry-After")
149
+ if retry_after:
150
+ try:
151
+ return float(retry_after)
152
+ except ValueError:
153
+ # Retry-After might be a date
154
+ pass
155
+
156
+ # Exponential backoff
157
+ delay = self.retry_config.initial_delay * (
158
+ self.retry_config.exponential_base ** (attempt - 1)
159
+ )
160
+ delay = min(delay, self.retry_config.max_delay)
161
+
162
+ # Add jitter to prevent thundering herd
163
+ if self.retry_config.jitter:
164
+ import random
165
+ delay *= (0.5 + random.random() * 0.5)
166
+
167
+ return delay
168
+
169
+ def _should_retry(
170
+ self,
171
+ exception: Exception,
172
+ response: Optional[httpx.Response] = None,
173
+ attempt: int = 1
174
+ ) -> bool:
175
+ """Determine if request should be retried."""
176
+ if attempt >= self.retry_config.max_retries:
177
+ return False
178
+
179
+ # Check response status codes
180
+ if response and response.status_code in self.retry_config.retry_on_status:
181
+ return True
182
+
183
+ # Check exception types
184
+ for exc_type in self.retry_config.retry_on_exceptions:
185
+ if isinstance(exception, exc_type):
186
+ return True
187
+
188
+ return False
189
+
190
+ async def request(
191
+ self,
192
+ method: Union[HTTPMethod, str],
193
+ endpoint: str,
194
+ data: Optional[Dict[str, Any]] = None,
195
+ json: Optional[Dict[str, Any]] = None,
196
+ params: Optional[Dict[str, Any]] = None,
197
+ headers: Optional[Dict[str, str]] = None,
198
+ timeout: Optional[float] = None,
199
+ retry_count: int = 0,
200
+ **kwargs
201
+ ) -> httpx.Response:
202
+ """Make HTTP request with retry and rate limiting.
203
+
204
+ Args:
205
+ method: HTTP method
206
+ endpoint: API endpoint (relative to base_url)
207
+ data: Form data
208
+ json: JSON data
209
+ params: Query parameters
210
+ headers: Additional headers
211
+ timeout: Request timeout override
212
+ retry_count: Current retry attempt
213
+ **kwargs: Additional httpx arguments
214
+
215
+ Returns:
216
+ HTTP response
217
+
218
+ Raises:
219
+ HTTPStatusError: On HTTP errors
220
+ TimeoutException: On timeout
221
+ """
222
+ # Rate limiting
223
+ if self.rate_limiter:
224
+ await self.rate_limiter.acquire()
225
+ if retry_count == 0: # Only count first attempts
226
+ self.stats["rate_limit_waits"] += 1
227
+
228
+ # Prepare request
229
+ url = f"{self.base_url}/{endpoint.lstrip('/')}" if endpoint else self.base_url
230
+
231
+ request_headers = self.default_headers.copy()
232
+ if headers:
233
+ request_headers.update(headers)
234
+
235
+ client = await self._get_client()
236
+
237
+ try:
238
+ response = await client.request(
239
+ method=str(method),
240
+ url=url,
241
+ data=data,
242
+ json=json,
243
+ params=params,
244
+ headers=request_headers,
245
+ timeout=timeout or self.timeout,
246
+ **kwargs
247
+ )
248
+
249
+ # Update stats
250
+ self.stats["requests_made"] += 1
251
+ if retry_count > 0:
252
+ self.stats["retries_performed"] += 1
253
+
254
+ response.raise_for_status()
255
+ return response
256
+
257
+ except Exception as e:
258
+ self.stats["errors"] += 1
259
+
260
+ # Check if we should retry
261
+ response = getattr(e, 'response', None)
262
+ if self._should_retry(e, response, retry_count + 1):
263
+ delay = await self._calculate_delay(retry_count + 1, response)
264
+
265
+ logger.warning(
266
+ f"Request failed (attempt {retry_count + 1}/{self.retry_config.max_retries}), "
267
+ f"retrying in {delay:.2f}s: {e}"
268
+ )
269
+
270
+ await asyncio.sleep(delay)
271
+ return await self.request(
272
+ method, endpoint, data, json, params, headers, timeout, retry_count + 1, **kwargs
273
+ )
274
+
275
+ # No more retries, re-raise the exception
276
+ raise
277
+
278
+ async def get(self, endpoint: str, **kwargs) -> httpx.Response:
279
+ """Make GET request."""
280
+ return await self.request(HTTPMethod.GET, endpoint, **kwargs)
281
+
282
+ async def post(self, endpoint: str, **kwargs) -> httpx.Response:
283
+ """Make POST request."""
284
+ return await self.request(HTTPMethod.POST, endpoint, **kwargs)
285
+
286
+ async def put(self, endpoint: str, **kwargs) -> httpx.Response:
287
+ """Make PUT request."""
288
+ return await self.request(HTTPMethod.PUT, endpoint, **kwargs)
289
+
290
+ async def patch(self, endpoint: str, **kwargs) -> httpx.Response:
291
+ """Make PATCH request."""
292
+ return await self.request(HTTPMethod.PATCH, endpoint, **kwargs)
293
+
294
+ async def delete(self, endpoint: str, **kwargs) -> httpx.Response:
295
+ """Make DELETE request."""
296
+ return await self.request(HTTPMethod.DELETE, endpoint, **kwargs)
297
+
298
+ async def get_json(self, endpoint: str, **kwargs) -> Dict[str, Any]:
299
+ """Make GET request and return JSON response."""
300
+ response = await self.get(endpoint, **kwargs)
301
+
302
+ # Handle empty responses
303
+ if response.status_code == 204 or not response.content:
304
+ return {}
305
+
306
+ return response.json()
307
+
308
+ async def post_json(self, endpoint: str, **kwargs) -> Dict[str, Any]:
309
+ """Make POST request and return JSON response."""
310
+ response = await self.post(endpoint, **kwargs)
311
+
312
+ # Handle empty responses
313
+ if response.status_code == 204 or not response.content:
314
+ return {}
315
+
316
+ return response.json()
317
+
318
+ async def put_json(self, endpoint: str, **kwargs) -> Dict[str, Any]:
319
+ """Make PUT request and return JSON response."""
320
+ response = await self.put(endpoint, **kwargs)
321
+
322
+ # Handle empty responses
323
+ if response.status_code == 204 or not response.content:
324
+ return {}
325
+
326
+ return response.json()
327
+
328
+ async def patch_json(self, endpoint: str, **kwargs) -> Dict[str, Any]:
329
+ """Make PATCH request and return JSON response."""
330
+ response = await self.patch(endpoint, **kwargs)
331
+
332
+ # Handle empty responses
333
+ if response.status_code == 204 or not response.content:
334
+ return {}
335
+
336
+ return response.json()
337
+
338
+ def get_stats(self) -> Dict[str, Any]:
339
+ """Get client statistics."""
340
+ return self.stats.copy()
341
+
342
+ def reset_stats(self) -> None:
343
+ """Reset client statistics."""
344
+ self.stats = {
345
+ "requests_made": 0,
346
+ "retries_performed": 0,
347
+ "rate_limit_waits": 0,
348
+ "errors": 0,
349
+ }
350
+
351
+ async def close(self) -> None:
352
+ """Close the HTTP client."""
353
+ if self._client:
354
+ await self._client.aclose()
355
+ self._client = None
356
+
357
+
358
+ class GitHubHTTPClient(BaseHTTPClient):
359
+ """GitHub-specific HTTP client with rate limiting."""
360
+
361
+ def __init__(self, token: str, api_url: str = "https://api.github.com"):
362
+ """Initialize GitHub HTTP client.
363
+
364
+ Args:
365
+ token: GitHub API token
366
+ api_url: GitHub API URL
367
+ """
368
+ headers = {
369
+ "Authorization": f"Bearer {token}",
370
+ "Accept": "application/vnd.github.v3+json",
371
+ "X-GitHub-Api-Version": "2022-11-28",
372
+ }
373
+
374
+ # GitHub rate limiting: 5000 requests per hour for authenticated requests
375
+ rate_limiter = RateLimiter(max_requests=5000, time_window=3600)
376
+
377
+ super().__init__(
378
+ base_url=api_url,
379
+ headers=headers,
380
+ rate_limiter=rate_limiter,
381
+ retry_config=RetryConfig(
382
+ max_retries=3,
383
+ retry_on_status=[429, 502, 503, 504, 522, 524]
384
+ )
385
+ )
386
+
387
+
388
+ class JiraHTTPClient(BaseHTTPClient):
389
+ """JIRA-specific HTTP client with authentication and retry logic."""
390
+
391
+ def __init__(
392
+ self,
393
+ email: str,
394
+ api_token: str,
395
+ server_url: str,
396
+ is_cloud: bool = True,
397
+ verify_ssl: bool = True
398
+ ):
399
+ """Initialize JIRA HTTP client.
400
+
401
+ Args:
402
+ email: User email
403
+ api_token: API token
404
+ server_url: JIRA server URL
405
+ is_cloud: Whether this is JIRA Cloud
406
+ verify_ssl: Whether to verify SSL certificates
407
+ """
408
+ api_base = f"{server_url}/rest/api/3" if is_cloud else f"{server_url}/rest/api/2"
409
+
410
+ headers = {
411
+ "Accept": "application/json",
412
+ "Content-Type": "application/json"
413
+ }
414
+
415
+ auth = httpx.BasicAuth(email, api_token)
416
+
417
+ # JIRA rate limiting varies by plan, using conservative limits
418
+ rate_limiter = RateLimiter(max_requests=100, time_window=60)
419
+
420
+ super().__init__(
421
+ base_url=api_base,
422
+ headers=headers,
423
+ auth=auth,
424
+ rate_limiter=rate_limiter,
425
+ verify_ssl=verify_ssl,
426
+ retry_config=RetryConfig(
427
+ max_retries=3,
428
+ retry_on_status=[429, 502, 503, 504]
429
+ )
430
+ )