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/_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)