polari-sdk 0.2.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.
- polari/__init__.py +95 -0
- polari/client.py +190 -0
- polari/config.py +112 -0
- polari/exceptions.py +90 -0
- polari/layers/__init__.py +5 -0
- polari/layers/base.py +144 -0
- polari/layers/layer0.py +306 -0
- polari/layers/layer1.py +270 -0
- polari/layers/layer2.py +263 -0
- polari/layers/layer3.py +139 -0
- polari/types.py +202 -0
- polari/utils/__init__.py +7 -0
- polari/utils/cost.py +86 -0
- polari/utils/metrics.py +95 -0
- polari/utils/retry.py +109 -0
- polari_sdk-0.2.0.dist-info/METADATA +423 -0
- polari_sdk-0.2.0.dist-info/RECORD +19 -0
- polari_sdk-0.2.0.dist-info/WHEEL +5 -0
- polari_sdk-0.2.0.dist-info/top_level.txt +1 -0
polari/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Polari SDK - Official Python SDK for Polari API.
|
|
2
|
+
|
|
3
|
+
Example:
|
|
4
|
+
>>> from polari import PolariClient, ArticleInput
|
|
5
|
+
>>>
|
|
6
|
+
>>> client = PolariClient(api_key="pk_...")
|
|
7
|
+
>>> article = ArticleInput(
|
|
8
|
+
... title="Breaking News",
|
|
9
|
+
... content="Article content here...",
|
|
10
|
+
... url="https://example.com/article"
|
|
11
|
+
... )
|
|
12
|
+
>>> result = await client.layer0.analyze(article)
|
|
13
|
+
>>> print(f"Quality: {result.quality_score}")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .client import PolariClient
|
|
17
|
+
from .config import PolariConfig
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
ConfigurationError,
|
|
21
|
+
NetworkError,
|
|
22
|
+
PolariAPIError,
|
|
23
|
+
PolariError,
|
|
24
|
+
ProcessingError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
RetryExhaustedError,
|
|
27
|
+
ServerError,
|
|
28
|
+
TimeoutError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
from .types import (
|
|
32
|
+
# Input types
|
|
33
|
+
ArticleInput,
|
|
34
|
+
# Layer result types
|
|
35
|
+
Layer0Result,
|
|
36
|
+
Layer1Result,
|
|
37
|
+
# Layer 2 result types
|
|
38
|
+
ClusterResult,
|
|
39
|
+
ClusterBatchResult,
|
|
40
|
+
ClusterSummary,
|
|
41
|
+
ClusterStats,
|
|
42
|
+
ClusterMember,
|
|
43
|
+
# Shared types
|
|
44
|
+
HealthStatus,
|
|
45
|
+
Metrics,
|
|
46
|
+
CostSummary,
|
|
47
|
+
# Deprecated — retained for import compatibility, will be removed in v1.0
|
|
48
|
+
Layer2Result,
|
|
49
|
+
Layer3Result,
|
|
50
|
+
GraphNode,
|
|
51
|
+
GraphEdge,
|
|
52
|
+
Entity,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__version__ = "0.2.0"
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
# Main client
|
|
59
|
+
"PolariClient",
|
|
60
|
+
"PolariConfig",
|
|
61
|
+
# Input types
|
|
62
|
+
"ArticleInput",
|
|
63
|
+
# Layer 0 results
|
|
64
|
+
"Layer0Result",
|
|
65
|
+
# Layer 1 results
|
|
66
|
+
"Layer1Result",
|
|
67
|
+
# Layer 2 results
|
|
68
|
+
"ClusterResult",
|
|
69
|
+
"ClusterBatchResult",
|
|
70
|
+
"ClusterSummary",
|
|
71
|
+
"ClusterStats",
|
|
72
|
+
"ClusterMember",
|
|
73
|
+
# Shared types
|
|
74
|
+
"HealthStatus",
|
|
75
|
+
"Metrics",
|
|
76
|
+
"CostSummary",
|
|
77
|
+
# Exceptions
|
|
78
|
+
"PolariError",
|
|
79
|
+
"PolariAPIError",
|
|
80
|
+
"AuthenticationError",
|
|
81
|
+
"RateLimitError",
|
|
82
|
+
"ValidationError",
|
|
83
|
+
"ServerError",
|
|
84
|
+
"NetworkError",
|
|
85
|
+
"TimeoutError",
|
|
86
|
+
"ConfigurationError",
|
|
87
|
+
"ProcessingError",
|
|
88
|
+
"RetryExhaustedError",
|
|
89
|
+
# Deprecated — will be removed in v1.0
|
|
90
|
+
"Layer2Result",
|
|
91
|
+
"Layer3Result",
|
|
92
|
+
"GraphNode",
|
|
93
|
+
"GraphEdge",
|
|
94
|
+
"Entity",
|
|
95
|
+
]
|
polari/client.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Polari SDK main client."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .config import PolariConfig
|
|
9
|
+
from .exceptions import ConfigurationError
|
|
10
|
+
from .layers.layer0 import Layer0
|
|
11
|
+
from .types import CostSummary, HealthStatus, Metrics
|
|
12
|
+
from .utils import CostTracker, MetricsCollector
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PolariClient:
|
|
18
|
+
"""Main entry point for Polari SDK.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
>>> client = PolariClient(api_key="pk_...")
|
|
22
|
+
>>> result = await client.layer0.analyze(article)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_key: str,
|
|
28
|
+
base_url: str = "http://localhost:8000",
|
|
29
|
+
timeout: int = 30,
|
|
30
|
+
max_retries: int = 3,
|
|
31
|
+
enable_metrics: bool = True,
|
|
32
|
+
enable_cost_tracking: bool = True,
|
|
33
|
+
log_level: str = "INFO",
|
|
34
|
+
**config_overrides,
|
|
35
|
+
):
|
|
36
|
+
"""Initialize Polari SDK client.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
api_key: Polari API key
|
|
40
|
+
base_url: Base API URL (Layer 0). Layer 1-3 URLs come from config or defaults.
|
|
41
|
+
timeout: Request timeout in seconds
|
|
42
|
+
max_retries: Maximum retry attempts
|
|
43
|
+
enable_metrics: Enable performance metrics collection
|
|
44
|
+
enable_cost_tracking: Enable cost tracking
|
|
45
|
+
log_level: Logging level
|
|
46
|
+
**config_overrides: Additional config overrides (e.g. layer1_url, layer2_url)
|
|
47
|
+
"""
|
|
48
|
+
logging.basicConfig(
|
|
49
|
+
level=getattr(logging, log_level.upper()),
|
|
50
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.config = PolariConfig(
|
|
54
|
+
api_key=api_key,
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
max_retries=max_retries,
|
|
58
|
+
enable_metrics=enable_metrics,
|
|
59
|
+
enable_cost_tracking=enable_cost_tracking,
|
|
60
|
+
log_level=log_level,
|
|
61
|
+
**config_overrides,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
self._metrics = MetricsCollector(enabled=enable_metrics)
|
|
65
|
+
self._cost_tracker = CostTracker(enabled=enable_cost_tracking)
|
|
66
|
+
|
|
67
|
+
self._http_client = httpx.AsyncClient(
|
|
68
|
+
headers={
|
|
69
|
+
"Authorization": f"Bearer {api_key}",
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
"User-Agent": "polari-sdk-python/0.2.0",
|
|
72
|
+
},
|
|
73
|
+
timeout=httpx.Timeout(
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
connect=self.config.connect_timeout,
|
|
76
|
+
read=self.config.read_timeout,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Lazy-initialized layer instances
|
|
81
|
+
self._layer0 = None
|
|
82
|
+
self._layer1 = None
|
|
83
|
+
self._layer2 = None
|
|
84
|
+
self._layer3 = None
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def layer0(self) -> "Layer0":
|
|
88
|
+
"""Layer 0: Token Intelligence (port 8003)."""
|
|
89
|
+
if self._layer0 is None:
|
|
90
|
+
from polari.layers.layer0 import Layer0
|
|
91
|
+
self._layer0 = Layer0(self)
|
|
92
|
+
return self._layer0
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def layer1(self):
|
|
96
|
+
"""Layer 1: Semantic Analysis (port 8004)."""
|
|
97
|
+
if self._layer1 is None:
|
|
98
|
+
from polari.layers.layer1 import Layer1
|
|
99
|
+
layer1_url = getattr(self.config, "layer1_url", "http://localhost:8004")
|
|
100
|
+
self._layer1 = Layer1(self, base_url=layer1_url)
|
|
101
|
+
return self._layer1
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def layer2(self):
|
|
105
|
+
"""Layer 2: Story Clustering (port 8005)."""
|
|
106
|
+
if self._layer2 is None:
|
|
107
|
+
from polari.layers.layer2 import Layer2
|
|
108
|
+
layer2_url = getattr(self.config, "layer2_url", "http://localhost:8005")
|
|
109
|
+
self._layer2 = Layer2(self, base_url=layer2_url)
|
|
110
|
+
return self._layer2
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def layer3(self):
|
|
114
|
+
"""Layer 3: Intelligence Graph (port 8006)."""
|
|
115
|
+
if self._layer3 is None:
|
|
116
|
+
from polari.layers.layer3 import Layer3
|
|
117
|
+
layer3_url = getattr(self.config, "layer3_url", "http://localhost:8006")
|
|
118
|
+
self._layer3 = Layer3(self, base_url=layer3_url)
|
|
119
|
+
return self._layer3
|
|
120
|
+
|
|
121
|
+
async def health_check(self) -> HealthStatus:
|
|
122
|
+
"""Check API health across all four layers concurrently.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
HealthStatus with per-layer results and overall status:
|
|
126
|
+
healthy — all four layers responding
|
|
127
|
+
degraded — at least one layer responding
|
|
128
|
+
down — no layers responding
|
|
129
|
+
"""
|
|
130
|
+
import asyncio
|
|
131
|
+
import time
|
|
132
|
+
|
|
133
|
+
start = time.time()
|
|
134
|
+
results = await asyncio.gather(
|
|
135
|
+
self.layer0.health_check(),
|
|
136
|
+
self.layer1.health_check(),
|
|
137
|
+
self.layer2.health_check(),
|
|
138
|
+
self.layer3.health_check(),
|
|
139
|
+
return_exceptions=True,
|
|
140
|
+
)
|
|
141
|
+
layers_status = {
|
|
142
|
+
"layer0": results[0] is True,
|
|
143
|
+
"layer1": results[1] is True,
|
|
144
|
+
"layer2": results[2] is True,
|
|
145
|
+
"layer3": results[3] is True,
|
|
146
|
+
}
|
|
147
|
+
latency_ms = (time.time() - start) * 1000
|
|
148
|
+
|
|
149
|
+
if all(layers_status.values()):
|
|
150
|
+
status = "healthy"
|
|
151
|
+
elif any(layers_status.values()):
|
|
152
|
+
status = "degraded"
|
|
153
|
+
else:
|
|
154
|
+
status = "down"
|
|
155
|
+
|
|
156
|
+
return HealthStatus(status=status, layers=layers_status, latency_ms=latency_ms)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_metrics(self) -> Metrics:
|
|
160
|
+
"""Get performance metrics."""
|
|
161
|
+
return self._metrics.get_metrics()
|
|
162
|
+
|
|
163
|
+
def get_cost_summary(self) -> CostSummary:
|
|
164
|
+
"""Get API cost summary."""
|
|
165
|
+
return self._cost_tracker.get_cost_summary()
|
|
166
|
+
|
|
167
|
+
async def close(self) -> None:
|
|
168
|
+
"""Close HTTP client and cleanup resources."""
|
|
169
|
+
await self._http_client.aclose()
|
|
170
|
+
logger.info("PolariClient closed")
|
|
171
|
+
|
|
172
|
+
async def __aenter__(self) -> "PolariClient":
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
176
|
+
await self.close()
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_env(cls, **overrides) -> "PolariClient":
|
|
180
|
+
"""Create client from environment variables (POLARI_API_KEY)."""
|
|
181
|
+
config = PolariConfig.from_env(**overrides)
|
|
182
|
+
return cls(
|
|
183
|
+
api_key=config.api_key,
|
|
184
|
+
base_url=config.base_url,
|
|
185
|
+
timeout=config.timeout,
|
|
186
|
+
max_retries=config.max_retries,
|
|
187
|
+
enable_metrics=config.enable_metrics,
|
|
188
|
+
enable_cost_tracking=config.enable_cost_tracking,
|
|
189
|
+
log_level=config.log_level,
|
|
190
|
+
)
|
polari/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Configuration management for Polari SDK."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from .exceptions import ConfigurationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PolariConfig:
|
|
12
|
+
"""SDK Configuration."""
|
|
13
|
+
|
|
14
|
+
# Authentication
|
|
15
|
+
api_key: str
|
|
16
|
+
base_url: str = "https://api.polariapi.com"
|
|
17
|
+
layer0_url: str = "https://layer0.api.polariapi.com"
|
|
18
|
+
layer1_url: str = "https://layer1.api.polariapi.com"
|
|
19
|
+
layer2_url: str = "https://layer2.api.polariapi.com"
|
|
20
|
+
layer3_url: str = "https://layer3.api.polariapi.com"
|
|
21
|
+
# Timeouts (seconds)
|
|
22
|
+
timeout: int = 30
|
|
23
|
+
connect_timeout: int = 10
|
|
24
|
+
read_timeout: int = 30
|
|
25
|
+
|
|
26
|
+
# Retry policy
|
|
27
|
+
max_retries: int = 3
|
|
28
|
+
retry_backoff_factor: float = 1.0
|
|
29
|
+
retry_statuses: Tuple[int, ...] = (429, 500, 502, 503, 504)
|
|
30
|
+
|
|
31
|
+
# Batch processing
|
|
32
|
+
default_batch_size: int = 10
|
|
33
|
+
max_batch_size: int = 50
|
|
34
|
+
|
|
35
|
+
# Features
|
|
36
|
+
enable_metrics: bool = True
|
|
37
|
+
enable_cost_tracking: bool = True
|
|
38
|
+
enable_caching: bool = False
|
|
39
|
+
|
|
40
|
+
# Logging
|
|
41
|
+
log_level: str = "INFO"
|
|
42
|
+
log_requests: bool = False
|
|
43
|
+
log_responses: bool = False
|
|
44
|
+
|
|
45
|
+
# Layer-specific thresholds
|
|
46
|
+
layer0_quality_threshold: float = 0.55
|
|
47
|
+
layer2_similarity_threshold: float = 0.7
|
|
48
|
+
layer2_min_cluster_size: int = 2
|
|
49
|
+
|
|
50
|
+
# Internal
|
|
51
|
+
_validated: bool = field(default=False, init=False, repr=False)
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
"""Validate configuration after initialization."""
|
|
55
|
+
if not self._validated:
|
|
56
|
+
self._validate()
|
|
57
|
+
self._validated = True
|
|
58
|
+
|
|
59
|
+
def _validate(self) -> None:
|
|
60
|
+
"""Validate configuration values."""
|
|
61
|
+
if not self.api_key:
|
|
62
|
+
raise ConfigurationError("API key is required")
|
|
63
|
+
|
|
64
|
+
if self.timeout <= 0:
|
|
65
|
+
raise ConfigurationError("Timeout must be positive")
|
|
66
|
+
|
|
67
|
+
if self.max_retries < 0:
|
|
68
|
+
raise ConfigurationError("Max retries cannot be negative")
|
|
69
|
+
|
|
70
|
+
if not 0.0 <= self.layer0_quality_threshold <= 1.0:
|
|
71
|
+
raise ConfigurationError("Quality threshold must be between 0 and 1")
|
|
72
|
+
|
|
73
|
+
if not 0.0 <= self.layer2_similarity_threshold <= 1.0:
|
|
74
|
+
raise ConfigurationError("Similarity threshold must be between 0 and 1")
|
|
75
|
+
|
|
76
|
+
if self.layer2_min_cluster_size < 2:
|
|
77
|
+
raise ConfigurationError("Min cluster size must be at least 2")
|
|
78
|
+
|
|
79
|
+
if self.default_batch_size > self.max_batch_size:
|
|
80
|
+
raise ConfigurationError("Default batch size cannot exceed max batch size")
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_env(cls, **overrides: any) -> "PolariConfig":
|
|
84
|
+
"""Load configuration from environment variables.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
**overrides: Override specific config values
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
PolariConfig instance
|
|
91
|
+
|
|
92
|
+
Environment Variables:
|
|
93
|
+
POLARI_API_KEY: API key (required)
|
|
94
|
+
POLARI_BASE_URL: Base API URL
|
|
95
|
+
POLARI_TIMEOUT: Request timeout in seconds
|
|
96
|
+
POLARI_MAX_RETRIES: Maximum retry attempts
|
|
97
|
+
POLARI_LOG_LEVEL: Logging level
|
|
98
|
+
"""
|
|
99
|
+
api_key = os.getenv("POLARI_API_KEY")
|
|
100
|
+
if not api_key and "api_key" not in overrides:
|
|
101
|
+
raise ConfigurationError("POLARI_API_KEY environment variable not set")
|
|
102
|
+
|
|
103
|
+
return cls(
|
|
104
|
+
api_key=overrides.get("api_key", api_key or ""),
|
|
105
|
+
base_url=overrides.get("base_url", os.getenv("POLARI_BASE_URL", cls.base_url)),
|
|
106
|
+
timeout=int(overrides.get("timeout", os.getenv("POLARI_TIMEOUT", cls.timeout))),
|
|
107
|
+
max_retries=int(
|
|
108
|
+
overrides.get("max_retries", os.getenv("POLARI_MAX_RETRIES", cls.max_retries))
|
|
109
|
+
),
|
|
110
|
+
log_level=overrides.get("log_level", os.getenv("POLARI_LOG_LEVEL", cls.log_level)),
|
|
111
|
+
**{k: v for k, v in overrides.items() if k not in ["api_key", "base_url", "timeout", "max_retries", "log_level"]},
|
|
112
|
+
)
|
polari/exceptions.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Polari SDK exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PolariError(Exception):
|
|
7
|
+
"""Base exception for all Polari SDK errors."""
|
|
8
|
+
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PolariAPIError(PolariError):
|
|
13
|
+
"""API returned an error response."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status_code: int, message: str, response: Optional[Dict[str, Any]] = None):
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.message = message
|
|
18
|
+
self.response = response or {}
|
|
19
|
+
super().__init__(f"API Error {status_code}: {message}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RateLimitError(PolariAPIError):
|
|
23
|
+
"""Rate limit exceeded (429)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str = "Rate limit exceeded", response: Optional[Dict[str, Any]] = None):
|
|
26
|
+
super().__init__(429, message, response)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthenticationError(PolariAPIError):
|
|
30
|
+
"""Authentication failed (401)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str = "Authentication failed", response: Optional[Dict[str, Any]] = None):
|
|
33
|
+
super().__init__(401, message, response)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ValidationError(PolariAPIError):
|
|
37
|
+
"""Request validation failed (400/422)."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
message: str = "Validation failed",
|
|
42
|
+
status_code: int = 400,
|
|
43
|
+
response: Optional[Dict[str, Any]] = None,
|
|
44
|
+
):
|
|
45
|
+
super().__init__(status_code, message, response)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ServerError(PolariAPIError):
|
|
49
|
+
"""Server error (5xx)."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
message: str = "Server error",
|
|
54
|
+
status_code: int = 500,
|
|
55
|
+
response: Optional[Dict[str, Any]] = None,
|
|
56
|
+
):
|
|
57
|
+
super().__init__(status_code, message, response)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NetworkError(PolariError):
|
|
61
|
+
"""Network connectivity issue."""
|
|
62
|
+
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TimeoutError(PolariError):
|
|
67
|
+
"""Request timeout."""
|
|
68
|
+
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ConfigurationError(PolariError):
|
|
73
|
+
"""Invalid configuration."""
|
|
74
|
+
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProcessingError(PolariError):
|
|
79
|
+
"""Error during article processing."""
|
|
80
|
+
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RetryExhaustedError(PolariError):
|
|
85
|
+
"""Max retry attempts exhausted."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, attempts: int, last_error: Optional[Exception] = None):
|
|
88
|
+
self.attempts = attempts
|
|
89
|
+
self.last_error = last_error
|
|
90
|
+
super().__init__(f"Retry exhausted after {attempts} attempts")
|
polari/layers/base.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Base layer interface for all Polari layers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ..config import PolariConfig
|
|
10
|
+
from ..exceptions import (
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
NetworkError,
|
|
13
|
+
PolariAPIError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
TimeoutError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
from ..utils import CostTracker, MetricsCollector
|
|
20
|
+
from ..utils.retry import ExponentialBackoff
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseLayer(ABC):
|
|
26
|
+
"""Abstract base class for all layer implementations."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
http_client: httpx.AsyncClient,
|
|
31
|
+
config: PolariConfig,
|
|
32
|
+
metrics: MetricsCollector,
|
|
33
|
+
cost_tracker: CostTracker,
|
|
34
|
+
):
|
|
35
|
+
"""Initialize base layer.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
http_client: Shared HTTP client
|
|
39
|
+
config: SDK configuration
|
|
40
|
+
metrics: Metrics collector
|
|
41
|
+
cost_tracker: Cost tracker
|
|
42
|
+
"""
|
|
43
|
+
self.http_client = http_client
|
|
44
|
+
self.config = config
|
|
45
|
+
self.metrics = metrics
|
|
46
|
+
self.cost_tracker = cost_tracker
|
|
47
|
+
self.layer_name = self.__class__.__name__.lower()
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def get_endpoint(self) -> str:
|
|
51
|
+
"""Get the API endpoint for this layer.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Endpoint path (e.g., "/v1/layer0/process")
|
|
55
|
+
"""
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
async def _make_request(
|
|
59
|
+
self,
|
|
60
|
+
method: str,
|
|
61
|
+
endpoint: str,
|
|
62
|
+
data: Optional[Dict[str, Any]] = None,
|
|
63
|
+
params: Optional[Dict[str, Any]] = None,
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""Make HTTP request with error handling and exponential backoff retry.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
method: HTTP method (GET, POST, etc.)
|
|
69
|
+
endpoint: API endpoint path
|
|
70
|
+
data: Request body data
|
|
71
|
+
params: Query parameters
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Response JSON
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
PolariAPIError: On API errors
|
|
78
|
+
NetworkError: On network errors
|
|
79
|
+
TimeoutError: On request timeout
|
|
80
|
+
"""
|
|
81
|
+
url = f"{getattr(self, 'base_url', self.config.base_url)}{endpoint}"
|
|
82
|
+
|
|
83
|
+
retry = ExponentialBackoff(
|
|
84
|
+
max_retries=self.config.max_retries,
|
|
85
|
+
base_delay=self.config.retry_backoff_factor,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def _do_request():
|
|
89
|
+
try:
|
|
90
|
+
response = await self.http_client.request(
|
|
91
|
+
method=method,
|
|
92
|
+
url=url,
|
|
93
|
+
json=data,
|
|
94
|
+
params=params,
|
|
95
|
+
timeout=self.config.timeout,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if response.status_code >= 400:
|
|
99
|
+
# Only retry 429 (rate limit) and 5xx (server errors)
|
|
100
|
+
# Never retry 401/403/400/422 - these won't change on retry
|
|
101
|
+
if response.status_code in (401, 403, 400, 404, 422):
|
|
102
|
+
|
|
103
|
+
self._handle_error_response(response) # Raises, NOT retried
|
|
104
|
+
else:
|
|
105
|
+
self._handle_error_response(response) # 429/5xx - retried
|
|
106
|
+
|
|
107
|
+
return response.json()
|
|
108
|
+
|
|
109
|
+
except httpx.TimeoutException as e:
|
|
110
|
+
logger.error(f"Request timeout: {e}")
|
|
111
|
+
raise TimeoutError(f"Request timeout after {self.config.timeout}s") from e
|
|
112
|
+
except httpx.NetworkError as e:
|
|
113
|
+
logger.error(f"Network error: {e}")
|
|
114
|
+
raise NetworkError(f"Network error: {e}") from e
|
|
115
|
+
|
|
116
|
+
return await retry.execute(_do_request)
|
|
117
|
+
|
|
118
|
+
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
119
|
+
"""Handle error HTTP responses.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
response: HTTP response object
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
Appropriate PolariAPIError subclass
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
error_data = response.json()
|
|
129
|
+
message = error_data.get("detail", error_data.get("message", "Unknown error"))
|
|
130
|
+
except Exception:
|
|
131
|
+
message = response.text or "Unknown error"
|
|
132
|
+
|
|
133
|
+
status_code = response.status_code
|
|
134
|
+
|
|
135
|
+
if status_code == 401:
|
|
136
|
+
raise AuthenticationError(message, response=error_data if 'error_data' in locals() else None)
|
|
137
|
+
elif status_code == 429:
|
|
138
|
+
raise RateLimitError(message, response=error_data if 'error_data' in locals() else None)
|
|
139
|
+
elif status_code in (400, 422):
|
|
140
|
+
raise ValidationError(message, status_code, response=error_data if 'error_data' in locals() else None)
|
|
141
|
+
elif status_code >= 500:
|
|
142
|
+
raise ServerError(message, status_code, response=error_data if 'error_data' in locals() else None)
|
|
143
|
+
else:
|
|
144
|
+
raise PolariAPIError(status_code, message, response=error_data if 'error_data' in locals() else None)
|