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/_client.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import _requests as r
|
|
12
|
+
from . import _transport as t
|
|
13
|
+
from ._models import (
|
|
14
|
+
Activity,
|
|
15
|
+
ActivityRange,
|
|
16
|
+
AlertChannel,
|
|
17
|
+
AlertChannelList,
|
|
18
|
+
AlertChannelType,
|
|
19
|
+
Attempt,
|
|
20
|
+
CronPreview,
|
|
21
|
+
Delivery,
|
|
22
|
+
EnqueueResult,
|
|
23
|
+
FailureGroup,
|
|
24
|
+
HTTPMethod,
|
|
25
|
+
MissedPolicy,
|
|
26
|
+
OverlapPolicy,
|
|
27
|
+
Queue,
|
|
28
|
+
QueueActivity,
|
|
29
|
+
Run,
|
|
30
|
+
RunPage,
|
|
31
|
+
RunState,
|
|
32
|
+
Schedule,
|
|
33
|
+
Task,
|
|
34
|
+
TaskPage,
|
|
35
|
+
TaskState,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Workly:
|
|
40
|
+
"""Client for the Workly API.
|
|
41
|
+
|
|
42
|
+
`url` defaults to `WORKLY_URL`, then http://localhost:7337 (`workly dev`).
|
|
43
|
+
`api_key` defaults to `WORKLY_API_KEY`; `workly dev` needs none.
|
|
44
|
+
|
|
45
|
+
Use it as a context manager, or call `close()`, to release connections.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
url: str | None = None,
|
|
51
|
+
api_key: str | None = None,
|
|
52
|
+
*,
|
|
53
|
+
timeout: float = 30.0,
|
|
54
|
+
max_retries: int = 2,
|
|
55
|
+
http_client: httpx.Client | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._config = r.client_config(url, api_key)
|
|
58
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
59
|
+
self._owns_http = http_client is None
|
|
60
|
+
self._max_retries = max_retries
|
|
61
|
+
self.queues = Queues(self)
|
|
62
|
+
self.tasks = Tasks(self)
|
|
63
|
+
self.schedules = Schedules(self)
|
|
64
|
+
self.runs = Runs(self)
|
|
65
|
+
self.alerts = Alerts(self)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def url(self) -> str:
|
|
69
|
+
return self._config.base_url
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
if self._owns_http:
|
|
73
|
+
self._http.close()
|
|
74
|
+
|
|
75
|
+
def __enter__(self) -> Self:
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def __exit__(
|
|
79
|
+
self,
|
|
80
|
+
exc_type: type[BaseException] | None,
|
|
81
|
+
exc: BaseException | None,
|
|
82
|
+
tb: TracebackType | None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.close()
|
|
85
|
+
|
|
86
|
+
def _send(self, req: r.Request) -> Any:
|
|
87
|
+
attempt = 0
|
|
88
|
+
while True:
|
|
89
|
+
try:
|
|
90
|
+
response = self._http.request(
|
|
91
|
+
req.method,
|
|
92
|
+
self._config.base_url + req.path,
|
|
93
|
+
params=req.params,
|
|
94
|
+
json=req.json,
|
|
95
|
+
headers={**self._config.headers, **req.headers},
|
|
96
|
+
)
|
|
97
|
+
except httpx.TransportError as exc:
|
|
98
|
+
if attempt < self._max_retries and t.retry_after_error(req, exc):
|
|
99
|
+
time.sleep(t.delay(attempt))
|
|
100
|
+
attempt += 1
|
|
101
|
+
continue
|
|
102
|
+
raise t.connection_error(req, exc) from exc
|
|
103
|
+
if attempt < self._max_retries and t.retry_after_status(req, response):
|
|
104
|
+
time.sleep(t.delay(attempt, response))
|
|
105
|
+
attempt += 1
|
|
106
|
+
continue
|
|
107
|
+
return t.decode(response)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Queues:
|
|
111
|
+
def __init__(self, client: Workly) -> None:
|
|
112
|
+
self._client = client
|
|
113
|
+
|
|
114
|
+
def list(self) -> list[Queue]:
|
|
115
|
+
"""All queues, by name, with their stats."""
|
|
116
|
+
data = self._client._send(r.list_queues())
|
|
117
|
+
return [Queue._from_json(q) for q in data["queues"]]
|
|
118
|
+
|
|
119
|
+
def get(self, name: str) -> Queue:
|
|
120
|
+
return Queue._from_json(self._client._send(r.get_queue(name)))
|
|
121
|
+
|
|
122
|
+
def put(
|
|
123
|
+
self,
|
|
124
|
+
name: str,
|
|
125
|
+
*,
|
|
126
|
+
max_attempts: int | None = None,
|
|
127
|
+
min_backoff_seconds: float | None = None,
|
|
128
|
+
max_backoff_seconds: float | None = None,
|
|
129
|
+
backoff_multiplier: float | None = None,
|
|
130
|
+
timeout_seconds: float | None = None,
|
|
131
|
+
retry_on_4xx: bool | None = None,
|
|
132
|
+
rate_per_second: float | None = None,
|
|
133
|
+
max_concurrency: int | None = None,
|
|
134
|
+
) -> Queue:
|
|
135
|
+
"""Create or update a queue.
|
|
136
|
+
|
|
137
|
+
Declarative: the queue ends up with exactly this configuration, and
|
|
138
|
+
omitted settings are reset to their defaults. It doesn't change
|
|
139
|
+
whether the queue is paused.
|
|
140
|
+
"""
|
|
141
|
+
config = dict(
|
|
142
|
+
max_attempts=max_attempts,
|
|
143
|
+
min_backoff_seconds=min_backoff_seconds,
|
|
144
|
+
max_backoff_seconds=max_backoff_seconds,
|
|
145
|
+
backoff_multiplier=backoff_multiplier,
|
|
146
|
+
timeout_seconds=timeout_seconds,
|
|
147
|
+
retry_on_4xx=retry_on_4xx,
|
|
148
|
+
rate_per_second=rate_per_second,
|
|
149
|
+
max_concurrency=max_concurrency,
|
|
150
|
+
)
|
|
151
|
+
return Queue._from_json(self._client._send(r.put_queue(name, config)))
|
|
152
|
+
|
|
153
|
+
def delete(self, name: str) -> None:
|
|
154
|
+
"""Delete a queue and all its tasks."""
|
|
155
|
+
self._client._send(r.delete_queue(name))
|
|
156
|
+
|
|
157
|
+
def pause(self, name: str) -> Queue:
|
|
158
|
+
"""Stop dispatching. In-flight deliveries finish; enqueueing still works."""
|
|
159
|
+
return Queue._from_json(self._client._send(r.pause_queue(name)))
|
|
160
|
+
|
|
161
|
+
def resume(self, name: str) -> Queue:
|
|
162
|
+
return Queue._from_json(self._client._send(r.resume_queue(name)))
|
|
163
|
+
|
|
164
|
+
def cancel_pending(self, name: str) -> int:
|
|
165
|
+
"""Cancel the queue's pending tasks, including scheduled ones. Returns how many.
|
|
166
|
+
|
|
167
|
+
Running tasks finish. Cancelled tasks stay in the history and can be retried.
|
|
168
|
+
"""
|
|
169
|
+
data = self._client._send(r.cancel_pending_tasks(name))
|
|
170
|
+
return int(data["cancelled"])
|
|
171
|
+
|
|
172
|
+
def failures(self, name: str, *, limit: int | None = None) -> list[FailureGroup]:
|
|
173
|
+
"""The queue's failed tasks grouped by error, most common first (default: 10 groups)."""
|
|
174
|
+
data = self._client._send(r.get_queue_failures(name, limit))
|
|
175
|
+
return [FailureGroup._from_json(g) for g in data["groups"]]
|
|
176
|
+
|
|
177
|
+
def activity(self, name: str, *, since: datetime | None = None) -> QueueActivity:
|
|
178
|
+
"""Count the queue's tasks that finished since `since` (default: 7 days ago), by state."""
|
|
179
|
+
return QueueActivity._from_json(self._client._send(r.get_queue_activity(name, since)))
|
|
180
|
+
|
|
181
|
+
def retry_failed(self, name: str, *, last_error: str | None = None) -> int:
|
|
182
|
+
"""Retry the failed tasks in the queue now, e.g. after an outage.
|
|
183
|
+
|
|
184
|
+
Each task gets a fresh set of attempts. With `last_error`, only tasks
|
|
185
|
+
that failed with exactly that error (a group from `failures`) are
|
|
186
|
+
retried. Returns how many were retried.
|
|
187
|
+
"""
|
|
188
|
+
data = self._client._send(r.retry_failed_tasks(name, last_error))
|
|
189
|
+
return int(data["retried"])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Tasks:
|
|
193
|
+
def __init__(self, client: Workly) -> None:
|
|
194
|
+
self._client = client
|
|
195
|
+
|
|
196
|
+
def enqueue(
|
|
197
|
+
self,
|
|
198
|
+
queue: str,
|
|
199
|
+
*,
|
|
200
|
+
url: str,
|
|
201
|
+
payload: Any = None,
|
|
202
|
+
body: str | None = None,
|
|
203
|
+
content_type: str | None = None,
|
|
204
|
+
method: HTTPMethod | None = None,
|
|
205
|
+
headers: Mapping[str, str] | None = None,
|
|
206
|
+
delay_seconds: float | None = None,
|
|
207
|
+
run_at: datetime | None = None,
|
|
208
|
+
idempotency_key: str | None = None,
|
|
209
|
+
max_attempts: int | None = None,
|
|
210
|
+
timeout_seconds: float | None = None,
|
|
211
|
+
traceparent: str | None = None,
|
|
212
|
+
) -> Task:
|
|
213
|
+
"""Enqueue a task to deliver to `url`.
|
|
214
|
+
|
|
215
|
+
`payload` is sent as JSON; `body` as raw text (use one). The queue is
|
|
216
|
+
created with default settings if it doesn't exist. If
|
|
217
|
+
`idempotency_key` matches a task already in the queue, that task is
|
|
218
|
+
returned and nothing is enqueued.
|
|
219
|
+
"""
|
|
220
|
+
spec = {
|
|
221
|
+
"url": url,
|
|
222
|
+
"payload": payload,
|
|
223
|
+
"body": body,
|
|
224
|
+
"content_type": content_type,
|
|
225
|
+
"method": method,
|
|
226
|
+
"headers": headers,
|
|
227
|
+
"delay_seconds": delay_seconds,
|
|
228
|
+
"run_at": run_at,
|
|
229
|
+
"idempotency_key": idempotency_key,
|
|
230
|
+
"max_attempts": max_attempts,
|
|
231
|
+
"timeout_seconds": timeout_seconds,
|
|
232
|
+
}
|
|
233
|
+
return Task._from_json(self._client._send(r.enqueue(queue, spec, traceparent)))
|
|
234
|
+
|
|
235
|
+
def enqueue_many(
|
|
236
|
+
self, queue: str, tasks: Iterable[r.TaskSpec], *, traceparent: str | None = None
|
|
237
|
+
) -> list[EnqueueResult]:
|
|
238
|
+
"""Enqueue up to 100 tasks at once: all of them, or none if any is invalid."""
|
|
239
|
+
data = self._client._send(r.enqueue_many(queue, list(tasks), traceparent))
|
|
240
|
+
return [
|
|
241
|
+
EnqueueResult(created=res["created"], task=Task._from_json(res["task"]))
|
|
242
|
+
for res in data["results"]
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
def get(self, task_id: str) -> Task:
|
|
246
|
+
return Task._from_json(self._client._send(r.get_task(task_id)))
|
|
247
|
+
|
|
248
|
+
def attempts(self, task_id: str) -> list[Attempt]:
|
|
249
|
+
"""The task's delivery attempts, oldest first."""
|
|
250
|
+
data = self._client._send(r.list_attempts(task_id))
|
|
251
|
+
return [Attempt._from_json(a) for a in data["attempts"]]
|
|
252
|
+
|
|
253
|
+
def delivery(self, task_id: str) -> Delivery:
|
|
254
|
+
"""The request that delivers the task, as it would be sent now and freshly signed.
|
|
255
|
+
|
|
256
|
+
Nothing is sent. Useful to replay a delivery by hand while fixing an endpoint.
|
|
257
|
+
"""
|
|
258
|
+
return Delivery._from_json(self._client._send(r.get_task_delivery(task_id)))
|
|
259
|
+
|
|
260
|
+
def cancel(self, task_id: str) -> Task:
|
|
261
|
+
"""Cancel a pending or running task. A running attempt finishes but isn't retried."""
|
|
262
|
+
return Task._from_json(self._client._send(r.cancel_task(task_id)))
|
|
263
|
+
|
|
264
|
+
def retry(self, task_id: str) -> Task:
|
|
265
|
+
"""Make the task due now. A finished task is replayed with fresh attempts."""
|
|
266
|
+
return Task._from_json(self._client._send(r.retry_task(task_id)))
|
|
267
|
+
|
|
268
|
+
def list(
|
|
269
|
+
self,
|
|
270
|
+
*,
|
|
271
|
+
queue: str | None = None,
|
|
272
|
+
state: TaskState | None = None,
|
|
273
|
+
idempotency_key: str | None = None,
|
|
274
|
+
last_error: str | None = None,
|
|
275
|
+
created_after: datetime | None = None,
|
|
276
|
+
created_before: datetime | None = None,
|
|
277
|
+
page_size: int | None = None,
|
|
278
|
+
) -> Iterator[Task]:
|
|
279
|
+
"""Tasks matching every filter given, newest first, fetching pages as needed."""
|
|
280
|
+
cursor: str | None = None
|
|
281
|
+
while True:
|
|
282
|
+
page = self.list_page(
|
|
283
|
+
queue=queue,
|
|
284
|
+
state=state,
|
|
285
|
+
idempotency_key=idempotency_key,
|
|
286
|
+
last_error=last_error,
|
|
287
|
+
created_after=created_after,
|
|
288
|
+
created_before=created_before,
|
|
289
|
+
cursor=cursor,
|
|
290
|
+
limit=page_size,
|
|
291
|
+
)
|
|
292
|
+
yield from page.tasks
|
|
293
|
+
if page.next_cursor is None:
|
|
294
|
+
return
|
|
295
|
+
cursor = page.next_cursor
|
|
296
|
+
|
|
297
|
+
def list_page(
|
|
298
|
+
self,
|
|
299
|
+
*,
|
|
300
|
+
queue: str | None = None,
|
|
301
|
+
state: TaskState | None = None,
|
|
302
|
+
idempotency_key: str | None = None,
|
|
303
|
+
last_error: str | None = None,
|
|
304
|
+
created_after: datetime | None = None,
|
|
305
|
+
created_before: datetime | None = None,
|
|
306
|
+
cursor: str | None = None,
|
|
307
|
+
limit: int | None = None,
|
|
308
|
+
) -> TaskPage:
|
|
309
|
+
"""One page of `list`. Pass the previous page's `next_cursor` as `cursor`."""
|
|
310
|
+
req = r.search_tasks(
|
|
311
|
+
queue=queue,
|
|
312
|
+
state=state,
|
|
313
|
+
idempotency_key=idempotency_key,
|
|
314
|
+
last_error=last_error,
|
|
315
|
+
created_after=created_after,
|
|
316
|
+
created_before=created_before,
|
|
317
|
+
cursor=cursor,
|
|
318
|
+
limit=limit,
|
|
319
|
+
)
|
|
320
|
+
return TaskPage._from_json(self._client._send(req))
|
|
321
|
+
|
|
322
|
+
def activity(self, *, range: ActivityRange | None = None) -> Activity:
|
|
323
|
+
"""Count the workspace's tasks created over time, by queue (default range: 24h).
|
|
324
|
+
|
|
325
|
+
Buckets are 5 minutes for 1h, 1 hour for 24h and 6 hours for 7d, aligned to UTC.
|
|
326
|
+
"""
|
|
327
|
+
return Activity._from_json(self._client._send(r.get_activity(range)))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class Schedules:
|
|
331
|
+
"""Cron schedules that call an endpoint. Separate from queues: each call is a run."""
|
|
332
|
+
|
|
333
|
+
def __init__(self, client: Workly) -> None:
|
|
334
|
+
self._client = client
|
|
335
|
+
|
|
336
|
+
def list(self) -> list[Schedule]:
|
|
337
|
+
"""All schedules, by name, with their next and last run."""
|
|
338
|
+
data = self._client._send(r.list_schedules())
|
|
339
|
+
return [Schedule._from_json(s) for s in data["schedules"]]
|
|
340
|
+
|
|
341
|
+
def get(self, name: str) -> Schedule:
|
|
342
|
+
return Schedule._from_json(self._client._send(r.get_schedule(name)))
|
|
343
|
+
|
|
344
|
+
def put(
|
|
345
|
+
self,
|
|
346
|
+
name: str,
|
|
347
|
+
*,
|
|
348
|
+
cron: str,
|
|
349
|
+
url: str,
|
|
350
|
+
timezone: str | None = None,
|
|
351
|
+
payload: Any = None,
|
|
352
|
+
body: str | None = None,
|
|
353
|
+
content_type: str | None = None,
|
|
354
|
+
method: HTTPMethod | None = None,
|
|
355
|
+
headers: Mapping[str, str] | None = None,
|
|
356
|
+
timeout_seconds: float | None = None,
|
|
357
|
+
max_attempts: int | None = None,
|
|
358
|
+
min_backoff_seconds: float | None = None,
|
|
359
|
+
max_backoff_seconds: float | None = None,
|
|
360
|
+
backoff_multiplier: float | None = None,
|
|
361
|
+
retry_on_4xx: bool | None = None,
|
|
362
|
+
overlap: OverlapPolicy | None = None,
|
|
363
|
+
missed: MissedPolicy | None = None,
|
|
364
|
+
) -> Schedule:
|
|
365
|
+
"""Create or update a schedule that calls `url` on `cron` (read in `timezone`, default UTC).
|
|
366
|
+
|
|
367
|
+
Declarative: the schedule ends up with exactly these settings, and
|
|
368
|
+
omitted ones are reset to their defaults. It doesn't change whether
|
|
369
|
+
the schedule is paused. `next_runs` on the result shows when it runs.
|
|
370
|
+
"""
|
|
371
|
+
config = dict(
|
|
372
|
+
cron=cron,
|
|
373
|
+
url=url,
|
|
374
|
+
timezone=timezone,
|
|
375
|
+
payload=payload,
|
|
376
|
+
body=body,
|
|
377
|
+
content_type=content_type,
|
|
378
|
+
method=method,
|
|
379
|
+
headers=headers,
|
|
380
|
+
timeout_seconds=timeout_seconds,
|
|
381
|
+
max_attempts=max_attempts,
|
|
382
|
+
min_backoff_seconds=min_backoff_seconds,
|
|
383
|
+
max_backoff_seconds=max_backoff_seconds,
|
|
384
|
+
backoff_multiplier=backoff_multiplier,
|
|
385
|
+
retry_on_4xx=retry_on_4xx,
|
|
386
|
+
overlap=overlap,
|
|
387
|
+
missed=missed,
|
|
388
|
+
)
|
|
389
|
+
return Schedule._from_json(self._client._send(r.put_schedule(name, config)))
|
|
390
|
+
|
|
391
|
+
def delete(self, name: str) -> None:
|
|
392
|
+
"""Delete a schedule and all its runs."""
|
|
393
|
+
self._client._send(r.delete_schedule(name))
|
|
394
|
+
|
|
395
|
+
def pause(self, name: str) -> Schedule:
|
|
396
|
+
"""Stop the schedule from firing. Runs in flight finish; `trigger` still works."""
|
|
397
|
+
return Schedule._from_json(self._client._send(r.pause_schedule(name)))
|
|
398
|
+
|
|
399
|
+
def resume(self, name: str) -> Schedule:
|
|
400
|
+
"""Fire again from the next slot. Slots that passed while paused aren't run."""
|
|
401
|
+
return Schedule._from_json(self._client._send(r.resume_schedule(name)))
|
|
402
|
+
|
|
403
|
+
def preview_cron(self, cron: str, *, timezone: str | None = None) -> CronPreview:
|
|
404
|
+
"""The next 5 times a schedule with this cron would run, without creating it.
|
|
405
|
+
|
|
406
|
+
`timezone` is an IANA name; the default is UTC.
|
|
407
|
+
"""
|
|
408
|
+
return CronPreview._from_json(self._client._send(r.preview_cron(cron, timezone)))
|
|
409
|
+
|
|
410
|
+
def trigger(self, name: str) -> Run:
|
|
411
|
+
"""Run the schedule now, even if paused. Doesn't change when it next fires."""
|
|
412
|
+
return Run._from_json(self._client._send(r.trigger_schedule(name)))
|
|
413
|
+
|
|
414
|
+
def runs(
|
|
415
|
+
self, name: str, *, state: RunState | None = None, page_size: int | None = None
|
|
416
|
+
) -> Iterator[Run]:
|
|
417
|
+
"""The schedule's runs, newest first, fetching pages as needed."""
|
|
418
|
+
cursor: str | None = None
|
|
419
|
+
while True:
|
|
420
|
+
page = self.runs_page(name, state=state, cursor=cursor, limit=page_size)
|
|
421
|
+
yield from page.runs
|
|
422
|
+
if page.next_cursor is None:
|
|
423
|
+
return
|
|
424
|
+
cursor = page.next_cursor
|
|
425
|
+
|
|
426
|
+
def runs_page(
|
|
427
|
+
self,
|
|
428
|
+
name: str,
|
|
429
|
+
*,
|
|
430
|
+
state: RunState | None = None,
|
|
431
|
+
cursor: str | None = None,
|
|
432
|
+
limit: int | None = None,
|
|
433
|
+
) -> RunPage:
|
|
434
|
+
"""One page of `runs`. Pass the previous page's `next_cursor` as `cursor`."""
|
|
435
|
+
req = r.list_runs(name, state=state, cursor=cursor, limit=limit)
|
|
436
|
+
return RunPage._from_json(self._client._send(req))
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class Runs:
|
|
440
|
+
"""Runs of schedules: one per firing, with its own attempts."""
|
|
441
|
+
|
|
442
|
+
def __init__(self, client: Workly) -> None:
|
|
443
|
+
self._client = client
|
|
444
|
+
|
|
445
|
+
def get(self, run_id: str) -> Run:
|
|
446
|
+
return Run._from_json(self._client._send(r.get_run(run_id)))
|
|
447
|
+
|
|
448
|
+
def attempts(self, run_id: str) -> list[Attempt]:
|
|
449
|
+
"""The run's delivery attempts, oldest first."""
|
|
450
|
+
data = self._client._send(r.list_run_attempts(run_id))
|
|
451
|
+
return [Attempt._from_json(a) for a in data["attempts"]]
|
|
452
|
+
|
|
453
|
+
def delivery(self, run_id: str) -> Delivery:
|
|
454
|
+
"""The request that delivers the run, as it would be sent now and freshly signed."""
|
|
455
|
+
return Delivery._from_json(self._client._send(r.get_run_delivery(run_id)))
|
|
456
|
+
|
|
457
|
+
def cancel(self, run_id: str) -> Run:
|
|
458
|
+
"""Cancel a pending or running run. A running attempt finishes but isn't retried."""
|
|
459
|
+
return Run._from_json(self._client._send(r.cancel_run(run_id)))
|
|
460
|
+
|
|
461
|
+
def retry(self, run_id: str) -> Run:
|
|
462
|
+
"""Deliver the run now. A finished or skipped run gets fresh attempts."""
|
|
463
|
+
return Run._from_json(self._client._send(r.retry_run(run_id)))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
class Alerts:
|
|
467
|
+
"""Where alerts about failed tasks and schedule runs go: email or Slack.
|
|
468
|
+
|
|
469
|
+
When tasks in a queue or runs of a schedule fail, every channel gets an
|
|
470
|
+
alert within about a minute, then at most one every 30 minutes per queue
|
|
471
|
+
or schedule.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
def __init__(self, client: Workly) -> None:
|
|
475
|
+
self._client = client
|
|
476
|
+
|
|
477
|
+
def list(self) -> AlertChannelList:
|
|
478
|
+
"""The workspace's channels, oldest first, and whether email can be sent."""
|
|
479
|
+
return AlertChannelList._from_json(self._client._send(r.list_alert_channels()))
|
|
480
|
+
|
|
481
|
+
def create(self, type: AlertChannelType, target: str) -> AlertChannel:
|
|
482
|
+
"""Add a channel: an email address, or a Slack incoming webhook URL."""
|
|
483
|
+
return AlertChannel._from_json(self._client._send(r.create_alert_channel(type, target)))
|
|
484
|
+
|
|
485
|
+
def delete(self, channel_id: str) -> None:
|
|
486
|
+
self._client._send(r.delete_alert_channel(channel_id))
|
|
487
|
+
|
|
488
|
+
def test(self, channel_id: str) -> None:
|
|
489
|
+
"""Send a test alert to the channel now. Raises APIError (alert_failed) if it can't."""
|
|
490
|
+
self._client._send(r.test_alert_channel(channel_id))
|
workly/_errors.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WorklyError(Exception):
|
|
5
|
+
"""Base class for every error the SDK raises."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIConnectionError(WorklyError):
|
|
9
|
+
"""The server couldn't be reached, or the connection failed mid-request."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIError(WorklyError):
|
|
13
|
+
"""The server answered with an error.
|
|
14
|
+
|
|
15
|
+
`code` is stable and machine-readable (e.g. `queue_not_found`);
|
|
16
|
+
`message` is for humans.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
20
|
+
super().__init__(f"{code}: {message} (HTTP {status})")
|
|
21
|
+
self.status = status
|
|
22
|
+
self.code = code
|
|
23
|
+
self.message = message
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BadRequestError(APIError):
|
|
27
|
+
"""400: the request was invalid."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuthenticationError(APIError):
|
|
31
|
+
"""401: the API key is missing or wrong."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PermissionDeniedError(APIError):
|
|
35
|
+
"""403: not allowed, or a limit was reached, e.g. a hosted workspace's monthly task quota."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotFoundError(APIError):
|
|
39
|
+
"""404: the queue or task doesn't exist."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConflictError(APIError):
|
|
43
|
+
"""409: the task is in the wrong state, e.g. retrying a running task."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PayloadTooLargeError(APIError):
|
|
47
|
+
"""413: the task body is over the server's limit."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RateLimitError(APIError):
|
|
51
|
+
"""429: too many requests; retried automatically (`max_retries`) before this is raised."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SignatureVerificationError(WorklyError):
|
|
55
|
+
"""A delivery's signature is missing, wrong or too old."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_BY_STATUS: dict[int, type[APIError]] = {
|
|
59
|
+
400: BadRequestError,
|
|
60
|
+
401: AuthenticationError,
|
|
61
|
+
403: PermissionDeniedError,
|
|
62
|
+
404: NotFoundError,
|
|
63
|
+
409: ConflictError,
|
|
64
|
+
413: PayloadTooLargeError,
|
|
65
|
+
429: RateLimitError,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def api_error(status: int, code: str, message: str) -> APIError:
|
|
70
|
+
return _BY_STATUS.get(status, APIError)(status, code, message)
|