runapi-core 0.7.0__tar.gz → 0.8.0__tar.gz
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.
- {runapi_core-0.7.0 → runapi_core-0.8.0}/PKG-INFO +16 -2
- {runapi_core-0.7.0 → runapi_core-0.8.0}/README.md +14 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/pyproject.toml +1 -1
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/__init__.py +4 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/contract_gen.py +6 -2
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/errors.py +13 -6
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/http_client.py +42 -6
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/models.py +17 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/polling.py +40 -11
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/resource.py +140 -19
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/response.py +8 -1
- runapi_core-0.8.0/src/runapi/core/version.py +1 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_http_client.py +68 -0
- runapi_core-0.8.0/tests/test_resource.py +420 -0
- runapi_core-0.7.0/src/runapi/core/version.py +0 -1
- runapi_core-0.7.0/tests/test_resource.py +0 -188
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/account.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/auth.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/config.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/constants.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/files.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/multipart.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/options.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/pricing.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/provider_client.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/py.typed +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/src/runapi/core/uploads.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_account.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_auth.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_config.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_errors.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_files.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_models.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_polling.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_pricing.py +0 -0
- {runapi_core-0.7.0 → runapi_core-0.8.0}/tests/test_uploads.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: runapi-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: RunAPI core SDK for JavaScript, Python, Ruby, Go, Java, and PHP
|
|
5
5
|
Project-URL: Homepage, https://runapi.ai/models
|
|
6
6
|
Project-URL: Documentation, https://runapi.ai/docs/resources/sdks
|
|
7
7
|
Project-URL: Source, https://github.com/runapi-ai/core-sdk
|
|
8
8
|
Project-URL: Issues, https://github.com/runapi-ai/core-sdk/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md
|
|
10
|
-
Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.
|
|
10
|
+
Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.8.0
|
|
11
11
|
Author-email: RunAPI <contact@runapi.ai>
|
|
12
12
|
License-Expression: Apache-2.0
|
|
13
13
|
Keywords: api,core,golang,gradle,java,maven,pypi,python,ruby,runapi,runapi-ai,sdk,typescript
|
|
@@ -41,6 +41,20 @@ RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use
|
|
|
41
41
|
|
|
42
42
|
Task-creation calls also accept an optional opaque `Idempotency-Key` up to 512 characters. Generate one value per logical task and reuse it only with identical input after an unknown result. Reusing the value with different input returns `409 Conflict`; do not derive it from `X-Client-Request-Id`.
|
|
43
43
|
|
|
44
|
+
## Recovering Interrupted Requests
|
|
45
|
+
|
|
46
|
+
Normal `run()` calls wait for and return the endpoint's terminal result. It also generates an `Idempotency-Key` before the initial POST when one was not supplied. If a lower-level request is interrupted after RunAPI accepted it, persist the opaque `Location` from the response and call `subscribe(location)` to recover the terminal result. Completed JSON responses keep their endpoint-specific type; text, SRT, and VTT responses return `str`.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from runapi.midjourney import MidjourneyClient
|
|
50
|
+
|
|
51
|
+
client = MidjourneyClient(api_key="sk-...")
|
|
52
|
+
result = client.shorten_prompt.run(prompt="A detailed cinematic mountain landscape")
|
|
53
|
+
|
|
54
|
+
# Persist this opaque URL after receiving 202 from a lower-level request.
|
|
55
|
+
result = client.shorten_prompt.subscribe("https://runapi.ai/api/v1/tasks/task-id")
|
|
56
|
+
```
|
|
57
|
+
|
|
44
58
|
High-level Python Provider Client resource methods accept per-request options and keep response headers on the returned model object. This example uses the Suno Provider Client; install `runapi-suno` to run it.
|
|
45
59
|
|
|
46
60
|
```python
|
|
@@ -24,6 +24,20 @@ RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use
|
|
|
24
24
|
|
|
25
25
|
Task-creation calls also accept an optional opaque `Idempotency-Key` up to 512 characters. Generate one value per logical task and reuse it only with identical input after an unknown result. Reusing the value with different input returns `409 Conflict`; do not derive it from `X-Client-Request-Id`.
|
|
26
26
|
|
|
27
|
+
## Recovering Interrupted Requests
|
|
28
|
+
|
|
29
|
+
Normal `run()` calls wait for and return the endpoint's terminal result. It also generates an `Idempotency-Key` before the initial POST when one was not supplied. If a lower-level request is interrupted after RunAPI accepted it, persist the opaque `Location` from the response and call `subscribe(location)` to recover the terminal result. Completed JSON responses keep their endpoint-specific type; text, SRT, and VTT responses return `str`.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from runapi.midjourney import MidjourneyClient
|
|
33
|
+
|
|
34
|
+
client = MidjourneyClient(api_key="sk-...")
|
|
35
|
+
result = client.shorten_prompt.run(prompt="A detailed cinematic mountain landscape")
|
|
36
|
+
|
|
37
|
+
# Persist this opaque URL after receiving 202 from a lower-level request.
|
|
38
|
+
result = client.shorten_prompt.subscribe("https://runapi.ai/api/v1/tasks/task-id")
|
|
39
|
+
```
|
|
40
|
+
|
|
27
41
|
High-level Python Provider Client resource methods accept per-request options and keep response headers on the returned model object. This example uses the Suno Provider Client; install `runapi-suno` to run it.
|
|
28
42
|
|
|
29
43
|
```python
|
|
@@ -19,7 +19,7 @@ Documentation = "https://runapi.ai/docs/resources/sdks"
|
|
|
19
19
|
Source = "https://github.com/runapi-ai/core-sdk"
|
|
20
20
|
Issues = "https://github.com/runapi-ai/core-sdk/issues"
|
|
21
21
|
Changelog = "https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md"
|
|
22
|
-
"Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.
|
|
22
|
+
"Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.8.0"
|
|
23
23
|
|
|
24
24
|
[tool.hatch.version]
|
|
25
25
|
path = "src/runapi/core/version.py"
|
|
@@ -34,6 +34,8 @@ from .models import (
|
|
|
34
34
|
BillingSettlement,
|
|
35
35
|
DynamicModel,
|
|
36
36
|
TaskBillingFacts,
|
|
37
|
+
TaskResult,
|
|
38
|
+
TaskResultResponse,
|
|
37
39
|
TaskResponse,
|
|
38
40
|
optional,
|
|
39
41
|
required,
|
|
@@ -82,6 +84,8 @@ __all__ = [
|
|
|
82
84
|
"BaseModel",
|
|
83
85
|
"DynamicModel",
|
|
84
86
|
"TaskResponse",
|
|
87
|
+
"TaskResult",
|
|
88
|
+
"TaskResultResponse",
|
|
85
89
|
"BillingReservation",
|
|
86
90
|
"BillingSettlement",
|
|
87
91
|
"BillingRefund",
|
|
@@ -341,7 +341,11 @@ CONTRACT = {
|
|
|
341
341
|
"models": ["grok-imagine-edit-image", "grok-imagine-image-2-0"],
|
|
342
342
|
"fields_by_model": {
|
|
343
343
|
"grok-imagine-edit-image": {},
|
|
344
|
-
"grok-imagine-image-2-0": {
|
|
344
|
+
"grok-imagine-image-2-0": {
|
|
345
|
+
"aspect_ratio": {
|
|
346
|
+
"enum": ["1:1", "2:3", "3:2", "16:9", "9:16", "auto"]
|
|
347
|
+
}
|
|
348
|
+
}
|
|
345
349
|
}
|
|
346
350
|
},
|
|
347
351
|
"grok-imagine/extend": {
|
|
@@ -2072,7 +2076,7 @@ CONTRACT = {
|
|
|
2072
2076
|
"fields_by_model": {
|
|
2073
2077
|
"topaz-upscale-image": {
|
|
2074
2078
|
"upscale_factor": {
|
|
2075
|
-
"enum": [1, 2, 4
|
|
2079
|
+
"enum": [1, 2, 4]
|
|
2076
2080
|
}
|
|
2077
2081
|
}
|
|
2078
2082
|
}
|
|
@@ -204,10 +204,17 @@ def error_from_response(response: "Any") -> Error:
|
|
|
204
204
|
Maps the status code to a specific error class and extracts the message,
|
|
205
205
|
request id, response details, and (for 429) the retry-after delay.
|
|
206
206
|
"""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
207
|
+
return error_from_response_data(
|
|
208
|
+
response.status_code,
|
|
209
|
+
_parse_body(response.text),
|
|
210
|
+
response.headers,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def error_from_response_data(status: int, body: Any, headers: Any = None) -> Error:
|
|
214
|
+
"""Build a public SDK error from a stored terminal response checkpoint."""
|
|
215
|
+
response_headers = ResponseHeaders(headers)
|
|
216
|
+
request_id = response_headers.get("x-request-id")
|
|
217
|
+
parsed_body = body
|
|
211
218
|
message = _extract_message(parsed_body) or DEFAULT_MESSAGES.get(status) or "Request failed"
|
|
212
219
|
|
|
213
220
|
error_class = STATUS_MAP.get(status, Error)
|
|
@@ -217,10 +224,10 @@ def error_from_response(response: "Any") -> Error:
|
|
|
217
224
|
"status": status,
|
|
218
225
|
"request_id": request_id,
|
|
219
226
|
"details": parsed_body,
|
|
220
|
-
"response_headers":
|
|
227
|
+
"response_headers": response_headers,
|
|
221
228
|
}
|
|
222
229
|
if error_class is RateLimitError:
|
|
223
|
-
kwargs["retry_after"] = _parse_retry_after(
|
|
230
|
+
kwargs["retry_after"] = _parse_retry_after(response_headers.get("retry-after"))
|
|
224
231
|
|
|
225
232
|
return error_class(message, **kwargs)
|
|
226
233
|
|
|
@@ -6,11 +6,12 @@ import json
|
|
|
6
6
|
import random
|
|
7
7
|
import time
|
|
8
8
|
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
from urllib.parse import urlsplit
|
|
9
10
|
|
|
10
11
|
import httpx
|
|
11
12
|
|
|
12
13
|
from . import constants
|
|
13
|
-
from .errors import NetworkError, RateLimitError, TimeoutError, error_from_response
|
|
14
|
+
from .errors import NetworkError, RateLimitError, TimeoutError, ValidationError, error_from_response
|
|
14
15
|
from .multipart import MultipartBody
|
|
15
16
|
from .options import ClientOptions, RequestOptions
|
|
16
17
|
from .response import ApiResponse
|
|
@@ -51,6 +52,7 @@ class HttpClient:
|
|
|
51
52
|
body: Any = None,
|
|
52
53
|
options: Optional[RequestOptions] = None,
|
|
53
54
|
) -> Any:
|
|
55
|
+
self._validate_request_url(path)
|
|
54
56
|
max_retries = self._options.max_retries
|
|
55
57
|
if options is not None and options.max_retries is not None:
|
|
56
58
|
max_retries = options.max_retries
|
|
@@ -75,8 +77,16 @@ class HttpClient:
|
|
|
75
77
|
timeout=timeout,
|
|
76
78
|
)
|
|
77
79
|
except httpx.TimeoutException as exc:
|
|
80
|
+
if self._retryable_request(method, headers) and retries < max_retries:
|
|
81
|
+
retries += 1
|
|
82
|
+
time.sleep(self._retry_delay(retries, TimeoutError(str(exc))))
|
|
83
|
+
continue
|
|
78
84
|
raise TimeoutError(str(exc))
|
|
79
85
|
except httpx.TransportError as exc:
|
|
86
|
+
if self._retryable_request(method, headers) and retries < max_retries:
|
|
87
|
+
retries += 1
|
|
88
|
+
time.sleep(self._retry_delay(retries, NetworkError(str(exc))))
|
|
89
|
+
continue
|
|
80
90
|
raise NetworkError(str(exc))
|
|
81
91
|
|
|
82
92
|
if response.status_code == 304:
|
|
@@ -87,11 +97,15 @@ class HttpClient:
|
|
|
87
97
|
if body is None:
|
|
88
98
|
return None
|
|
89
99
|
if isinstance(body, (dict, list)):
|
|
90
|
-
return ApiResponse(body, response.headers)
|
|
100
|
+
return ApiResponse(body, response.headers, status_code=response.status_code)
|
|
91
101
|
return body
|
|
92
102
|
|
|
93
103
|
error = error_from_response(response)
|
|
94
|
-
if
|
|
104
|
+
if (
|
|
105
|
+
self._retryable_request(method, headers)
|
|
106
|
+
and response.status_code in constants.RETRYABLE_STATUS_CODES
|
|
107
|
+
and retries < max_retries
|
|
108
|
+
):
|
|
95
109
|
retries += 1
|
|
96
110
|
time.sleep(self._retry_delay(retries, error))
|
|
97
111
|
continue
|
|
@@ -122,6 +136,7 @@ class HttpClient:
|
|
|
122
136
|
options: Optional[RequestOptions] = None,
|
|
123
137
|
) -> bytes:
|
|
124
138
|
"""Return a successful response body without text decoding."""
|
|
139
|
+
self._validate_request_url(path)
|
|
125
140
|
max_retries = self._options.max_retries
|
|
126
141
|
if options is not None and options.max_retries is not None:
|
|
127
142
|
max_retries = options.max_retries
|
|
@@ -140,7 +155,11 @@ class HttpClient:
|
|
|
140
155
|
if response.is_success:
|
|
141
156
|
return response.content
|
|
142
157
|
error = error_from_response(response)
|
|
143
|
-
if
|
|
158
|
+
if (
|
|
159
|
+
self._retryable_request(method, headers)
|
|
160
|
+
and response.status_code in constants.RETRYABLE_STATUS_CODES
|
|
161
|
+
and retries < max_retries
|
|
162
|
+
):
|
|
144
163
|
retries += 1
|
|
145
164
|
time.sleep(self._retry_delay(retries, error))
|
|
146
165
|
continue
|
|
@@ -172,8 +191,25 @@ class HttpClient:
|
|
|
172
191
|
return body, None, None, []
|
|
173
192
|
return None, None, None, []
|
|
174
193
|
|
|
175
|
-
def
|
|
176
|
-
return method in constants.IDEMPOTENT_METHODS
|
|
194
|
+
def _retryable_request(self, method: str, headers: Dict[str, str]) -> bool:
|
|
195
|
+
return method in constants.IDEMPOTENT_METHODS or (
|
|
196
|
+
method == "POST"
|
|
197
|
+
and any(name.lower() == "idempotency-key" and str(value).strip() for name, value in headers.items())
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def _validate_request_url(self, path: str) -> None:
|
|
201
|
+
requested = urlsplit(path)
|
|
202
|
+
if not requested.scheme and not requested.netloc:
|
|
203
|
+
return
|
|
204
|
+
configured = urlsplit(str(self._options.base_url))
|
|
205
|
+
if self._origin(requested) != self._origin(configured):
|
|
206
|
+
raise ValidationError("Request URL must use the configured RunAPI origin")
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _origin(url: Any) -> Tuple[str, str, Optional[int]]:
|
|
210
|
+
scheme = url.scheme.lower()
|
|
211
|
+
port = url.port or ({"http": 80, "https": 443}.get(scheme))
|
|
212
|
+
return scheme, (url.hostname or "").lower(), port
|
|
177
213
|
|
|
178
214
|
def _retry_delay(self, attempt: int, error: Any) -> float:
|
|
179
215
|
if isinstance(error, RateLimitError) and error.retry_after and error.retry_after > 0:
|
|
@@ -266,6 +266,23 @@ class TaskResponse(BaseModel):
|
|
|
266
266
|
billing = optional(lambda: TaskBillingFacts)
|
|
267
267
|
|
|
268
268
|
|
|
269
|
+
class TaskResultResponse(BaseModel):
|
|
270
|
+
"""The persisted public response checkpoint for a terminal Task."""
|
|
271
|
+
|
|
272
|
+
status = required(int)
|
|
273
|
+
content_type = required(str)
|
|
274
|
+
headers = optional(dict)
|
|
275
|
+
body = optional()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TaskResult(TaskResponse):
|
|
279
|
+
"""The account-scoped Task Result resource returned from an opaque Location."""
|
|
280
|
+
|
|
281
|
+
id = required(str)
|
|
282
|
+
status = required(str, enum=lambda: TaskResponse.Status.ALL)
|
|
283
|
+
response = optional(lambda: TaskResultResponse)
|
|
284
|
+
|
|
285
|
+
|
|
269
286
|
class BillingReservation(BaseModel):
|
|
270
287
|
amount_cents = required(int)
|
|
271
288
|
|
|
@@ -5,14 +5,21 @@ from __future__ import annotations
|
|
|
5
5
|
import time
|
|
6
6
|
from typing import Any, Callable, Optional
|
|
7
7
|
|
|
8
|
-
from .errors import TaskFailedError, TaskTimeoutError
|
|
8
|
+
from .errors import TaskFailedError, TaskTimeoutError, _parse_retry_after
|
|
9
9
|
from .models import BaseModel
|
|
10
10
|
from .options import PollingOptions
|
|
11
11
|
|
|
12
12
|
ACTIVE_STATUSES = ("pending", "processing")
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def poll_until_complete(
|
|
15
|
+
def poll_until_complete(
|
|
16
|
+
fetch: Callable[[], Any],
|
|
17
|
+
options: Optional[PollingOptions] = None,
|
|
18
|
+
*,
|
|
19
|
+
initial_delay: Optional[float] = None,
|
|
20
|
+
completed: Optional[Callable[[Any], Any]] = None,
|
|
21
|
+
failed: Optional[Callable[[Any], Exception]] = None,
|
|
22
|
+
) -> Any:
|
|
16
23
|
"""Call ``fetch`` repeatedly until the task completes.
|
|
17
24
|
|
|
18
25
|
Returns the completed response. Raises :class:`TaskFailedError` on a failed
|
|
@@ -22,15 +29,30 @@ def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptio
|
|
|
22
29
|
options = PollingOptions()
|
|
23
30
|
|
|
24
31
|
deadline = time.monotonic() + options.max_wait
|
|
32
|
+
wait = initial_delay
|
|
33
|
+
response = None
|
|
25
34
|
|
|
26
35
|
while True:
|
|
36
|
+
if wait and wait > 0:
|
|
37
|
+
remaining = deadline - time.monotonic()
|
|
38
|
+
if remaining <= 0:
|
|
39
|
+
raise _timeout_error(options, response)
|
|
40
|
+
time.sleep(min(wait, remaining))
|
|
41
|
+
|
|
42
|
+
if (response is not None or (wait and wait > 0)) and time.monotonic() >= deadline:
|
|
43
|
+
raise _timeout_error(options, response)
|
|
44
|
+
|
|
27
45
|
response = fetch()
|
|
46
|
+
if time.monotonic() >= deadline:
|
|
47
|
+
raise _timeout_error(options, response)
|
|
28
48
|
status = str(_value_for(response, "status") or "").lower()
|
|
29
49
|
|
|
30
50
|
if status == "completed":
|
|
31
|
-
return response
|
|
51
|
+
return completed(response) if completed else response
|
|
32
52
|
|
|
33
53
|
if status == "failed":
|
|
54
|
+
if failed:
|
|
55
|
+
raise failed(response)
|
|
34
56
|
message = _value_for(response, "error") or "Task failed"
|
|
35
57
|
raise TaskFailedError(
|
|
36
58
|
message,
|
|
@@ -38,13 +60,6 @@ def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptio
|
|
|
38
60
|
response_headers=_response_headers_for(response),
|
|
39
61
|
)
|
|
40
62
|
|
|
41
|
-
if time.monotonic() >= deadline:
|
|
42
|
-
raise TaskTimeoutError(
|
|
43
|
-
f"Task polling timed out after {options.max_wait}s",
|
|
44
|
-
details=_details_for(response),
|
|
45
|
-
response_headers=_response_headers_for(response),
|
|
46
|
-
)
|
|
47
|
-
|
|
48
63
|
if status not in ACTIVE_STATUSES:
|
|
49
64
|
raise TaskFailedError(
|
|
50
65
|
f"Unknown task status: {status}",
|
|
@@ -52,7 +67,15 @@ def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptio
|
|
|
52
67
|
response_headers=_response_headers_for(response),
|
|
53
68
|
)
|
|
54
69
|
|
|
55
|
-
|
|
70
|
+
wait = _retry_after_for(response) or options.poll_interval
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _timeout_error(options: PollingOptions, response: Any) -> TaskTimeoutError:
|
|
74
|
+
return TaskTimeoutError(
|
|
75
|
+
f"Task polling timed out after {options.max_wait}s",
|
|
76
|
+
details=_details_for(response) if response is not None else None,
|
|
77
|
+
response_headers=_response_headers_for(response) if response is not None else None,
|
|
78
|
+
)
|
|
56
79
|
|
|
57
80
|
|
|
58
81
|
def _value_for(response: Any, key: str) -> Any:
|
|
@@ -69,3 +92,9 @@ def _details_for(response: Any) -> Any:
|
|
|
69
92
|
|
|
70
93
|
def _response_headers_for(response: Any) -> Any:
|
|
71
94
|
return response.response_headers if hasattr(response, "response_headers") else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _retry_after_for(response: Any) -> Optional[float]:
|
|
98
|
+
headers = _response_headers_for(response)
|
|
99
|
+
delay = _parse_retry_after(headers.get("Retry-After") if headers else None)
|
|
100
|
+
return delay if delay and delay > 0 else None
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from dataclasses import replace
|
|
5
6
|
from typing import Any, Callable, Dict, Optional, Sequence
|
|
7
|
+
from uuid import uuid4
|
|
6
8
|
|
|
7
9
|
from . import polling
|
|
8
|
-
from .errors import ValidationError
|
|
9
|
-
from .models import BaseModel, TaskResponse
|
|
10
|
+
from .errors import TaskFailedError, ValidationError, _parse_retry_after, error_from_response_data
|
|
11
|
+
from .models import BaseModel, TaskResult, TaskResponse
|
|
10
12
|
from .options import PollingOptions, RequestOptions
|
|
11
13
|
from .response import ApiResponse
|
|
12
14
|
|
|
@@ -19,7 +21,7 @@ class Resource:
|
|
|
19
21
|
re-coerces to once the task completes).
|
|
20
22
|
"""
|
|
21
23
|
|
|
22
|
-
RESPONSE_CLASS: type = TaskResponse
|
|
24
|
+
RESPONSE_CLASS: Optional[type] = TaskResponse
|
|
23
25
|
COMPLETED_RESPONSE_CLASS: Optional[type] = None
|
|
24
26
|
|
|
25
27
|
def __init__(self, http: Any) -> None:
|
|
@@ -34,12 +36,146 @@ class Resource:
|
|
|
34
36
|
response_class: Optional[type] = None,
|
|
35
37
|
) -> Any:
|
|
36
38
|
response = self._http.request(method, path, body=body, options=options)
|
|
39
|
+
return self._coerce_response(response, response_class)
|
|
40
|
+
|
|
41
|
+
def _coerce_response(self, response: Any, response_class: Optional[type] = None) -> Any:
|
|
37
42
|
payload = response.body if isinstance(response, ApiResponse) else response
|
|
38
|
-
|
|
43
|
+
target = response_class or type(self).RESPONSE_CLASS
|
|
44
|
+
result = payload if target is None else BaseModel.coerce(payload, as_=target)
|
|
39
45
|
if isinstance(response, ApiResponse):
|
|
40
46
|
self._attach_response_headers(result, response.response_headers)
|
|
41
47
|
return result
|
|
42
48
|
|
|
49
|
+
def _run_hybrid(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
path: str,
|
|
53
|
+
body: Any = None,
|
|
54
|
+
options: Optional[RequestOptions] = None,
|
|
55
|
+
polling_options: Optional[PollingOptions] = None,
|
|
56
|
+
) -> Any:
|
|
57
|
+
"""Run a terminal-or-accepted Task endpoint through one stable API."""
|
|
58
|
+
request_options = self._with_idempotency_key(options) if method.upper() == "POST" else options
|
|
59
|
+
response = self._http.request(method, path, body=body, options=request_options)
|
|
60
|
+
if isinstance(response, ApiResponse) and response.status_code == 202:
|
|
61
|
+
location = response.response_headers.get("Location")
|
|
62
|
+
if not location:
|
|
63
|
+
raise TaskFailedError(
|
|
64
|
+
"Accepted Task response is missing Location",
|
|
65
|
+
response_headers=response.response_headers,
|
|
66
|
+
)
|
|
67
|
+
return self.subscribe(
|
|
68
|
+
location,
|
|
69
|
+
options=request_options,
|
|
70
|
+
polling_options=polling_options,
|
|
71
|
+
initial_delay=self._retry_after(response.response_headers),
|
|
72
|
+
)
|
|
73
|
+
return self._coerce_response(response)
|
|
74
|
+
|
|
75
|
+
def subscribe(
|
|
76
|
+
self,
|
|
77
|
+
location: str,
|
|
78
|
+
*,
|
|
79
|
+
options: Optional[RequestOptions] = None,
|
|
80
|
+
polling_options: Optional[PollingOptions] = None,
|
|
81
|
+
initial_delay: Optional[float] = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""Follow an opaque Task Result URL until its stored terminal response is ready."""
|
|
84
|
+
return self._poll_until_complete(
|
|
85
|
+
lambda: self._request("get", location, options=options, response_class=TaskResult),
|
|
86
|
+
polling_options,
|
|
87
|
+
initial_delay=initial_delay,
|
|
88
|
+
completed=self._decode_task_result,
|
|
89
|
+
failed=self._task_result_error,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _with_idempotency_key(self, options: Optional[RequestOptions]) -> RequestOptions:
|
|
93
|
+
headers = {}
|
|
94
|
+
idempotency_key = None
|
|
95
|
+
source_headers = (options.headers or {}) if options else {}
|
|
96
|
+
for name, value in source_headers.items():
|
|
97
|
+
if name.lower() != "idempotency-key":
|
|
98
|
+
headers[name] = value
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
normalized = str(value).strip()
|
|
102
|
+
if not normalized:
|
|
103
|
+
continue
|
|
104
|
+
if idempotency_key is not None and idempotency_key != normalized:
|
|
105
|
+
raise ValidationError("conflicting Idempotency-Key headers")
|
|
106
|
+
idempotency_key = normalized
|
|
107
|
+
|
|
108
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid4())
|
|
109
|
+
return replace(options, headers=headers) if options else RequestOptions(headers=headers)
|
|
110
|
+
|
|
111
|
+
def _decode_task_result(self, task: TaskResult) -> Any:
|
|
112
|
+
response = task.response
|
|
113
|
+
if response is None:
|
|
114
|
+
raise TaskFailedError("Completed Task is missing its terminal response", details=task.to_dict())
|
|
115
|
+
if not 200 <= response.status < 300:
|
|
116
|
+
raise self._task_result_error(task)
|
|
117
|
+
|
|
118
|
+
body = self._plain_value(response.body)
|
|
119
|
+
content_type = response.content_type.lower().split(";", 1)[0].strip()
|
|
120
|
+
if content_type == "application/json" or content_type.endswith("+json"):
|
|
121
|
+
response_class = type(self).RESPONSE_CLASS
|
|
122
|
+
result = body if response_class is None else BaseModel.coerce(body, as_=response_class)
|
|
123
|
+
self._attach_response_headers(result, response.headers or {})
|
|
124
|
+
return result
|
|
125
|
+
if self._textual_content_type(content_type):
|
|
126
|
+
if isinstance(body, bytes):
|
|
127
|
+
return body.decode("utf-8")
|
|
128
|
+
return "" if body is None else str(body)
|
|
129
|
+
if isinstance(body, bytes):
|
|
130
|
+
return body
|
|
131
|
+
if isinstance(body, str):
|
|
132
|
+
return body.encode("utf-8")
|
|
133
|
+
return body
|
|
134
|
+
|
|
135
|
+
def _task_result_error(self, task: TaskResult) -> Exception:
|
|
136
|
+
response = task.response
|
|
137
|
+
if response is None:
|
|
138
|
+
return TaskFailedError("Task failed without a terminal response", details=task.to_dict())
|
|
139
|
+
return error_from_response_data(
|
|
140
|
+
response.status,
|
|
141
|
+
self._plain_value(response.body),
|
|
142
|
+
response.headers or {},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def _poll_until_complete(
|
|
146
|
+
self,
|
|
147
|
+
fetch: Callable[[], Any],
|
|
148
|
+
polling_opts: Optional[PollingOptions] = None,
|
|
149
|
+
**kwargs: Any,
|
|
150
|
+
) -> Any:
|
|
151
|
+
response = polling.poll_until_complete(fetch, polling_opts or PollingOptions(), **kwargs)
|
|
152
|
+
|
|
153
|
+
if kwargs.get("completed"):
|
|
154
|
+
return response
|
|
155
|
+
|
|
156
|
+
completed_class = type(self).COMPLETED_RESPONSE_CLASS
|
|
157
|
+
if completed_class is None or isinstance(response, completed_class):
|
|
158
|
+
return response
|
|
159
|
+
|
|
160
|
+
payload = response.to_dict() if isinstance(response, BaseModel) else response
|
|
161
|
+
completed = completed_class.from_dict(payload)
|
|
162
|
+
if isinstance(response, BaseModel):
|
|
163
|
+
completed._with_response_headers(response.response_headers)
|
|
164
|
+
return completed
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _plain_value(value: Any) -> Any:
|
|
168
|
+
return value.to_dict() if isinstance(value, BaseModel) else value
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _textual_content_type(content_type: str) -> bool:
|
|
172
|
+
return content_type.startswith("text/") or content_type in {"application/srt", "application/x-subrip"}
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _retry_after(headers: Any) -> Optional[float]:
|
|
176
|
+
delay = _parse_retry_after(headers.get("Retry-After") if headers else None)
|
|
177
|
+
return delay if delay and delay > 0 else None
|
|
178
|
+
|
|
43
179
|
@staticmethod
|
|
44
180
|
def _compact_params(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
45
181
|
return {
|
|
@@ -249,21 +385,6 @@ class Resource:
|
|
|
249
385
|
return len(value) > 0
|
|
250
386
|
return True
|
|
251
387
|
|
|
252
|
-
def _poll_until_complete(
|
|
253
|
-
self, fetch: Callable[[], Any], polling_opts: Optional[PollingOptions] = None
|
|
254
|
-
) -> Any:
|
|
255
|
-
response = polling.poll_until_complete(fetch, polling_opts or PollingOptions())
|
|
256
|
-
|
|
257
|
-
completed_class = type(self).COMPLETED_RESPONSE_CLASS
|
|
258
|
-
if completed_class is None or isinstance(response, completed_class):
|
|
259
|
-
return response
|
|
260
|
-
|
|
261
|
-
payload = response.to_dict() if isinstance(response, BaseModel) else response
|
|
262
|
-
completed = completed_class.from_dict(payload)
|
|
263
|
-
if isinstance(response, BaseModel):
|
|
264
|
-
completed._with_response_headers(response.response_headers)
|
|
265
|
-
return completed
|
|
266
|
-
|
|
267
388
|
def _attach_response_headers(self, result: Any, headers: Any) -> None:
|
|
268
389
|
if isinstance(result, BaseModel):
|
|
269
390
|
result._with_response_headers(headers)
|
|
@@ -35,11 +35,18 @@ class ApiResponse(dict[str, Any]):
|
|
|
35
35
|
that index the returned JSON body keep working.
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
|
-
def __init__(
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
body: Mapping[str, Any] | Sequence[Any],
|
|
41
|
+
headers: Optional[Mapping[str, Any]] = None,
|
|
42
|
+
*,
|
|
43
|
+
status_code: Optional[int] = None,
|
|
44
|
+
) -> None:
|
|
39
45
|
super().__init__(body if isinstance(body, Mapping) else {})
|
|
40
46
|
self.body = self if isinstance(body, Mapping) else body
|
|
41
47
|
self.response_headers = headers if isinstance(headers, ResponseHeaders) else ResponseHeaders(headers)
|
|
42
48
|
self.headers = self.response_headers
|
|
49
|
+
self.status_code = status_code
|
|
43
50
|
|
|
44
51
|
def __getitem__(self, key: Any) -> Any:
|
|
45
52
|
if self.body is not self:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.0"
|
|
@@ -31,6 +31,25 @@ def test_returns_parsed_json_on_success():
|
|
|
31
31
|
assert client.request("get", "/api/v1/test") == {"id": "123"}
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def test_rejects_cross_origin_absolute_url_before_sending_credentials():
|
|
35
|
+
requests = []
|
|
36
|
+
client = make_client(lambda request: requests.append(request) or httpx.Response(200, json={}))
|
|
37
|
+
|
|
38
|
+
with pytest.raises(errors.ValidationError, match="configured RunAPI origin"):
|
|
39
|
+
client.request("get", "https://attacker.example/tasks/task-1")
|
|
40
|
+
|
|
41
|
+
assert requests == []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_accepts_absolute_url_on_configured_origin():
|
|
45
|
+
requested_urls = []
|
|
46
|
+
client = make_client(lambda request: requested_urls.append(str(request.url)) or httpx.Response(200, json={}))
|
|
47
|
+
|
|
48
|
+
client.request("get", "https://runapi.ai/api/v1/tasks/task-1")
|
|
49
|
+
|
|
50
|
+
assert requested_urls == ["https://runapi.ai/api/v1/tasks/task-1"]
|
|
51
|
+
|
|
52
|
+
|
|
34
53
|
def test_keeps_response_headers_on_success():
|
|
35
54
|
client = make_client(
|
|
36
55
|
lambda request: httpx.Response(
|
|
@@ -198,6 +217,55 @@ def test_does_not_retry_post_on_503():
|
|
|
198
217
|
assert calls["n"] == 1
|
|
199
218
|
|
|
200
219
|
|
|
220
|
+
def test_retries_post_with_idempotency_key_and_reuses_the_key():
|
|
221
|
+
calls = []
|
|
222
|
+
|
|
223
|
+
def handler(request):
|
|
224
|
+
calls.append(request.headers["idempotency-key"])
|
|
225
|
+
if len(calls) == 1:
|
|
226
|
+
return httpx.Response(503, json={"error": "down"})
|
|
227
|
+
return httpx.Response(200, json={"ok": True})
|
|
228
|
+
|
|
229
|
+
assert make_client(handler).request(
|
|
230
|
+
"post",
|
|
231
|
+
"/api/v1/test",
|
|
232
|
+
body={"prompt": "hello"},
|
|
233
|
+
options=RequestOptions(headers={"Idempotency-Key": "task-key"}),
|
|
234
|
+
) == {"ok": True}
|
|
235
|
+
assert calls == ["task-key", "task-key"]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_does_not_retry_post_with_blank_idempotency_key():
|
|
239
|
+
calls = {"n": 0}
|
|
240
|
+
|
|
241
|
+
def handler(_request):
|
|
242
|
+
calls["n"] += 1
|
|
243
|
+
return httpx.Response(503, json={"error": "down"})
|
|
244
|
+
|
|
245
|
+
with pytest.raises(errors.ServiceUnavailableError):
|
|
246
|
+
make_client(handler).request(
|
|
247
|
+
"post",
|
|
248
|
+
"/api/v1/test",
|
|
249
|
+
body={"prompt": "hello"},
|
|
250
|
+
options=RequestOptions(headers={"Idempotency-Key": " "}),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
assert calls["n"] == 1
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_request_bytes_retries_idempotent_get():
|
|
257
|
+
calls = {"n": 0}
|
|
258
|
+
|
|
259
|
+
def handler(_request):
|
|
260
|
+
calls["n"] += 1
|
|
261
|
+
if calls["n"] == 1:
|
|
262
|
+
return httpx.Response(503, json={"error": "down"})
|
|
263
|
+
return httpx.Response(200, content=b"audio")
|
|
264
|
+
|
|
265
|
+
assert make_client(handler).request_bytes("get", "/api/v1/files/audio") == b"audio"
|
|
266
|
+
assert calls["n"] == 2
|
|
267
|
+
|
|
268
|
+
|
|
201
269
|
def test_respects_retry_after(monkeypatch):
|
|
202
270
|
slept = []
|
|
203
271
|
monkeypatch.setattr(http_client.time, "sleep", lambda seconds: slept.append(seconds))
|