workly 0.1.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.
- workly/__init__.py +114 -0
- workly/_async_client.py +494 -0
- workly/_client.py +490 -0
- workly/_errors.py +70 -0
- workly/_models.py +502 -0
- workly/_requests.py +322 -0
- workly/_signing.py +99 -0
- workly/_transport.py +59 -0
- workly/py.typed +0 -0
- workly-0.1.0.dist-info/METADATA +216 -0
- workly-0.1.0.dist-info/RECORD +13 -0
- workly-0.1.0.dist-info/WHEEL +4 -0
- workly-0.1.0.dist-info/licenses/LICENSE +202 -0
workly/_requests.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Request building shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Each API operation is a function returning a `Request`; the clients only
|
|
4
|
+
send it and decode the response, so the two stay in step.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
from typing import Any, Required, TypedDict
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
from ._models import AlertChannelType, HTTPMethod, RunState, TaskState
|
|
18
|
+
|
|
19
|
+
DEFAULT_URL = "http://localhost:7337"
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
__version__ = version("workly")
|
|
23
|
+
except PackageNotFoundError: # running from a source tree
|
|
24
|
+
__version__ = "0.0.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Request:
|
|
29
|
+
method: str
|
|
30
|
+
path: str
|
|
31
|
+
params: dict[str, str] = field(default_factory=dict[str, str])
|
|
32
|
+
json: Any = None
|
|
33
|
+
headers: dict[str, str] = field(default_factory=dict[str, str])
|
|
34
|
+
idempotent: bool = False
|
|
35
|
+
"""Safe to resend even if the server may have processed it."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ClientConfig:
|
|
40
|
+
base_url: str
|
|
41
|
+
headers: dict[str, str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def client_config(url: str | None, api_key: str | None) -> ClientConfig:
|
|
45
|
+
base_url = (url or os.environ.get("WORKLY_URL") or DEFAULT_URL).rstrip("/")
|
|
46
|
+
api_key = api_key if api_key is not None else os.environ.get("WORKLY_API_KEY")
|
|
47
|
+
headers = {"User-Agent": f"workly-python/{__version__}", "Accept": "application/json"}
|
|
48
|
+
if api_key:
|
|
49
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
50
|
+
return ClientConfig(base_url, headers)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TaskSpec(TypedDict, total=False):
|
|
54
|
+
"""One task for `tasks.enqueue_many`. Same fields as `tasks.enqueue`."""
|
|
55
|
+
|
|
56
|
+
url: Required[str]
|
|
57
|
+
payload: Any
|
|
58
|
+
body: str
|
|
59
|
+
content_type: str
|
|
60
|
+
method: HTTPMethod
|
|
61
|
+
headers: Mapping[str, str]
|
|
62
|
+
delay_seconds: float
|
|
63
|
+
run_at: datetime
|
|
64
|
+
idempotency_key: str
|
|
65
|
+
max_attempts: int
|
|
66
|
+
timeout_seconds: float
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def task_body(spec: Mapping[str, Any]) -> dict[str, Any]:
|
|
70
|
+
body: dict[str, Any] = {}
|
|
71
|
+
for key, value in spec.items():
|
|
72
|
+
if value is None:
|
|
73
|
+
continue
|
|
74
|
+
if key == "run_at":
|
|
75
|
+
value = _timestamp(value, "run_at")
|
|
76
|
+
elif key == "headers":
|
|
77
|
+
value = dict(value)
|
|
78
|
+
body[key] = value
|
|
79
|
+
if "payload" in body and "body" in body:
|
|
80
|
+
raise ValueError("pass payload or body, not both")
|
|
81
|
+
if "delay_seconds" in body and "run_at" in body:
|
|
82
|
+
raise ValueError("pass delay_seconds or run_at, not both")
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _timestamp(value: datetime, name: str) -> str:
|
|
87
|
+
if value.tzinfo is None:
|
|
88
|
+
raise ValueError(f"{name} must be timezone-aware, e.g. datetime.now(timezone.utc)")
|
|
89
|
+
return value.isoformat()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _q(segment: str) -> str:
|
|
93
|
+
return quote(segment, safe="")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _traceparent(traceparent: str | None) -> dict[str, str]:
|
|
97
|
+
return {"traceparent": traceparent} if traceparent else {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Queues
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def list_queues() -> Request:
|
|
104
|
+
return Request("GET", "/v1/queues", idempotent=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_queue(name: str) -> Request:
|
|
108
|
+
return Request("GET", f"/v1/queues/{_q(name)}", idempotent=True)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def put_queue(name: str, config: dict[str, Any]) -> Request:
|
|
112
|
+
body = {k: v for k, v in config.items() if v is not None}
|
|
113
|
+
return Request("PUT", f"/v1/queues/{_q(name)}", json=body, idempotent=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def delete_queue(name: str) -> Request:
|
|
117
|
+
return Request("DELETE", f"/v1/queues/{_q(name)}", idempotent=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def pause_queue(name: str) -> Request:
|
|
121
|
+
return Request("POST", f"/v1/queues/{_q(name)}/pause", idempotent=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resume_queue(name: str) -> Request:
|
|
125
|
+
return Request("POST", f"/v1/queues/{_q(name)}/resume", idempotent=True)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cancel_pending_tasks(name: str) -> Request:
|
|
129
|
+
return Request("POST", f"/v1/queues/{_q(name)}/cancel-pending", idempotent=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def retry_failed_tasks(name: str, last_error: str | None) -> Request:
|
|
133
|
+
params = {} if last_error is None else {"last_error": last_error}
|
|
134
|
+
return Request("POST", f"/v1/queues/{_q(name)}/retry-failed", params=params, idempotent=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_queue_failures(name: str, limit: int | None) -> Request:
|
|
138
|
+
params = {} if limit is None else {"limit": str(limit)}
|
|
139
|
+
return Request("GET", f"/v1/queues/{_q(name)}/failures", params=params, idempotent=True)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_queue_activity(name: str, since: datetime | None) -> Request:
|
|
143
|
+
params = {} if since is None else {"since": _timestamp(since, "since")}
|
|
144
|
+
return Request("GET", f"/v1/queues/{_q(name)}/activity", params=params, idempotent=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_activity(range: str | None) -> Request:
|
|
148
|
+
params = {} if range is None else {"range": range}
|
|
149
|
+
return Request("GET", "/v1/activity", params=params, idempotent=True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Tasks
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def enqueue(queue: str, spec: Mapping[str, Any], traceparent: str | None) -> Request:
|
|
156
|
+
body = task_body(spec)
|
|
157
|
+
return Request(
|
|
158
|
+
"POST",
|
|
159
|
+
f"/v1/queues/{_q(queue)}/tasks",
|
|
160
|
+
json=body,
|
|
161
|
+
headers=_traceparent(traceparent),
|
|
162
|
+
idempotent="idempotency_key" in body,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def enqueue_many(queue: str, specs: list[Mapping[str, Any]], traceparent: str | None) -> Request:
|
|
167
|
+
tasks = [task_body(s) for s in specs]
|
|
168
|
+
return Request(
|
|
169
|
+
"POST",
|
|
170
|
+
f"/v1/queues/{_q(queue)}/tasks:batch",
|
|
171
|
+
json={"tasks": tasks},
|
|
172
|
+
headers=_traceparent(traceparent),
|
|
173
|
+
idempotent=all("idempotency_key" in t for t in tasks),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def search_tasks(
|
|
178
|
+
*,
|
|
179
|
+
queue: str | None,
|
|
180
|
+
state: TaskState | None,
|
|
181
|
+
idempotency_key: str | None,
|
|
182
|
+
last_error: str | None,
|
|
183
|
+
created_after: datetime | None,
|
|
184
|
+
created_before: datetime | None,
|
|
185
|
+
cursor: str | None,
|
|
186
|
+
limit: int | None,
|
|
187
|
+
) -> Request:
|
|
188
|
+
params: dict[str, str] = {}
|
|
189
|
+
if queue is not None:
|
|
190
|
+
params["queue"] = queue
|
|
191
|
+
if state is not None:
|
|
192
|
+
params["state"] = state
|
|
193
|
+
if idempotency_key is not None:
|
|
194
|
+
params["idempotency_key"] = idempotency_key
|
|
195
|
+
if last_error is not None:
|
|
196
|
+
params["last_error"] = last_error
|
|
197
|
+
if created_after is not None:
|
|
198
|
+
params["created_after"] = _timestamp(created_after, "created_after")
|
|
199
|
+
if created_before is not None:
|
|
200
|
+
params["created_before"] = _timestamp(created_before, "created_before")
|
|
201
|
+
if cursor is not None:
|
|
202
|
+
params["cursor"] = cursor
|
|
203
|
+
if limit is not None:
|
|
204
|
+
params["limit"] = str(limit)
|
|
205
|
+
return Request("GET", "/v1/tasks", params=params, idempotent=True)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_task(task_id: str) -> Request:
|
|
209
|
+
return Request("GET", f"/v1/tasks/{_q(task_id)}", idempotent=True)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def list_attempts(task_id: str) -> Request:
|
|
213
|
+
return Request("GET", f"/v1/tasks/{_q(task_id)}/attempts", idempotent=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_task_delivery(task_id: str) -> Request:
|
|
217
|
+
return Request("GET", f"/v1/tasks/{_q(task_id)}/delivery", idempotent=True)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def cancel_task(task_id: str) -> Request:
|
|
221
|
+
return Request("POST", f"/v1/tasks/{_q(task_id)}/cancel", idempotent=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def retry_task(task_id: str) -> Request:
|
|
225
|
+
# Not idempotent: resending could replay a task that already finished again.
|
|
226
|
+
return Request("POST", f"/v1/tasks/{_q(task_id)}/retry")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Schedules and runs
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def list_schedules() -> Request:
|
|
233
|
+
return Request("GET", "/v1/schedules", idempotent=True)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_schedule(name: str) -> Request:
|
|
237
|
+
return Request("GET", f"/v1/schedules/{_q(name)}", idempotent=True)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def put_schedule(name: str, config: Mapping[str, Any]) -> Request:
|
|
241
|
+
body: dict[str, Any] = {}
|
|
242
|
+
for key, value in config.items():
|
|
243
|
+
if value is None:
|
|
244
|
+
continue
|
|
245
|
+
body[key] = dict(value) if key == "headers" else value
|
|
246
|
+
if "payload" in body and "body" in body:
|
|
247
|
+
raise ValueError("pass payload or body, not both")
|
|
248
|
+
return Request("PUT", f"/v1/schedules/{_q(name)}", json=body, idempotent=True)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def delete_schedule(name: str) -> Request:
|
|
252
|
+
return Request("DELETE", f"/v1/schedules/{_q(name)}", idempotent=True)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def pause_schedule(name: str) -> Request:
|
|
256
|
+
return Request("POST", f"/v1/schedules/{_q(name)}/pause", idempotent=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def resume_schedule(name: str) -> Request:
|
|
260
|
+
return Request("POST", f"/v1/schedules/{_q(name)}/resume", idempotent=True)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def preview_cron(cron: str, timezone: str | None) -> Request:
|
|
264
|
+
params = {"cron": cron} if timezone is None else {"cron": cron, "timezone": timezone}
|
|
265
|
+
return Request("GET", "/v1/cron/preview", params=params, idempotent=True)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def trigger_schedule(name: str) -> Request:
|
|
269
|
+
# Not idempotent: resending would create a second run.
|
|
270
|
+
return Request("POST", f"/v1/schedules/{_q(name)}/trigger")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def list_runs(
|
|
274
|
+
name: str, *, state: RunState | None, cursor: str | None, limit: int | None
|
|
275
|
+
) -> Request:
|
|
276
|
+
params: dict[str, str] = {}
|
|
277
|
+
if state is not None:
|
|
278
|
+
params["state"] = state
|
|
279
|
+
if cursor is not None:
|
|
280
|
+
params["cursor"] = cursor
|
|
281
|
+
if limit is not None:
|
|
282
|
+
params["limit"] = str(limit)
|
|
283
|
+
return Request("GET", f"/v1/schedules/{_q(name)}/runs", params=params, idempotent=True)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_run(run_id: str) -> Request:
|
|
287
|
+
return Request("GET", f"/v1/runs/{_q(run_id)}", idempotent=True)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def list_run_attempts(run_id: str) -> Request:
|
|
291
|
+
return Request("GET", f"/v1/runs/{_q(run_id)}/attempts", idempotent=True)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_run_delivery(run_id: str) -> Request:
|
|
295
|
+
return Request("GET", f"/v1/runs/{_q(run_id)}/delivery", idempotent=True)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cancel_run(run_id: str) -> Request:
|
|
299
|
+
return Request("POST", f"/v1/runs/{_q(run_id)}/cancel", idempotent=True)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def retry_run(run_id: str) -> Request:
|
|
303
|
+
# Not idempotent, like retry_task.
|
|
304
|
+
return Request("POST", f"/v1/runs/{_q(run_id)}/retry")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def list_alert_channels() -> Request:
|
|
308
|
+
return Request("GET", "/v1/alert-channels", idempotent=True)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def create_alert_channel(type: AlertChannelType, target: str) -> Request:
|
|
312
|
+
# Not idempotent: a resend after it was added gets alert_channel_exists.
|
|
313
|
+
return Request("POST", "/v1/alert-channels", json={"type": type, "target": target})
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def delete_alert_channel(channel_id: str) -> Request:
|
|
317
|
+
return Request("DELETE", f"/v1/alert-channels/{_q(channel_id)}", idempotent=True)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_alert_channel(channel_id: str) -> Request:
|
|
321
|
+
# Not idempotent: a resend sends a second test message.
|
|
322
|
+
return Request("POST", f"/v1/alert-channels/{_q(channel_id)}/test")
|
workly/_signing.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Delivery signatures. Must match internal/signing in the server:
|
|
2
|
+
|
|
3
|
+
Workly-Timestamp: <unix seconds>
|
|
4
|
+
Workly-Signature: v1=<hex HMAC-SHA256 of "<timestamp>.<body>">
|
|
5
|
+
|
|
6
|
+
Several comma-separated signatures may be sent during key rotation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
|
|
18
|
+
from ._errors import SignatureVerificationError
|
|
19
|
+
|
|
20
|
+
HEADER_SIGNATURE = "Workly-Signature"
|
|
21
|
+
HEADER_TIMESTAMP = "Workly-Timestamp"
|
|
22
|
+
|
|
23
|
+
DEFAULT_TOLERANCE = timedelta(minutes=5)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sign(body: bytes | str, secret: str | bytes, timestamp: int | None = None) -> dict[str, str]:
|
|
27
|
+
"""Return the signature headers Workly would send with `body`.
|
|
28
|
+
|
|
29
|
+
Useful for testing your endpoint without a server.
|
|
30
|
+
"""
|
|
31
|
+
ts = int(time.time()) if timestamp is None else timestamp
|
|
32
|
+
return {
|
|
33
|
+
HEADER_TIMESTAMP: str(ts),
|
|
34
|
+
HEADER_SIGNATURE: "v1=" + _mac(_bytes(secret), ts, _bytes(body)).hex(),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def verify_signature(
|
|
39
|
+
body: bytes | str,
|
|
40
|
+
headers: Mapping[str, str],
|
|
41
|
+
secret: str | bytes | None = None,
|
|
42
|
+
*,
|
|
43
|
+
tolerance: timedelta | float | None = DEFAULT_TOLERANCE,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Check that a delivery came from Workly. Raises SignatureVerificationError if not.
|
|
46
|
+
|
|
47
|
+
Pass the raw request body, exactly as received (not re-serialized JSON),
|
|
48
|
+
and the request headers. `secret` defaults to `WORKLY_SIGNING_SECRET`.
|
|
49
|
+
`tolerance` is how old the timestamp may be, which limits replays; None
|
|
50
|
+
skips the check.
|
|
51
|
+
"""
|
|
52
|
+
if secret is None:
|
|
53
|
+
secret = os.environ.get("WORKLY_SIGNING_SECRET")
|
|
54
|
+
if not secret:
|
|
55
|
+
raise ValueError("pass secret or set WORKLY_SIGNING_SECRET")
|
|
56
|
+
|
|
57
|
+
signature = _header(headers, HEADER_SIGNATURE)
|
|
58
|
+
timestamp = _header(headers, HEADER_TIMESTAMP)
|
|
59
|
+
try:
|
|
60
|
+
ts = int(timestamp or "")
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise SignatureVerificationError("missing or malformed signature headers") from None
|
|
63
|
+
if not signature:
|
|
64
|
+
raise SignatureVerificationError("missing or malformed signature headers")
|
|
65
|
+
|
|
66
|
+
if tolerance is not None:
|
|
67
|
+
seconds = tolerance.total_seconds() if isinstance(tolerance, timedelta) else tolerance
|
|
68
|
+
if seconds > 0 and abs(time.time() - ts) > seconds:
|
|
69
|
+
raise SignatureVerificationError("timestamp outside tolerance")
|
|
70
|
+
|
|
71
|
+
want = _mac(_bytes(secret), ts, _bytes(body))
|
|
72
|
+
for part in signature.split(","):
|
|
73
|
+
scheme, _, value = part.strip().partition("=")
|
|
74
|
+
if scheme != "v1":
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
got = bytes.fromhex(value)
|
|
78
|
+
except ValueError:
|
|
79
|
+
continue
|
|
80
|
+
if hmac.compare_digest(got, want):
|
|
81
|
+
return
|
|
82
|
+
raise SignatureVerificationError("signature does not match")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _mac(secret: bytes, timestamp: int, body: bytes) -> bytes:
|
|
86
|
+
return hmac.new(secret, f"{timestamp}.".encode() + body, hashlib.sha256).digest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _bytes(value: str | bytes) -> bytes:
|
|
90
|
+
return value.encode() if isinstance(value, str) else value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
94
|
+
# Framework header objects are case-insensitive; plain dicts may not be.
|
|
95
|
+
value = headers.get(name)
|
|
96
|
+
if value is None:
|
|
97
|
+
lower = name.lower()
|
|
98
|
+
value = next((v for k, v in headers.items() if k.lower() == lower), None)
|
|
99
|
+
return value
|
workly/_transport.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Retry and response handling shared by the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._errors import APIConnectionError, api_error
|
|
12
|
+
from ._requests import Request
|
|
13
|
+
|
|
14
|
+
# Gateway errors, retried for requests that are safe to repeat. A 429 is
|
|
15
|
+
# retried for every request: the server refused it without doing anything.
|
|
16
|
+
_GATEWAY_STATUSES = frozenset({502, 503, 504})
|
|
17
|
+
_MAX_DELAY = 8.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def retry_after_error(req: Request, exc: httpx.TransportError) -> bool:
|
|
21
|
+
# A request that never connected can always be resent; anything else
|
|
22
|
+
# may have reached the server.
|
|
23
|
+
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
24
|
+
return True
|
|
25
|
+
return req.idempotent
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def retry_after_status(req: Request, response: httpx.Response) -> bool:
|
|
29
|
+
status = response.status_code
|
|
30
|
+
return status == 429 or (req.idempotent and status in _GATEWAY_STATUSES)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def delay(attempt: int, response: httpx.Response | None = None) -> float:
|
|
34
|
+
if response is not None:
|
|
35
|
+
try:
|
|
36
|
+
retry_after = float(response.headers.get("Retry-After", ""))
|
|
37
|
+
except ValueError:
|
|
38
|
+
retry_after = math.nan # absent, or an HTTP date
|
|
39
|
+
if retry_after >= 0: # False for NaN
|
|
40
|
+
return min(retry_after, _MAX_DELAY)
|
|
41
|
+
return min(0.5 * 2**attempt, _MAX_DELAY) * random.uniform(0.5, 1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def connection_error(req: Request, exc: httpx.TransportError) -> APIConnectionError:
|
|
45
|
+
return APIConnectionError(f"{req.method} {req.path}: {exc!r}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decode(response: httpx.Response) -> Any:
|
|
49
|
+
if response.status_code >= 400:
|
|
50
|
+
code, message = "unknown", response.text or response.reason_phrase
|
|
51
|
+
try:
|
|
52
|
+
err = response.json()["error"]
|
|
53
|
+
code, message = err["code"], err["message"]
|
|
54
|
+
except (ValueError, KeyError, TypeError):
|
|
55
|
+
pass
|
|
56
|
+
raise api_error(response.status_code, code, message)
|
|
57
|
+
if response.status_code == 204 or not response.content:
|
|
58
|
+
return None
|
|
59
|
+
return response.json()
|
workly/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: workly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Workly, the open-source HTTP task queue
|
|
5
|
+
Keywords: task queue,background jobs,webhooks,http
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Dist: httpx>=0.27,<1
|
|
14
|
+
Requires-Dist: workly-cli ; extra == 'cli'
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Project-URL: Homepage, https://workly.run
|
|
17
|
+
Project-URL: Repository, https://github.com/worklydev/workly
|
|
18
|
+
Provides-Extra: cli
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Workly for Python
|
|
22
|
+
|
|
23
|
+
The Python SDK for [Workly](https://github.com/worklydev/workly), the open-source HTTP task queue. Enqueue a task, and Workly delivers it to your endpoint over HTTP, retrying with backoff until it succeeds.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install workly # the SDK
|
|
27
|
+
pip install "workly[cli]" # the SDK plus the workly binary, for `workly dev`
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Python 3.11+. The only dependency is [httpx](https://www.python-httpx.org). The `cli` extra adds [`workly-cli`](https://pypi.org/project/workly-cli/), the server binary (about 7 MB), which you usually want on dev machines and not in production images.
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
Start a local server with `workly dev` (SQLite, no auth, on :7337), then:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from workly import Workly
|
|
38
|
+
|
|
39
|
+
client = Workly() # WORKLY_URL, else http://localhost:7337; WORKLY_API_KEY
|
|
40
|
+
task = client.tasks.enqueue(
|
|
41
|
+
"emails",
|
|
42
|
+
url="https://api.example.com/send-email",
|
|
43
|
+
payload={"user_id": 123},
|
|
44
|
+
)
|
|
45
|
+
print(task.id, task.state)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Your endpoint receives a `POST` with the JSON payload. Any 2xx marks the task succeeded. A 5xx, 408, 429, timeout or connection error is retried with backoff. Delivery is at-least-once, so make handlers safe to run twice. `Workly-Task-Id` is stable across attempts and works as a deduplication key.
|
|
49
|
+
|
|
50
|
+
## Verifying deliveries
|
|
51
|
+
|
|
52
|
+
Every delivery is signed: with the server's `WORKLY_SIGNING_SECRET` when you run Workly yourself, or with your workspace's secret on [workly.run](#hosted-workly). Check the signature against the **raw** body:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from workly import SignatureVerificationError, verify_signature
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Flask
|
|
59
|
+
@app.post("/send-email")
|
|
60
|
+
def send_email():
|
|
61
|
+
try:
|
|
62
|
+
verify_signature(request.get_data(), request.headers) # secret from WORKLY_SIGNING_SECRET
|
|
63
|
+
except SignatureVerificationError:
|
|
64
|
+
abort(401)
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# FastAPI / Starlette
|
|
69
|
+
@app.post("/send-email")
|
|
70
|
+
async def send_email(request: Request):
|
|
71
|
+
verify_signature(await request.body(), request.headers, secret)
|
|
72
|
+
...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
By default, signatures older than 5 minutes are rejected (`tolerance=`). `workly dev` signs with `workly-dev-secret`. To test your handler without a server, use `workly.sign(body, secret)`, which returns the headers Workly would send.
|
|
76
|
+
|
|
77
|
+
## Hosted Workly
|
|
78
|
+
|
|
79
|
+
On [workly.run](https://workly.run), point the client at the hosted API and use the keys from the [console](https://console.workly.run):
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
export WORKLY_URL=https://api.workly.run
|
|
83
|
+
export WORKLY_API_KEY=... # API keys page
|
|
84
|
+
export WORKLY_SIGNING_SECRET=whsec_... # same page; each workspace has its own
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Nothing else changes. Hosted workspaces have a request rate limit and a monthly task quota: requests over the rate limit are retried for you, and an enqueue over the quota raises `PermissionDeniedError` with the code `quota_exceeded`.
|
|
88
|
+
|
|
89
|
+
## Tasks
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from datetime import datetime, timedelta, timezone
|
|
93
|
+
|
|
94
|
+
client.tasks.enqueue(
|
|
95
|
+
"emails",
|
|
96
|
+
url="https://api.example.com/send-email",
|
|
97
|
+
payload={"user_id": 123}, # JSON; or body="raw text", content_type="text/csv"
|
|
98
|
+
method="POST", # default
|
|
99
|
+
headers={"X-Tenant": "acme"}, # sent with every delivery
|
|
100
|
+
delay_seconds=60, # or run_at=datetime(..., tzinfo=timezone.utc)
|
|
101
|
+
idempotency_key="welcome-123", # an existing task with this key is returned instead
|
|
102
|
+
max_attempts=5, # overrides the queue's setting
|
|
103
|
+
timeout_seconds=10, # likewise
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Up to 100 at once, all or nothing.
|
|
107
|
+
client.tasks.enqueue_many(
|
|
108
|
+
"emails",
|
|
109
|
+
[{"url": "https://api.example.com/send-email", "payload": {"user_id": i}} for i in range(100)],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
task = client.tasks.get("task_01J9...")
|
|
113
|
+
for attempt in client.tasks.attempts(task.id):
|
|
114
|
+
print(attempt.number, attempt.outcome, attempt.status_code, attempt.duration_ms)
|
|
115
|
+
|
|
116
|
+
client.tasks.retry(task.id) # run now; replays a finished task
|
|
117
|
+
delivery = client.tasks.delivery(task.id) # the signed request, to replay by hand
|
|
118
|
+
client.tasks.cancel(task.id)
|
|
119
|
+
|
|
120
|
+
# Newest first, across pages. All filters are optional.
|
|
121
|
+
for task in client.tasks.list(
|
|
122
|
+
queue="emails", state="failed", created_after=datetime.now(timezone.utc) - timedelta(days=1)
|
|
123
|
+
):
|
|
124
|
+
...
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Queues
|
|
128
|
+
|
|
129
|
+
Queues are created automatically on first enqueue. `put` sets a queue's configuration declaratively, which makes it easy to keep in code: settings you leave out are reset to their defaults.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
client.queues.put(
|
|
133
|
+
"emails",
|
|
134
|
+
max_attempts=10,
|
|
135
|
+
min_backoff_seconds=1,
|
|
136
|
+
max_backoff_seconds=3600,
|
|
137
|
+
backoff_multiplier=2,
|
|
138
|
+
timeout_seconds=30,
|
|
139
|
+
rate_per_second=50,
|
|
140
|
+
max_concurrency=20,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
queue = client.queues.get("emails")
|
|
144
|
+
print(queue.stats.due, queue.stats.oldest_due_at)
|
|
145
|
+
|
|
146
|
+
client.queues.pause("emails") # stop delivering; enqueueing still works
|
|
147
|
+
client.queues.resume("emails")
|
|
148
|
+
client.queues.cancel_pending("emails") # cancel pending tasks
|
|
149
|
+
client.queues.retry_failed("emails") # replay failed tasks, e.g. after an outage
|
|
150
|
+
for group in client.queues.failures("emails"): # failed tasks grouped by error
|
|
151
|
+
print(group.count, group.error)
|
|
152
|
+
client.queues.retry_failed("emails", last_error="HTTP 503 Service Unavailable") # replay one group
|
|
153
|
+
client.queues.activity("emails") # succeeded/failed/cancelled in the last 7 days
|
|
154
|
+
client.queues.delete("emails") # delete the queue and all its tasks
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Schedules
|
|
158
|
+
|
|
159
|
+
A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. `put` is declarative, like `queues.put`. See [Schedules](https://docs.workly.run/schedules/).
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
schedule = client.schedules.put(
|
|
163
|
+
"nightly-report",
|
|
164
|
+
cron="0 3 * * *",
|
|
165
|
+
timezone="Europe/Stockholm",
|
|
166
|
+
url="https://api.example.com/reports/nightly",
|
|
167
|
+
payload={"kind": "daily"},
|
|
168
|
+
max_attempts=3,
|
|
169
|
+
overlap="skip", # skip a slot while the previous run is still going
|
|
170
|
+
)
|
|
171
|
+
print(schedule.next_runs) # the next five times
|
|
172
|
+
|
|
173
|
+
run = client.schedules.trigger("nightly-report") # run it now
|
|
174
|
+
for run in client.schedules.runs("nightly-report", state="failed"):
|
|
175
|
+
print(run.id, run.scheduled_for, run.last_error)
|
|
176
|
+
client.runs.attempts(run.id)
|
|
177
|
+
client.runs.retry(run.id) # deliver it again, e.g. a failed or skipped run
|
|
178
|
+
client.schedules.pause("nightly-report")
|
|
179
|
+
client.schedules.resume("nightly-report") # from the next scheduled time
|
|
180
|
+
client.schedules.delete("nightly-report") # with all its runs
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Runs are delivered with `Workly-Run-Id`, `Workly-Schedule` and `Workly-Scheduled-For` headers and the same signature, so `verify_signature` works unchanged.
|
|
184
|
+
|
|
185
|
+
## Async
|
|
186
|
+
|
|
187
|
+
`AsyncWorkly` has the same methods, as coroutines:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from workly import AsyncWorkly
|
|
191
|
+
|
|
192
|
+
async with AsyncWorkly() as client:
|
|
193
|
+
task = await client.tasks.enqueue("emails", url="...", payload={"user_id": 123})
|
|
194
|
+
async for task in client.tasks.list(queue="emails"):
|
|
195
|
+
...
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Errors
|
|
199
|
+
|
|
200
|
+
| Exception | When |
|
|
201
|
+
|---|---|
|
|
202
|
+
| `BadRequestError` | 400, invalid request |
|
|
203
|
+
| `AuthenticationError` | 401, missing or wrong API key |
|
|
204
|
+
| `PermissionDeniedError` | 403, not allowed, or a hosted limit was reached (e.g. `quota_exceeded`) |
|
|
205
|
+
| `NotFoundError` | 404, no such queue or task |
|
|
206
|
+
| `ConflictError` | 409, e.g. retrying a running task |
|
|
207
|
+
| `PayloadTooLargeError` | 413 |
|
|
208
|
+
| `RateLimitError` | 429, still rate limited after the retries |
|
|
209
|
+
| `APIError` | base class of the above; has `.status`, `.code` (stable) and `.message` |
|
|
210
|
+
| `APIConnectionError` | the server couldn't be reached |
|
|
211
|
+
|
|
212
|
+
Requests are retried up to `max_retries` times (2 by default), honouring `Retry-After`. A 429 is always retried, since the server refused the request without doing anything. Connection errors and 502–504 are retried for requests that are safe to repeat: an enqueue only when it has an `idempotency_key`, or when the connection failed before the request was sent.
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
[Apache-2.0](https://github.com/worklydev/workly/blob/main/LICENSE)
|