tinyfish 0.2.2__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.
- tinyfish/__init__.py +104 -0
- tinyfish/_utils/__init__.py +26 -0
- tinyfish/_utils/client/__init__.py +4 -0
- tinyfish/_utils/client/_base.py +137 -0
- tinyfish/_utils/client/async_.py +192 -0
- tinyfish/_utils/client/sync.py +191 -0
- tinyfish/_utils/exceptions.py +159 -0
- tinyfish/_utils/resource.py +23 -0
- tinyfish/_utils/sse_parser.py +62 -0
- tinyfish/agent/__init__.py +368 -0
- tinyfish/agent/types.py +182 -0
- tinyfish/client.py +76 -0
- tinyfish/py.typed +11 -0
- tinyfish/runs/__init__.py +147 -0
- tinyfish/runs/types.py +107 -0
- tinyfish-0.2.2.dist-info/METADATA +8 -0
- tinyfish-0.2.2.dist-info/RECORD +18 -0
- tinyfish-0.2.2.dist-info/WHEEL +4 -0
tinyfish/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TinyFish SDK - State-of-the-Art web agents in an API
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
|
|
7
|
+
# Clients
|
|
8
|
+
# Exceptions
|
|
9
|
+
from ._utils.exceptions import (
|
|
10
|
+
APIConnectionError,
|
|
11
|
+
APIError,
|
|
12
|
+
APIStatusError,
|
|
13
|
+
APITimeoutError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
BadRequestError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
InternalServerError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
PermissionDeniedError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
RequestTimeoutError,
|
|
22
|
+
SDKError,
|
|
23
|
+
SSEParseError,
|
|
24
|
+
UnprocessableEntityError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Agent resource
|
|
28
|
+
from .agent import AgentStream, AsyncAgentStream
|
|
29
|
+
|
|
30
|
+
# Agent types
|
|
31
|
+
from .agent.types import (
|
|
32
|
+
AgentRunAsyncResponse,
|
|
33
|
+
AgentRunResponse,
|
|
34
|
+
AgentRunWithStreamingResponse,
|
|
35
|
+
BrowserProfile,
|
|
36
|
+
CompleteEvent,
|
|
37
|
+
EventType,
|
|
38
|
+
HeartbeatEvent,
|
|
39
|
+
ProgressEvent,
|
|
40
|
+
ProxyConfig,
|
|
41
|
+
ProxyCountryCode,
|
|
42
|
+
StartedEvent,
|
|
43
|
+
StreamingUrlEvent,
|
|
44
|
+
)
|
|
45
|
+
from .client import AsyncTinyFish, TinyFish
|
|
46
|
+
|
|
47
|
+
# Runs types
|
|
48
|
+
from .runs.types import (
|
|
49
|
+
ErrorCategory,
|
|
50
|
+
PaginationInfo,
|
|
51
|
+
Run,
|
|
52
|
+
RunError,
|
|
53
|
+
RunListResponse,
|
|
54
|
+
RunStatus,
|
|
55
|
+
SortDirection,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__version__ = version("tinyfish")
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
# Clients
|
|
62
|
+
"TinyFish",
|
|
63
|
+
"AsyncTinyFish",
|
|
64
|
+
# Exceptions
|
|
65
|
+
"SDKError",
|
|
66
|
+
"SSEParseError",
|
|
67
|
+
"APIError",
|
|
68
|
+
"APIConnectionError",
|
|
69
|
+
"APITimeoutError",
|
|
70
|
+
"APIStatusError",
|
|
71
|
+
"BadRequestError",
|
|
72
|
+
"AuthenticationError",
|
|
73
|
+
"PermissionDeniedError",
|
|
74
|
+
"NotFoundError",
|
|
75
|
+
"RequestTimeoutError",
|
|
76
|
+
"ConflictError",
|
|
77
|
+
"UnprocessableEntityError",
|
|
78
|
+
"RateLimitError",
|
|
79
|
+
"InternalServerError",
|
|
80
|
+
# Agent resource
|
|
81
|
+
"AgentStream",
|
|
82
|
+
"AsyncAgentStream",
|
|
83
|
+
# Agent types
|
|
84
|
+
"EventType",
|
|
85
|
+
"BrowserProfile",
|
|
86
|
+
"ProxyCountryCode",
|
|
87
|
+
"ProxyConfig",
|
|
88
|
+
"AgentRunResponse",
|
|
89
|
+
"AgentRunAsyncResponse",
|
|
90
|
+
"StartedEvent",
|
|
91
|
+
"StreamingUrlEvent",
|
|
92
|
+
"ProgressEvent",
|
|
93
|
+
"HeartbeatEvent",
|
|
94
|
+
"CompleteEvent",
|
|
95
|
+
"AgentRunWithStreamingResponse",
|
|
96
|
+
# Runs types
|
|
97
|
+
"ErrorCategory",
|
|
98
|
+
"SortDirection",
|
|
99
|
+
"RunError",
|
|
100
|
+
"Run",
|
|
101
|
+
"RunStatus",
|
|
102
|
+
"RunListResponse",
|
|
103
|
+
"PaginationInfo",
|
|
104
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Reusable SDK foundation.
|
|
2
|
+
|
|
3
|
+
Provides base classes for building HTTP API SDKs:
|
|
4
|
+
- Clients: HTTP client with auth, retries, error handling
|
|
5
|
+
- Resources: Containers for grouping related API methods
|
|
6
|
+
- Exceptions: Complete error hierarchy
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .client import BaseAsyncAPIClient as BaseAsyncAPIClient
|
|
10
|
+
from .client import BaseSyncAPIClient as BaseSyncAPIClient
|
|
11
|
+
from .exceptions import APIConnectionError as APIConnectionError
|
|
12
|
+
from .exceptions import APIError as APIError
|
|
13
|
+
from .exceptions import APIStatusError as APIStatusError
|
|
14
|
+
from .exceptions import APITimeoutError as APITimeoutError
|
|
15
|
+
from .exceptions import AuthenticationError as AuthenticationError
|
|
16
|
+
from .exceptions import BadRequestError as BadRequestError
|
|
17
|
+
from .exceptions import ConflictError as ConflictError
|
|
18
|
+
from .exceptions import InternalServerError as InternalServerError
|
|
19
|
+
from .exceptions import NotFoundError as NotFoundError
|
|
20
|
+
from .exceptions import PermissionDeniedError as PermissionDeniedError
|
|
21
|
+
from .exceptions import RateLimitError as RateLimitError
|
|
22
|
+
from .exceptions import RequestTimeoutError as RequestTimeoutError
|
|
23
|
+
from .exceptions import SDKError as SDKError
|
|
24
|
+
from .exceptions import UnprocessableEntityError as UnprocessableEntityError
|
|
25
|
+
from .resource import BaseAsyncAPIResource as BaseAsyncAPIResource
|
|
26
|
+
from .resource import BaseSyncAPIResource as BaseSyncAPIResource
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Shared base client internals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from importlib.metadata import version as _pkg_version
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from ..exceptions import (
|
|
13
|
+
APIStatusError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
BadRequestError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
InternalServerError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
PermissionDeniedError,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
RequestTimeoutError,
|
|
22
|
+
UnprocessableEntityError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
ResponseT = TypeVar("ResponseT", bound=BaseModel)
|
|
26
|
+
|
|
27
|
+
# 600 s = 10 min — matches the platform's own run timeout
|
|
28
|
+
# (frontend/app/v1/lib/one-off-run.ts: RUN_TIMEOUT_MS = 60_000 * 10)
|
|
29
|
+
_DEFAULT_TIMEOUT: float = 600.0
|
|
30
|
+
_DEFAULT_MAX_RETRIES: int = 2
|
|
31
|
+
_RETRY_MULTIPLIER: float = 0.5
|
|
32
|
+
_RETRY_MAX_WAIT: float = 8.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _BaseClient:
|
|
36
|
+
"""Shared logic for sync and async clients. Not for direct use."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
api_key: str | None,
|
|
42
|
+
base_url: str,
|
|
43
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Args:
|
|
47
|
+
api_key: API key for authentication. If None, falls back to the
|
|
48
|
+
TINYFISH_API_KEY environment variable.
|
|
49
|
+
base_url: Base URL for all API requests
|
|
50
|
+
max_retries: Default max retry attempts (default: 2)
|
|
51
|
+
"""
|
|
52
|
+
# If no key was passed explicitly, try the environment variable.
|
|
53
|
+
# This lets users do `export TINYFISH_API_KEY=xxx` and omit api_key
|
|
54
|
+
# entirely when constructing the client.
|
|
55
|
+
resolved_key = api_key or os.environ.get("TINYFISH_API_KEY")
|
|
56
|
+
if not resolved_key:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"No API key provided. Pass api_key= to the client or set the TINYFISH_API_KEY environment variable."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Stored with leading underscores so they are not part of the public API
|
|
62
|
+
# and do not appear in autocomplete or when someone inspects the object
|
|
63
|
+
# casually (e.g. vars(client) in a debug session).
|
|
64
|
+
self._api_key = resolved_key
|
|
65
|
+
self._base_url = base_url
|
|
66
|
+
self._max_retries = max_retries
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
# Mask the key so that logging `repr(client)` never writes a live secret
|
|
70
|
+
# to stdout, log files, or error tracking services. We keep the first 8
|
|
71
|
+
# characters so the user can still tell *which* key is in use when they
|
|
72
|
+
# have more than one (e.g. separate dev/prod keys).
|
|
73
|
+
masked = f"{self._api_key[:8]}****" if len(self._api_key) > 8 else "****"
|
|
74
|
+
return f"{self.__class__.__name__}(base_url={self._base_url!r}, api_key={masked!r})"
|
|
75
|
+
|
|
76
|
+
def _build_default_headers(self) -> dict[str, str]:
|
|
77
|
+
return {
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
"Accept": "application/json",
|
|
80
|
+
"X-API-Key": self._api_key,
|
|
81
|
+
"User-Agent": f"tinyfish-python/{_pkg_version('tinyfish')}",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def _make_status_error(self, response: httpx.Response) -> APIStatusError:
|
|
85
|
+
"""
|
|
86
|
+
Translate an error HTTP response into the appropriate SDK exception.
|
|
87
|
+
|
|
88
|
+
Called by _request() in sync.py and async_.py immediately after a response
|
|
89
|
+
comes back with response.is_error == True. httpx does not raise on bad status
|
|
90
|
+
codes by itself — this method bridges that gap.
|
|
91
|
+
|
|
92
|
+
See docs/exceptions-and-errors-guide.md for the full error-handling architecture.
|
|
93
|
+
"""
|
|
94
|
+
error_map: dict[int, type[APIStatusError]] = {
|
|
95
|
+
int(httpx.codes.BAD_REQUEST): BadRequestError,
|
|
96
|
+
int(httpx.codes.UNAUTHORIZED): AuthenticationError,
|
|
97
|
+
int(httpx.codes.FORBIDDEN): PermissionDeniedError,
|
|
98
|
+
int(httpx.codes.NOT_FOUND): NotFoundError,
|
|
99
|
+
int(httpx.codes.REQUEST_TIMEOUT): RequestTimeoutError,
|
|
100
|
+
int(httpx.codes.CONFLICT): ConflictError,
|
|
101
|
+
int(httpx.codes.UNPROCESSABLE_ENTITY): UnprocessableEntityError,
|
|
102
|
+
int(httpx.codes.TOO_MANY_REQUESTS): RateLimitError,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if response.is_server_error:
|
|
106
|
+
error_class = InternalServerError
|
|
107
|
+
else:
|
|
108
|
+
error_class = error_map.get(response.status_code, APIStatusError)
|
|
109
|
+
|
|
110
|
+
# Try to parse error message from JSON response
|
|
111
|
+
try:
|
|
112
|
+
error_data = response.json()
|
|
113
|
+
message = error_data.get("error", {}).get("message", response.text)
|
|
114
|
+
except Exception:
|
|
115
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
116
|
+
|
|
117
|
+
if error_class is APIStatusError:
|
|
118
|
+
# Fallback: no concrete subclass matched, so pass the raw status code.
|
|
119
|
+
return error_class(message=message, response=response, status_code=response.status_code)
|
|
120
|
+
return error_class(message=message, response=response)
|
|
121
|
+
|
|
122
|
+
def _parse_response(
|
|
123
|
+
self,
|
|
124
|
+
response: httpx.Response,
|
|
125
|
+
cast_to: type[ResponseT],
|
|
126
|
+
) -> ResponseT:
|
|
127
|
+
"""
|
|
128
|
+
Parse HTTP response JSON into the given Pydantic model.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
response: The HTTP response
|
|
132
|
+
cast_to: Pydantic model to validate and parse into
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
pydantic.ValidationError: Response doesn't match the expected schema
|
|
136
|
+
"""
|
|
137
|
+
return cast_to.model_validate(response.json())
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Base asynchronous API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any, Self, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
11
|
+
|
|
12
|
+
from ..exceptions import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APITimeoutError,
|
|
15
|
+
InternalServerError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
RequestTimeoutError,
|
|
18
|
+
)
|
|
19
|
+
from ._base import _DEFAULT_MAX_RETRIES, _DEFAULT_TIMEOUT, _RETRY_MAX_WAIT, _RETRY_MULTIPLIER, _BaseClient
|
|
20
|
+
|
|
21
|
+
ResponseT = TypeVar("ResponseT", bound=BaseModel)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseAsyncAPIClient(_BaseClient):
|
|
25
|
+
"""Base asynchronous API client — same interface as BaseSyncAPIClient with async/await."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
api_key: str,
|
|
31
|
+
base_url: str,
|
|
32
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
33
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Args:
|
|
37
|
+
api_key: API key for authentication
|
|
38
|
+
base_url: Base URL for all API requests
|
|
39
|
+
timeout: Default timeout in seconds (default: 600.0)
|
|
40
|
+
max_retries: Default max retry attempts (default: 2)
|
|
41
|
+
"""
|
|
42
|
+
super().__init__(api_key=api_key, base_url=base_url, max_retries=max_retries)
|
|
43
|
+
|
|
44
|
+
self._client = httpx.AsyncClient(
|
|
45
|
+
base_url=base_url,
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
headers=self._build_default_headers(),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def _request(
|
|
51
|
+
self,
|
|
52
|
+
method: str,
|
|
53
|
+
path: str,
|
|
54
|
+
*,
|
|
55
|
+
json: dict[str, Any] | None = None,
|
|
56
|
+
params: dict[str, Any] | None = None,
|
|
57
|
+
) -> httpx.Response:
|
|
58
|
+
"""
|
|
59
|
+
Make async HTTP request with automatic retries.
|
|
60
|
+
|
|
61
|
+
Retryable errors: 408, 429, 5xx, and network errors.
|
|
62
|
+
Non-retryable errors (400, 401, 403, 404, …) propagate immediately.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
method: HTTP method (GET, POST, etc.)
|
|
66
|
+
path: URL path (relative to base_url)
|
|
67
|
+
json: JSON request body (optional)
|
|
68
|
+
params: Query parameters (optional)
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
APITimeoutError: Request timed out
|
|
72
|
+
APIConnectionError: Network/connection error
|
|
73
|
+
APIStatusError: HTTP error status (4xx, 5xx)
|
|
74
|
+
"""
|
|
75
|
+
max_attempts = self._max_retries + 1
|
|
76
|
+
|
|
77
|
+
# Retryable: 408, 429, 5xx, and network errors (TimeoutException ⊂ RequestError).
|
|
78
|
+
# Non-retryable status errors (400, 401, 403, 404, …) propagate immediately.
|
|
79
|
+
# After retries exhausted: httpx errors are wrapped into SDK types by the outer try/except.
|
|
80
|
+
@retry(
|
|
81
|
+
stop=stop_after_attempt(max_attempts),
|
|
82
|
+
wait=wait_exponential(multiplier=_RETRY_MULTIPLIER, max=_RETRY_MAX_WAIT),
|
|
83
|
+
retry=retry_if_exception_type(
|
|
84
|
+
(
|
|
85
|
+
RequestTimeoutError,
|
|
86
|
+
RateLimitError,
|
|
87
|
+
InternalServerError,
|
|
88
|
+
httpx.RequestError, # covers TimeoutException and connection errors
|
|
89
|
+
)
|
|
90
|
+
),
|
|
91
|
+
reraise=True,
|
|
92
|
+
)
|
|
93
|
+
async def _execute() -> httpx.Response:
|
|
94
|
+
response = await self._client.request(
|
|
95
|
+
method=method,
|
|
96
|
+
url=path,
|
|
97
|
+
json=json,
|
|
98
|
+
params=params,
|
|
99
|
+
)
|
|
100
|
+
if response.is_error:
|
|
101
|
+
raise self._make_status_error(response)
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return await _execute()
|
|
106
|
+
except httpx.TimeoutException as e:
|
|
107
|
+
raise APITimeoutError(str(e), request=e.request) from e
|
|
108
|
+
except httpx.RequestError as e:
|
|
109
|
+
raise APIConnectionError(str(e), request=e.request) from e
|
|
110
|
+
|
|
111
|
+
async def _get(
|
|
112
|
+
self,
|
|
113
|
+
path: str,
|
|
114
|
+
*,
|
|
115
|
+
cast_to: type[ResponseT],
|
|
116
|
+
params: dict[str, Any] | None = None,
|
|
117
|
+
) -> ResponseT:
|
|
118
|
+
"""Make GET request and parse the response into cast_to."""
|
|
119
|
+
response = await self._request("GET", path, params=params)
|
|
120
|
+
return self._parse_response(response, cast_to)
|
|
121
|
+
|
|
122
|
+
async def _post(
|
|
123
|
+
self,
|
|
124
|
+
path: str,
|
|
125
|
+
*,
|
|
126
|
+
json: dict[str, Any] | None = None,
|
|
127
|
+
cast_to: type[ResponseT],
|
|
128
|
+
) -> ResponseT:
|
|
129
|
+
"""Make POST request and parse the response into cast_to."""
|
|
130
|
+
response = await self._request("POST", path, json=json)
|
|
131
|
+
return self._parse_response(response, cast_to)
|
|
132
|
+
|
|
133
|
+
async def _post_stream(
|
|
134
|
+
self,
|
|
135
|
+
path: str,
|
|
136
|
+
*,
|
|
137
|
+
json: dict[str, Any] | None = None,
|
|
138
|
+
) -> AsyncIterator[str]:
|
|
139
|
+
"""POST request that streams raw response lines (for SSE). Does not parse JSON.
|
|
140
|
+
|
|
141
|
+
Retries the initial connection on 429/5xx with exponential backoff,
|
|
142
|
+
matching the retry behaviour of ``_request()``. Once a 200 response
|
|
143
|
+
is received and streaming begins, no further retries are attempted.
|
|
144
|
+
"""
|
|
145
|
+
max_attempts = self._max_retries + 1
|
|
146
|
+
|
|
147
|
+
@retry(
|
|
148
|
+
stop=stop_after_attempt(max_attempts),
|
|
149
|
+
wait=wait_exponential(multiplier=_RETRY_MULTIPLIER, max=_RETRY_MAX_WAIT),
|
|
150
|
+
retry=retry_if_exception_type(
|
|
151
|
+
(
|
|
152
|
+
RequestTimeoutError,
|
|
153
|
+
RateLimitError,
|
|
154
|
+
InternalServerError,
|
|
155
|
+
httpx.RequestError,
|
|
156
|
+
)
|
|
157
|
+
),
|
|
158
|
+
reraise=True,
|
|
159
|
+
)
|
|
160
|
+
async def _connect() -> httpx.Response:
|
|
161
|
+
response = await self._client.send(
|
|
162
|
+
self._client.build_request("POST", path, json=json),
|
|
163
|
+
stream=True,
|
|
164
|
+
)
|
|
165
|
+
if response.is_error:
|
|
166
|
+
await response.aread()
|
|
167
|
+
await response.aclose()
|
|
168
|
+
raise self._make_status_error(response)
|
|
169
|
+
return response
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
response = await _connect()
|
|
173
|
+
except httpx.TimeoutException as e:
|
|
174
|
+
raise APITimeoutError(str(e), request=e.request) from e
|
|
175
|
+
except httpx.RequestError as e:
|
|
176
|
+
raise APIConnectionError(str(e), request=e.request) from e
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
async for line in response.aiter_lines():
|
|
180
|
+
yield line
|
|
181
|
+
finally:
|
|
182
|
+
await response.aclose()
|
|
183
|
+
|
|
184
|
+
async def close(self) -> None:
|
|
185
|
+
"""Close the underlying HTTP client."""
|
|
186
|
+
await self._client.aclose()
|
|
187
|
+
|
|
188
|
+
async def __aenter__(self) -> Self:
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
async def __aexit__(self, *args: object) -> None:
|
|
192
|
+
await self.close()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Base synchronous API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any, Self, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
11
|
+
|
|
12
|
+
from ..exceptions import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APITimeoutError,
|
|
15
|
+
InternalServerError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
RequestTimeoutError,
|
|
18
|
+
)
|
|
19
|
+
from ._base import _DEFAULT_MAX_RETRIES, _DEFAULT_TIMEOUT, _RETRY_MAX_WAIT, _RETRY_MULTIPLIER, _BaseClient
|
|
20
|
+
|
|
21
|
+
ResponseT = TypeVar("ResponseT", bound=BaseModel)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseSyncAPIClient(_BaseClient):
|
|
25
|
+
"""Base synchronous API client with retries, error handling, and Pydantic response parsing."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
api_key: str,
|
|
31
|
+
base_url: str,
|
|
32
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
33
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Args:
|
|
37
|
+
api_key: API key for authentication
|
|
38
|
+
base_url: Base URL for all API requests
|
|
39
|
+
timeout: Default timeout in seconds (default: 600.0)
|
|
40
|
+
max_retries: Default max retry attempts (default: 2)
|
|
41
|
+
"""
|
|
42
|
+
super().__init__(api_key=api_key, base_url=base_url, max_retries=max_retries)
|
|
43
|
+
|
|
44
|
+
self._client = httpx.Client(
|
|
45
|
+
base_url=base_url,
|
|
46
|
+
timeout=timeout,
|
|
47
|
+
headers=self._build_default_headers(),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _request(
|
|
51
|
+
self,
|
|
52
|
+
method: str,
|
|
53
|
+
path: str,
|
|
54
|
+
*,
|
|
55
|
+
json: dict[str, Any] | None = None,
|
|
56
|
+
params: dict[str, Any] | None = None,
|
|
57
|
+
) -> httpx.Response:
|
|
58
|
+
"""
|
|
59
|
+
Make HTTP request with automatic retries.
|
|
60
|
+
|
|
61
|
+
Retryable errors: 408, 429, 5xx, and network errors.
|
|
62
|
+
Non-retryable errors (400, 401, 403, 404, …) propagate immediately.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
method: HTTP method (GET, POST, etc.)
|
|
66
|
+
path: URL path (relative to base_url)
|
|
67
|
+
json: JSON request body (optional)
|
|
68
|
+
params: Query parameters (optional)
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
APITimeoutError: Request timed out
|
|
72
|
+
APIConnectionError: Network/connection error
|
|
73
|
+
APIStatusError: HTTP error status (4xx, 5xx)
|
|
74
|
+
"""
|
|
75
|
+
max_attempts = self._max_retries + 1
|
|
76
|
+
|
|
77
|
+
# Retryable: 408, 429, 5xx, and network errors (TimeoutException ⊂ RequestError).
|
|
78
|
+
# Non-retryable status errors (400, 401, 403, 404, …) propagate immediately.
|
|
79
|
+
# After retries exhausted: httpx errors are wrapped into SDK types by the outer try/except.
|
|
80
|
+
@retry(
|
|
81
|
+
stop=stop_after_attempt(max_attempts),
|
|
82
|
+
wait=wait_exponential(multiplier=_RETRY_MULTIPLIER, max=_RETRY_MAX_WAIT),
|
|
83
|
+
retry=retry_if_exception_type(
|
|
84
|
+
(
|
|
85
|
+
RequestTimeoutError,
|
|
86
|
+
RateLimitError,
|
|
87
|
+
InternalServerError,
|
|
88
|
+
httpx.RequestError, # covers TimeoutException and connection errors
|
|
89
|
+
)
|
|
90
|
+
),
|
|
91
|
+
reraise=True,
|
|
92
|
+
)
|
|
93
|
+
def _execute() -> httpx.Response:
|
|
94
|
+
response = self._client.request(
|
|
95
|
+
method=method,
|
|
96
|
+
url=path,
|
|
97
|
+
json=json,
|
|
98
|
+
params=params,
|
|
99
|
+
)
|
|
100
|
+
if response.is_error:
|
|
101
|
+
raise self._make_status_error(response)
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return _execute()
|
|
106
|
+
except httpx.TimeoutException as e:
|
|
107
|
+
raise APITimeoutError(str(e), request=e.request) from e
|
|
108
|
+
except httpx.RequestError as e:
|
|
109
|
+
raise APIConnectionError(str(e), request=e.request) from e
|
|
110
|
+
|
|
111
|
+
def _get(
|
|
112
|
+
self,
|
|
113
|
+
path: str,
|
|
114
|
+
*,
|
|
115
|
+
cast_to: type[ResponseT],
|
|
116
|
+
params: dict[str, Any] | None = None,
|
|
117
|
+
) -> ResponseT:
|
|
118
|
+
"""Make GET request and parse the response into cast_to."""
|
|
119
|
+
response = self._request("GET", path, params=params)
|
|
120
|
+
return self._parse_response(response, cast_to)
|
|
121
|
+
|
|
122
|
+
def _post(
|
|
123
|
+
self,
|
|
124
|
+
path: str,
|
|
125
|
+
*,
|
|
126
|
+
json: dict[str, Any] | None = None,
|
|
127
|
+
cast_to: type[ResponseT],
|
|
128
|
+
) -> ResponseT:
|
|
129
|
+
"""Make POST request and parse the response into cast_to."""
|
|
130
|
+
response = self._request("POST", path, json=json)
|
|
131
|
+
return self._parse_response(response, cast_to)
|
|
132
|
+
|
|
133
|
+
def _post_stream(
|
|
134
|
+
self,
|
|
135
|
+
path: str,
|
|
136
|
+
*,
|
|
137
|
+
json: dict[str, Any] | None = None,
|
|
138
|
+
) -> Iterator[str]:
|
|
139
|
+
"""POST request that streams raw response lines (for SSE). Does not parse JSON.
|
|
140
|
+
|
|
141
|
+
Retries the initial connection on 429/5xx with exponential backoff,
|
|
142
|
+
matching the retry behaviour of ``_request()``. Once a 200 response
|
|
143
|
+
is received and streaming begins, no further retries are attempted.
|
|
144
|
+
"""
|
|
145
|
+
max_attempts = self._max_retries + 1
|
|
146
|
+
|
|
147
|
+
@retry(
|
|
148
|
+
stop=stop_after_attempt(max_attempts),
|
|
149
|
+
wait=wait_exponential(multiplier=_RETRY_MULTIPLIER, max=_RETRY_MAX_WAIT),
|
|
150
|
+
retry=retry_if_exception_type(
|
|
151
|
+
(
|
|
152
|
+
RequestTimeoutError,
|
|
153
|
+
RateLimitError,
|
|
154
|
+
InternalServerError,
|
|
155
|
+
httpx.RequestError,
|
|
156
|
+
)
|
|
157
|
+
),
|
|
158
|
+
reraise=True,
|
|
159
|
+
)
|
|
160
|
+
def _connect() -> httpx.Response:
|
|
161
|
+
response = self._client.send(
|
|
162
|
+
self._client.build_request("POST", path, json=json),
|
|
163
|
+
stream=True,
|
|
164
|
+
)
|
|
165
|
+
if response.is_error:
|
|
166
|
+
response.read()
|
|
167
|
+
response.close()
|
|
168
|
+
raise self._make_status_error(response)
|
|
169
|
+
return response
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
response = _connect()
|
|
173
|
+
except httpx.TimeoutException as e:
|
|
174
|
+
raise APITimeoutError(str(e), request=e.request) from e
|
|
175
|
+
except httpx.RequestError as e:
|
|
176
|
+
raise APIConnectionError(str(e), request=e.request) from e
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
yield from response.iter_lines()
|
|
180
|
+
finally:
|
|
181
|
+
response.close()
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
"""Close the underlying HTTP client."""
|
|
185
|
+
self._client.close()
|
|
186
|
+
|
|
187
|
+
def __enter__(self) -> Self:
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def __exit__(self, *args: object) -> None:
|
|
191
|
+
self.close()
|