firefeed-core 1.0.0__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.
- firefeed_core/__init__.py +52 -0
- firefeed_core/api_client/__init__.py +17 -0
- firefeed_core/api_client/circuit_breaker.py +141 -0
- firefeed_core/api_client/client.py +434 -0
- firefeed_core/api_client/rate_limiter.py +269 -0
- firefeed_core/api_client/retry.py +183 -0
- firefeed_core/auth/__init__.py +16 -0
- firefeed_core/auth/permissions.py +243 -0
- firefeed_core/auth/token_manager.py +390 -0
- firefeed_core/auth/token_validator.py +241 -0
- firefeed_core/config/__init__.py +30 -0
- firefeed_core/config/base_config.py +375 -0
- firefeed_core/config/logging_config.py +21 -0
- firefeed_core/config/redis_config.py +210 -0
- firefeed_core/config/redis_utils.py +224 -0
- firefeed_core/config/services_config.py +251 -0
- firefeed_core/config/settings.py +139 -0
- firefeed_core/config/validation.py +414 -0
- firefeed_core/di_container.py +108 -0
- firefeed_core/email_service/__init__.py +0 -0
- firefeed_core/email_service/sender.py +825 -0
- firefeed_core/exceptions/__init__.py +161 -0
- firefeed_core/exceptions/api_exceptions.py +234 -0
- firefeed_core/exceptions/base_exceptions.py +139 -0
- firefeed_core/exceptions/database_exceptions.py +25 -0
- firefeed_core/exceptions/rss_exceptions.py +59 -0
- firefeed_core/exceptions/service_exceptions.py +236 -0
- firefeed_core/interfaces/__init__.py +11 -0
- firefeed_core/interfaces/base_interfaces.py +225 -0
- firefeed_core/interfaces/core_interfaces.py +65 -0
- firefeed_core/interfaces/email_interfaces.py +36 -0
- firefeed_core/interfaces/media_interfaces.py +36 -0
- firefeed_core/interfaces/repository_interfaces.py +212 -0
- firefeed_core/interfaces/rss_interfaces.py +171 -0
- firefeed_core/interfaces/telegram_interfaces.py +208 -0
- firefeed_core/interfaces/text_analysis_interfaces.py +36 -0
- firefeed_core/interfaces/translation_interfaces.py +116 -0
- firefeed_core/interfaces/user_interfaces.py +207 -0
- firefeed_core/models/__init__.py +11 -0
- firefeed_core/models/api_key_models.py +37 -0
- firefeed_core/models/base_models.py +1172 -0
- firefeed_core/models/category_models.py +8 -0
- firefeed_core/models/error_models.py +7 -0
- firefeed_core/models/media_models.py +7 -0
- firefeed_core/models/rss_models.py +96 -0
- firefeed_core/models/source_models.py +9 -0
- firefeed_core/models/telegram_models.py +18 -0
- firefeed_core/models/translation_models.py +14 -0
- firefeed_core/models/user_models.py +126 -0
- firefeed_core/services/email_service.py +825 -0
- firefeed_core/services/rss_service.py +185 -0
- firefeed_core/services/translation_service.py +602 -0
- firefeed_core/tests/__init__.py +3 -0
- firefeed_core/tests/integration/__init__.py +0 -0
- firefeed_core/tests/integration/test_api_client.py +283 -0
- firefeed_core/tests/integration/test_email_service.py +275 -0
- firefeed_core/tests/unit/__init__.py +0 -0
- firefeed_core/tests/unit/api/__init__.py +0 -0
- firefeed_core/tests/unit/config/__init__.py +0 -0
- firefeed_core/tests/unit/core/__init__.py +0 -0
- firefeed_core/tests/unit/database/__init__.py +0 -0
- firefeed_core/tests/unit/email/__init__.py +1 -0
- firefeed_core/tests/unit/email/test_email_sender.py +296 -0
- firefeed_core/tests/unit/exceptions/__init__.py +0 -0
- firefeed_core/tests/unit/models/__init__.py +0 -0
- firefeed_core/tests/unit/repositories/__init__.py +0 -0
- firefeed_core/tests/unit/services/__init__.py +0 -0
- firefeed_core/tests/unit/utils/__init__.py +0 -0
- firefeed_core/utils/__init__.py +355 -0
- firefeed_core/utils/api.py +67 -0
- firefeed_core/utils/async_utils.py +415 -0
- firefeed_core/utils/cache.py +74 -0
- firefeed_core/utils/cache_utils.py +404 -0
- firefeed_core/utils/cleanup.py +80 -0
- firefeed_core/utils/file_utils.py +421 -0
- firefeed_core/utils/formatting_utils.py +397 -0
- firefeed_core/utils/image.py +185 -0
- firefeed_core/utils/image_utils.py +333 -0
- firefeed_core/utils/network_utils.py +430 -0
- firefeed_core/utils/retry.py +72 -0
- firefeed_core/utils/retry_utils.py +350 -0
- firefeed_core/utils/security_utils.py +492 -0
- firefeed_core/utils/text.py +90 -0
- firefeed_core/utils/text_utils.py +431 -0
- firefeed_core/utils/time_utils.py +363 -0
- firefeed_core/utils/validation_utils.py +457 -0
- firefeed_core/utils/video.py +126 -0
- firefeed_core-1.0.0.dist-info/METADATA +276 -0
- firefeed_core-1.0.0.dist-info/RECORD +92 -0
- firefeed_core-1.0.0.dist-info/WHEEL +5 -0
- firefeed_core-1.0.0.dist-info/licenses/LICENSE +45 -0
- firefeed_core-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FireFeed Core - Core utilities and models for FireFeed microservices
|
|
3
|
+
|
|
4
|
+
This package contains shared components used across all FireFeed microservices:
|
|
5
|
+
- Common Pydantic models
|
|
6
|
+
- Exceptions and error handling
|
|
7
|
+
- API clients for inter-service communication
|
|
8
|
+
- Authentication and authorization utilities
|
|
9
|
+
- Configuration management
|
|
10
|
+
- Interface definitions
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from . import models
|
|
14
|
+
from . import exceptions
|
|
15
|
+
from . import config
|
|
16
|
+
from . import auth
|
|
17
|
+
from . import api_client
|
|
18
|
+
from . import interfaces
|
|
19
|
+
from . import utils
|
|
20
|
+
|
|
21
|
+
# Version
|
|
22
|
+
__version__ = "1.0.0"
|
|
23
|
+
|
|
24
|
+
# Main exports
|
|
25
|
+
from .api_client import APIClient
|
|
26
|
+
from .auth.token_manager import ServiceTokenManager
|
|
27
|
+
from .config.settings import FireFeedSettings
|
|
28
|
+
from .exceptions import FireFeedException, APIException, AuthenticationException
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Version
|
|
32
|
+
"__version__",
|
|
33
|
+
|
|
34
|
+
# Core components
|
|
35
|
+
"APIClient",
|
|
36
|
+
"ServiceTokenManager",
|
|
37
|
+
"FireFeedSettings",
|
|
38
|
+
|
|
39
|
+
# Exceptions
|
|
40
|
+
"FireFeedException",
|
|
41
|
+
"APIException",
|
|
42
|
+
"AuthenticationException",
|
|
43
|
+
|
|
44
|
+
# Submodules
|
|
45
|
+
"models",
|
|
46
|
+
"exceptions",
|
|
47
|
+
"config",
|
|
48
|
+
"auth",
|
|
49
|
+
"api_client",
|
|
50
|
+
"interfaces",
|
|
51
|
+
"utils",
|
|
52
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FireFeed Core API Client
|
|
3
|
+
|
|
4
|
+
Provides HTTP client functionality for inter-service communication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import APIClient
|
|
8
|
+
from .circuit_breaker import CircuitBreaker
|
|
9
|
+
from .retry import RetryPolicy
|
|
10
|
+
from .rate_limiter import RateLimiter
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"APIClient",
|
|
14
|
+
"CircuitBreaker",
|
|
15
|
+
"RetryPolicy",
|
|
16
|
+
"RateLimiter",
|
|
17
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Circuit Breaker implementation for FireFeed Core
|
|
3
|
+
|
|
4
|
+
Provides fault tolerance by preventing requests to failing services.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Dict, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CircuitState(Enum):
|
|
13
|
+
"""Circuit breaker states."""
|
|
14
|
+
CLOSED = "closed" # Normal operation
|
|
15
|
+
OPEN = "open" # Blocking requests
|
|
16
|
+
HALF_OPEN = "half_open" # Testing if service recovered
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CircuitBreaker:
|
|
20
|
+
"""
|
|
21
|
+
Circuit breaker implementation to prevent cascading failures.
|
|
22
|
+
|
|
23
|
+
States:
|
|
24
|
+
- CLOSED: Normal operation, all requests pass through
|
|
25
|
+
- OPEN: Service is failing, block all requests
|
|
26
|
+
- HALF_OPEN: Allow limited requests to test recovery
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
failure_threshold: int = 5,
|
|
32
|
+
timeout: int = 60,
|
|
33
|
+
recovery_timeout: int = 30,
|
|
34
|
+
success_threshold: int = 3
|
|
35
|
+
):
|
|
36
|
+
"""
|
|
37
|
+
Initialize circuit breaker.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
failure_threshold: Number of failures to open circuit
|
|
41
|
+
timeout: Time in seconds before trying to close circuit
|
|
42
|
+
recovery_timeout: Time in half-open state before returning to closed
|
|
43
|
+
success_threshold: Number of successes needed to close circuit from half-open
|
|
44
|
+
"""
|
|
45
|
+
self.failure_threshold = failure_threshold
|
|
46
|
+
self.timeout = timeout
|
|
47
|
+
self.recovery_timeout = recovery_timeout
|
|
48
|
+
self.success_threshold = success_threshold
|
|
49
|
+
|
|
50
|
+
self.state = CircuitState.CLOSED
|
|
51
|
+
self.failure_count = 0
|
|
52
|
+
self.success_count = 0
|
|
53
|
+
self.last_failure_time = None
|
|
54
|
+
self.last_success_time = None
|
|
55
|
+
self.last_state_change = time.time()
|
|
56
|
+
|
|
57
|
+
def allow_request(self) -> bool:
|
|
58
|
+
"""
|
|
59
|
+
Check if request should be allowed based on circuit state.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if request should be allowed, False otherwise
|
|
63
|
+
"""
|
|
64
|
+
current_time = time.time()
|
|
65
|
+
|
|
66
|
+
# State transitions
|
|
67
|
+
if self.state == CircuitState.OPEN:
|
|
68
|
+
# Check if timeout has passed, move to half-open
|
|
69
|
+
if current_time - self.last_state_change >= self.timeout:
|
|
70
|
+
self.state = CircuitState.HALF_OPEN
|
|
71
|
+
self.success_count = 0
|
|
72
|
+
self.last_state_change = current_time
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
elif self.state == CircuitState.HALF_OPEN:
|
|
77
|
+
# Allow request in half-open state
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
else: # CLOSED
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
def record_success(self):
|
|
84
|
+
"""Record successful request."""
|
|
85
|
+
current_time = time.time()
|
|
86
|
+
self.last_success_time = current_time
|
|
87
|
+
|
|
88
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
89
|
+
self.success_count += 1
|
|
90
|
+
if self.success_count >= self.success_threshold:
|
|
91
|
+
self.state = CircuitState.CLOSED
|
|
92
|
+
self.failure_count = 0
|
|
93
|
+
self.last_state_change = current_time
|
|
94
|
+
elif self.state == CircuitState.CLOSED:
|
|
95
|
+
# Reset failure count on success
|
|
96
|
+
self.failure_count = max(0, self.failure_count - 1)
|
|
97
|
+
|
|
98
|
+
def record_failure(self):
|
|
99
|
+
"""Record failed request."""
|
|
100
|
+
current_time = time.time()
|
|
101
|
+
self.last_failure_time = current_time
|
|
102
|
+
self.failure_count += 1
|
|
103
|
+
|
|
104
|
+
if self.state == CircuitState.CLOSED:
|
|
105
|
+
if self.failure_count >= self.failure_threshold:
|
|
106
|
+
self.state = CircuitState.OPEN
|
|
107
|
+
self.last_state_change = current_time
|
|
108
|
+
|
|
109
|
+
elif self.state == CircuitState.HALF_OPEN:
|
|
110
|
+
# Return to open state on failure in half-open
|
|
111
|
+
self.state = CircuitState.OPEN
|
|
112
|
+
self.last_state_change = current_time
|
|
113
|
+
|
|
114
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
115
|
+
"""
|
|
116
|
+
Get circuit breaker statistics.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Dictionary with circuit breaker stats
|
|
120
|
+
"""
|
|
121
|
+
return {
|
|
122
|
+
"state": self.state.value,
|
|
123
|
+
"failure_count": self.failure_count,
|
|
124
|
+
"success_count": self.success_count,
|
|
125
|
+
"last_failure_time": self.last_failure_time,
|
|
126
|
+
"last_success_time": self.last_success_time,
|
|
127
|
+
"last_state_change": self.last_state_change,
|
|
128
|
+
"failure_threshold": self.failure_threshold,
|
|
129
|
+
"timeout": self.timeout,
|
|
130
|
+
"recovery_timeout": self.recovery_timeout,
|
|
131
|
+
"success_threshold": self.success_threshold,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def reset(self):
|
|
135
|
+
"""Reset circuit breaker to initial state."""
|
|
136
|
+
self.state = CircuitState.CLOSED
|
|
137
|
+
self.failure_count = 0
|
|
138
|
+
self.success_count = 0
|
|
139
|
+
self.last_failure_time = None
|
|
140
|
+
self.last_success_time = None
|
|
141
|
+
self.last_state_change = time.time()
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FireFeed Core API Client
|
|
3
|
+
|
|
4
|
+
A robust HTTP client for inter-service communication with authentication,
|
|
5
|
+
retry policies, circuit breaker pattern, and rate limiting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, List, Optional, Union
|
|
12
|
+
from urllib.parse import urljoin
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
16
|
+
|
|
17
|
+
from ..auth.token_manager import ServiceTokenManager
|
|
18
|
+
from ..exceptions.api_exceptions import (
|
|
19
|
+
APIException,
|
|
20
|
+
AuthenticationException,
|
|
21
|
+
AuthorizationException,
|
|
22
|
+
RateLimitException,
|
|
23
|
+
ServiceUnavailableException,
|
|
24
|
+
CircuitBreakerOpenException,
|
|
25
|
+
BadRequestException,
|
|
26
|
+
NotFoundException,
|
|
27
|
+
ConflictException,
|
|
28
|
+
)
|
|
29
|
+
from .circuit_breaker import CircuitBreaker
|
|
30
|
+
from .retry import RetryPolicy
|
|
31
|
+
from .rate_limiter import RateLimiter
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class APIClient:
|
|
38
|
+
"""
|
|
39
|
+
HTTP client for FireFeed microservices communication.
|
|
40
|
+
|
|
41
|
+
Features:
|
|
42
|
+
- JWT token authentication
|
|
43
|
+
- Automatic retry with exponential backoff
|
|
44
|
+
- Circuit breaker pattern for fault tolerance
|
|
45
|
+
- Rate limiting to prevent abuse
|
|
46
|
+
- Request/response logging
|
|
47
|
+
- Timeout management
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
base_url: str,
|
|
53
|
+
token: str,
|
|
54
|
+
service_id: str,
|
|
55
|
+
timeout: int = 30,
|
|
56
|
+
max_retries: int = 3,
|
|
57
|
+
circuit_breaker_failure_threshold: int = 5,
|
|
58
|
+
circuit_breaker_timeout: int = 60,
|
|
59
|
+
rate_limit_requests: int = 100,
|
|
60
|
+
rate_limit_window: int = 60,
|
|
61
|
+
**httpx_kwargs
|
|
62
|
+
):
|
|
63
|
+
"""
|
|
64
|
+
Initialize API Client.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
base_url: Base URL for the API (e.g., http://firefeed-api:8000)
|
|
68
|
+
token: JWT token for authentication
|
|
69
|
+
service_id: Unique identifier for this service
|
|
70
|
+
timeout: Request timeout in seconds
|
|
71
|
+
max_retries: Maximum number of retry attempts
|
|
72
|
+
circuit_breaker_failure_threshold: Number of failures to open circuit
|
|
73
|
+
circuit_breaker_timeout: Time in seconds before trying again
|
|
74
|
+
rate_limit_requests: Maximum requests per window
|
|
75
|
+
rate_limit_window: Time window in seconds for rate limiting
|
|
76
|
+
**httpx_kwargs: Additional arguments for httpx.AsyncClient
|
|
77
|
+
"""
|
|
78
|
+
self.base_url = base_url.rstrip('/')
|
|
79
|
+
self.token = token
|
|
80
|
+
self.service_id = service_id
|
|
81
|
+
self.timeout = timeout
|
|
82
|
+
|
|
83
|
+
# Initialize components
|
|
84
|
+
self.token_manager = ServiceTokenManager(secret_key="", issuer="") # Will be set by validator
|
|
85
|
+
self.circuit_breaker = CircuitBreaker(
|
|
86
|
+
failure_threshold=circuit_breaker_failure_threshold,
|
|
87
|
+
timeout=circuit_breaker_timeout
|
|
88
|
+
)
|
|
89
|
+
self.retry_policy = RetryPolicy(max_retries=max_retries)
|
|
90
|
+
self.rate_limiter = RateLimiter(
|
|
91
|
+
max_requests=rate_limit_requests,
|
|
92
|
+
window_seconds=rate_limit_window
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# HTTP client configuration
|
|
96
|
+
self.httpx_kwargs = {
|
|
97
|
+
"timeout": httpx.Timeout(timeout),
|
|
98
|
+
"headers": {
|
|
99
|
+
"User-Agent": f"firefeed-{service_id}/1.0.0",
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
"Accept": "application/json",
|
|
102
|
+
},
|
|
103
|
+
**httpx_kwargs
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Create HTTP client
|
|
107
|
+
self.client = httpx.AsyncClient(**self.httpx_kwargs)
|
|
108
|
+
|
|
109
|
+
logger.info(f"Initialized APIClient for {service_id} -> {base_url}")
|
|
110
|
+
|
|
111
|
+
async def __aenter__(self):
|
|
112
|
+
"""Async context manager entry."""
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
116
|
+
"""Async context manager exit."""
|
|
117
|
+
await self.close()
|
|
118
|
+
|
|
119
|
+
async def close(self):
|
|
120
|
+
"""Close the HTTP client."""
|
|
121
|
+
await self.client.aclose()
|
|
122
|
+
logger.info("APIClient closed")
|
|
123
|
+
|
|
124
|
+
def _validate_token(self) -> str:
|
|
125
|
+
"""
|
|
126
|
+
Validate and refresh token if needed.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Valid JWT token string
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
AuthenticationException: If token is invalid or expired
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
# For now, assume token is valid if it exists
|
|
136
|
+
# In production, you would validate JWT signature and expiry
|
|
137
|
+
if not self.token:
|
|
138
|
+
raise AuthenticationException("No authentication token provided")
|
|
139
|
+
|
|
140
|
+
# TODO: Add JWT validation logic here
|
|
141
|
+
# decoded_token = self.token_manager.verify_token(self.token)
|
|
142
|
+
# Check expiry, issuer, etc.
|
|
143
|
+
|
|
144
|
+
return self.token
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise AuthenticationException(f"Token validation failed: {str(e)}")
|
|
148
|
+
|
|
149
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
150
|
+
"""
|
|
151
|
+
Get request headers including authentication.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Dictionary of headers
|
|
155
|
+
"""
|
|
156
|
+
token = self._validate_token()
|
|
157
|
+
|
|
158
|
+
headers = {
|
|
159
|
+
"Authorization": f"Bearer {token}",
|
|
160
|
+
"X-Service-ID": self.service_id,
|
|
161
|
+
"X-Request-ID": f"{self.service_id}-{int(time.time() * 1000000)}",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return headers
|
|
165
|
+
|
|
166
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
167
|
+
"""
|
|
168
|
+
Handle HTTP response and convert to appropriate exceptions.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
response: httpx.Response object
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Parsed JSON response data
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
APIException: For various HTTP error codes
|
|
178
|
+
"""
|
|
179
|
+
# Log response
|
|
180
|
+
logger.debug(
|
|
181
|
+
f"Response: {response.status_code} {response.request.url} "
|
|
182
|
+
f"({len(response.content)} bytes)"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
response_data = response.json() if response.content else {}
|
|
187
|
+
except Exception:
|
|
188
|
+
response_data = {"message": response.text}
|
|
189
|
+
|
|
190
|
+
# Handle HTTP status codes
|
|
191
|
+
if response.status_code == 200:
|
|
192
|
+
return response_data
|
|
193
|
+
|
|
194
|
+
elif response.status_code == 201:
|
|
195
|
+
return response_data
|
|
196
|
+
|
|
197
|
+
elif response.status_code == 400:
|
|
198
|
+
error_details = response_data.get("details", [])
|
|
199
|
+
raise BadRequestException(
|
|
200
|
+
message=response_data.get("error", {}).get("message", "Bad request"),
|
|
201
|
+
validation_errors=error_details
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
elif response.status_code == 401:
|
|
205
|
+
raise AuthenticationException(
|
|
206
|
+
message=response_data.get("error", {}).get("message", "Authentication failed")
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
elif response.status_code == 403:
|
|
210
|
+
raise AuthorizationException(
|
|
211
|
+
message=response_data.get("error", {}).get("message", "Forbidden")
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
elif response.status_code == 404:
|
|
215
|
+
raise NotFoundException(
|
|
216
|
+
message=response_data.get("error", {}).get("message", "Not found")
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
elif response.status_code == 409:
|
|
220
|
+
raise ConflictException(
|
|
221
|
+
message=response_data.get("error", {}).get("message", "Conflict")
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
elif response.status_code == 429:
|
|
225
|
+
retry_after = response.headers.get("Retry-After")
|
|
226
|
+
raise RateLimitException(
|
|
227
|
+
message=response_data.get("error", {}).get("message", "Rate limit exceeded"),
|
|
228
|
+
retry_after=int(retry_after) if retry_after else None
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
elif response.status_code >= 500:
|
|
232
|
+
raise ServiceUnavailableException(
|
|
233
|
+
message=response_data.get("error", {}).get("message", "Service unavailable")
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
else:
|
|
237
|
+
raise APIException(
|
|
238
|
+
message=f"Unexpected response: {response.status_code}",
|
|
239
|
+
status_code=response.status_code,
|
|
240
|
+
response_data=response_data
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
async def _make_request(
|
|
244
|
+
self,
|
|
245
|
+
method: str,
|
|
246
|
+
endpoint: str,
|
|
247
|
+
params: Optional[Dict[str, Any]] = None,
|
|
248
|
+
data: Optional[Dict[str, Any]] = None,
|
|
249
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
250
|
+
**kwargs
|
|
251
|
+
) -> Any:
|
|
252
|
+
"""
|
|
253
|
+
Make HTTP request with retry and circuit breaker logic.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.)
|
|
257
|
+
endpoint: API endpoint (will be joined with base_url)
|
|
258
|
+
params: Query parameters
|
|
259
|
+
data: Form data
|
|
260
|
+
json_data: JSON data
|
|
261
|
+
**kwargs: Additional arguments for httpx request
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Parsed response data
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
APIException: For various HTTP and network errors
|
|
268
|
+
"""
|
|
269
|
+
url = urljoin(self.base_url + "/", endpoint.lstrip("/"))
|
|
270
|
+
headers = self._get_headers()
|
|
271
|
+
|
|
272
|
+
# Prepare request arguments
|
|
273
|
+
request_kwargs = {
|
|
274
|
+
"headers": headers,
|
|
275
|
+
"params": params or {},
|
|
276
|
+
**kwargs
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if json_data is not None:
|
|
280
|
+
request_kwargs["json"] = json_data
|
|
281
|
+
elif data is not None:
|
|
282
|
+
request_kwargs["data"] = data
|
|
283
|
+
|
|
284
|
+
# Check rate limiting
|
|
285
|
+
if not self.rate_limiter.allow_request():
|
|
286
|
+
retry_after = self.rate_limiter.get_retry_after()
|
|
287
|
+
raise RateLimitException(
|
|
288
|
+
message="Rate limit exceeded",
|
|
289
|
+
retry_after=retry_after
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Check circuit breaker
|
|
293
|
+
if not self.circuit_breaker.allow_request():
|
|
294
|
+
raise CircuitBreakerOpenException(
|
|
295
|
+
message="Circuit breaker is open"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Retry logic
|
|
299
|
+
last_exception = None
|
|
300
|
+
for attempt in range(self.retry_policy.max_retries + 1):
|
|
301
|
+
try:
|
|
302
|
+
logger.debug(f"Making request: {method} {url} (attempt {attempt + 1})")
|
|
303
|
+
|
|
304
|
+
response = await self.client.request(method, url, **request_kwargs)
|
|
305
|
+
|
|
306
|
+
# Record success for circuit breaker
|
|
307
|
+
self.circuit_breaker.record_success()
|
|
308
|
+
|
|
309
|
+
# Record rate limit usage
|
|
310
|
+
self.rate_limiter.record_request()
|
|
311
|
+
|
|
312
|
+
return self._handle_response(response)
|
|
313
|
+
|
|
314
|
+
except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError) as e:
|
|
315
|
+
last_exception = e
|
|
316
|
+
self.circuit_breaker.record_failure()
|
|
317
|
+
|
|
318
|
+
if attempt < self.retry_policy.max_retries:
|
|
319
|
+
delay = self.retry_policy.get_delay(attempt)
|
|
320
|
+
logger.warning(
|
|
321
|
+
f"Request failed (attempt {attempt + 1}): {str(e)}. "
|
|
322
|
+
f"Retrying in {delay}s..."
|
|
323
|
+
)
|
|
324
|
+
await asyncio.sleep(delay)
|
|
325
|
+
continue
|
|
326
|
+
else:
|
|
327
|
+
break
|
|
328
|
+
|
|
329
|
+
except APIException as e:
|
|
330
|
+
# Don't retry on client errors (4xx)
|
|
331
|
+
if 400 <= e.status_code < 500:
|
|
332
|
+
self.circuit_breaker.record_failure()
|
|
333
|
+
raise
|
|
334
|
+
else:
|
|
335
|
+
# Retry on server errors (5xx)
|
|
336
|
+
last_exception = e
|
|
337
|
+
self.circuit_breaker.record_failure()
|
|
338
|
+
|
|
339
|
+
if attempt < self.retry_policy.max_retries:
|
|
340
|
+
delay = self.retry_policy.get_delay(attempt)
|
|
341
|
+
logger.warning(
|
|
342
|
+
f"Request failed (attempt {attempt + 1}): {str(e)}. "
|
|
343
|
+
f"Retrying in {delay}s..."
|
|
344
|
+
)
|
|
345
|
+
await asyncio.sleep(delay)
|
|
346
|
+
continue
|
|
347
|
+
else:
|
|
348
|
+
break
|
|
349
|
+
|
|
350
|
+
# All retries failed
|
|
351
|
+
if last_exception:
|
|
352
|
+
raise ServiceUnavailableException(
|
|
353
|
+
f"Request failed after {self.retry_policy.max_retries + 1} attempts: {str(last_exception)}"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# HTTP method wrappers
|
|
357
|
+
|
|
358
|
+
async def get(
|
|
359
|
+
self,
|
|
360
|
+
endpoint: str,
|
|
361
|
+
params: Optional[Dict[str, Any]] = None,
|
|
362
|
+
**kwargs
|
|
363
|
+
) -> Any:
|
|
364
|
+
"""Make GET request."""
|
|
365
|
+
return await self._make_request("GET", endpoint, params=params, **kwargs)
|
|
366
|
+
|
|
367
|
+
async def post(
|
|
368
|
+
self,
|
|
369
|
+
endpoint: str,
|
|
370
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
371
|
+
data: Optional[Dict[str, Any]] = None,
|
|
372
|
+
**kwargs
|
|
373
|
+
) -> Any:
|
|
374
|
+
"""Make POST request."""
|
|
375
|
+
return await self._make_request("POST", endpoint, json_data=json_data, data=data, **kwargs)
|
|
376
|
+
|
|
377
|
+
async def put(
|
|
378
|
+
self,
|
|
379
|
+
endpoint: str,
|
|
380
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
381
|
+
data: Optional[Dict[str, Any]] = None,
|
|
382
|
+
**kwargs
|
|
383
|
+
) -> Any:
|
|
384
|
+
"""Make PUT request."""
|
|
385
|
+
return await self._make_request("PUT", endpoint, json_data=json_data, data=data, **kwargs)
|
|
386
|
+
|
|
387
|
+
async def patch(
|
|
388
|
+
self,
|
|
389
|
+
endpoint: str,
|
|
390
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
391
|
+
data: Optional[Dict[str, Any]] = None,
|
|
392
|
+
**kwargs
|
|
393
|
+
) -> Any:
|
|
394
|
+
"""Make PATCH request."""
|
|
395
|
+
return await self._make_request("PATCH", endpoint, json_data=json_data, data=data, **kwargs)
|
|
396
|
+
|
|
397
|
+
async def delete(
|
|
398
|
+
self,
|
|
399
|
+
endpoint: str,
|
|
400
|
+
**kwargs
|
|
401
|
+
) -> Any:
|
|
402
|
+
"""Make DELETE request."""
|
|
403
|
+
return await self._make_request("DELETE", endpoint, **kwargs)
|
|
404
|
+
|
|
405
|
+
# Utility methods
|
|
406
|
+
|
|
407
|
+
async def health_check(self) -> bool:
|
|
408
|
+
"""
|
|
409
|
+
Perform health check against the service.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
True if service is healthy, False otherwise
|
|
413
|
+
"""
|
|
414
|
+
try:
|
|
415
|
+
response = await self.get("/health")
|
|
416
|
+
return response.get("status") == "healthy"
|
|
417
|
+
except Exception as e:
|
|
418
|
+
logger.warning(f"Health check failed: {str(e)}")
|
|
419
|
+
return False
|
|
420
|
+
|
|
421
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
422
|
+
"""
|
|
423
|
+
Get client statistics.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Dictionary with client statistics
|
|
427
|
+
"""
|
|
428
|
+
return {
|
|
429
|
+
"service_id": self.service_id,
|
|
430
|
+
"base_url": self.base_url,
|
|
431
|
+
"circuit_breaker": self.circuit_breaker.get_stats(),
|
|
432
|
+
"rate_limiter": self.rate_limiter.get_stats(),
|
|
433
|
+
"retry_policy": self.retry_policy.get_stats(),
|
|
434
|
+
}
|