Skip to content

Commit 6fc38e8

Browse files
docs: add module-level docstrings and fill minor API gaps
Addresses zorporation/durable-workflow#389. Adds module-level docstrings to client, errors, metrics, retry_policy, worker, and workflow; adds per-class docstrings to every exception in errors.py (which were previously inheriting Exception's base docstring); fills docstrings on ActivityContext.info/is_cancelled/heartbeat, InMemoryMetrics.counter_value/observations, and the concrete MetricsRecorder implementations. Hides plumbing methods (to_server_command, to_dict) from the mkdocstrings reference via filters and flags the RetryPolicy name-collision footgun in its module docstring (pointing at #392). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b518f82 commit 6fc38e8

8 files changed

Lines changed: 172 additions & 6 deletions

File tree

mkdocs.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ plugins:
4444
separate_signature: true
4545
merge_init_into_class: true
4646
members_order: source
47-
filters: ["!^_"]
47+
inherited_members: true
48+
filters:
49+
- "!^_"
50+
- "!^to_server_command$"
51+
- "!^to_dict$"
4852

4953
markdown_extensions:
5054
- admonition

src/durable_workflow/activity.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,26 @@ def __init__(
4545

4646
@property
4747
def info(self) -> ActivityInfo:
48+
"""Metadata for the currently running activity attempt."""
4849
return self._info
4950

5051
@property
5152
def is_cancelled(self) -> bool:
53+
"""``True`` once the server has signalled that the owning workflow cancelled this activity."""
5254
return self._cancel_requested
5355

5456
async def heartbeat(self, details: dict[str, Any] | None = None) -> None:
57+
"""Report liveness to the server and check for a cancellation request.
58+
59+
Long-running activities should call ``heartbeat()`` periodically so the
60+
server can distinguish a slow-but-alive attempt from a dead worker.
61+
Optional ``details`` are attached to the heartbeat and surface as the
62+
activity's last-known progress on failure.
63+
64+
Raises :class:`~durable_workflow.errors.ActivityCancelled` when the
65+
owning workflow has requested cancellation, so the activity can exit
66+
cleanly at its next natural break point.
67+
"""
5568
resp = await self._client.heartbeat_activity_task(
5669
task_id=self._info.task_id,
5770
activity_attempt_id=self._info.activity_attempt_id,

src/durable_workflow/client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
"""Async client for the Durable Workflow server's control and worker planes.
2+
3+
The :class:`Client` wraps the server's HTTP/JSON protocol. Control-plane
4+
methods (``start_workflow``, ``signal_workflow``, ``describe_workflow``,
5+
schedule management, …) are what callers use to drive workflows from outside.
6+
Worker-plane methods (``register_worker``, ``poll_workflow_task``,
7+
``complete_activity_task``, …) are what the :class:`~durable_workflow.Worker`
8+
uses to run tasks; they are public so advanced users can build custom
9+
workers, but most applications should not call them directly.
10+
11+
The module also defines the returned-value dataclasses (``WorkflowExecution``,
12+
``WorkflowList``, ``ScheduleSpec``, ``ScheduleDescription``, …) and the
13+
ergonomic handle classes (:class:`WorkflowHandle`, :class:`ScheduleHandle`)
14+
that bind a workflow or schedule id to a :class:`Client` so you can call
15+
methods without repeating the id on every call.
16+
"""
17+
118
from __future__ import annotations
219

320
import asyncio

src/durable_workflow/errors.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,172 @@
1+
"""Typed exceptions raised by the Durable Workflow client and worker.
2+
3+
Every exception inherits from :class:`DurableWorkflowError`, so callers that
4+
only want to distinguish SDK errors from unrelated failures can catch that
5+
base. More specific subclasses let callers react to particular outcomes —
6+
workflow-not-found, update-rejected, schedule-already-exists — without
7+
parsing server response bodies.
8+
"""
9+
110
from __future__ import annotations
211

312
from typing import Any
413

514

615
class DurableWorkflowError(Exception):
7-
pass
16+
"""Base class for every exception raised by the SDK."""
817

918

1019
class ServerError(DurableWorkflowError):
20+
"""A server response was an error that does not map to a typed subclass.
21+
22+
The HTTP status is on :attr:`status`, and the parsed JSON body is on
23+
:attr:`body` when the server returned one.
24+
"""
25+
1126
def __init__(self, status: int, body: object) -> None:
1227
super().__init__(f"server returned {status}: {body!r}")
1328
self.status = status
1429
self.body = body
1530

1631
def reason(self) -> str | None:
32+
"""Return the machine-readable ``reason`` field from the response body, if any."""
1733
if isinstance(self.body, dict):
1834
return self.body.get("reason")
1935
return None
2036

2137

2238
class WorkflowFailed(DurableWorkflowError):
39+
"""A workflow finished in the ``failed`` state.
40+
41+
:attr:`exception_class` carries the fully qualified name of the exception
42+
class the workflow raised, when the server recorded one.
43+
"""
44+
2345
def __init__(self, message: str, exception_class: str | None = None) -> None:
2446
super().__init__(message)
2547
self.exception_class = exception_class
2648

2749

2850
class WorkflowNotFound(DurableWorkflowError):
51+
"""The addressed workflow instance does not exist on the server."""
52+
2953
def __init__(self, workflow_id: str) -> None:
3054
super().__init__(f"workflow not found: {workflow_id}")
3155
self.workflow_id = workflow_id
3256

3357

3458
class WorkflowAlreadyStarted(DurableWorkflowError):
59+
"""A start request collided with an existing instance id.
60+
61+
Raised when duplicate-start policy is ``reject`` (the default) and the
62+
caller-supplied ``workflow_id`` is already in use.
63+
"""
64+
3565
def __init__(self, workflow_id: str) -> None:
3666
super().__init__(f"workflow already started: {workflow_id}")
3767
self.workflow_id = workflow_id
3868

3969

4070
class NamespaceNotFound(DurableWorkflowError):
71+
"""The namespace configured on the :class:`~durable_workflow.Client` is unknown to the server."""
72+
4173
def __init__(self, namespace: str) -> None:
4274
super().__init__(f"namespace not found: {namespace}")
4375
self.namespace = namespace
4476

4577

4678
class InvalidArgument(DurableWorkflowError):
79+
"""The server rejected the request as malformed (HTTP 422).
80+
81+
:attr:`errors` holds the structured validation errors from the response
82+
body when the server returned them.
83+
"""
84+
4785
def __init__(self, message: str, errors: dict[str, Any] | None = None) -> None:
4886
super().__init__(message)
4987
self.errors = errors
5088

5189

5290
class Unauthorized(DurableWorkflowError):
91+
"""The request was rejected for missing or invalid authentication (HTTP 401)."""
92+
5393
def __init__(self, message: str = "unauthorized") -> None:
5494
super().__init__(message)
5595

5696

5797
class ScheduleNotFound(DurableWorkflowError):
98+
"""The addressed schedule does not exist on the server."""
99+
58100
def __init__(self, schedule_id: str) -> None:
59101
super().__init__(f"schedule not found: {schedule_id}")
60102
self.schedule_id = schedule_id
61103

62104

63105
class ScheduleAlreadyExists(DurableWorkflowError):
106+
"""A create-schedule request collided with an existing schedule id."""
107+
64108
def __init__(self, schedule_id: str) -> None:
65109
super().__init__(f"schedule already exists: {schedule_id}")
66110
self.schedule_id = schedule_id
67111

68112

69113
class QueryFailed(DurableWorkflowError):
70-
pass
114+
"""A workflow query was rejected or the workflow raised while handling it."""
71115

72116

73117
class UpdateRejected(DurableWorkflowError):
74-
pass
118+
"""A workflow update was rejected by the workflow's validator."""
75119

76120

77121
class ChildWorkflowFailed(DurableWorkflowError):
122+
"""A child workflow finished in the ``failed`` state.
123+
124+
Raised inside the parent workflow when it awaits the child's result.
125+
:attr:`exception_class` mirrors the child's recorded exception class.
126+
"""
127+
78128
def __init__(self, message: str, exception_class: str | None = None) -> None:
79129
super().__init__(message)
80130
self.exception_class = exception_class
81131

82132

83133
class WorkflowTerminated(DurableWorkflowError):
134+
"""A workflow was terminated by operator action.
135+
136+
Termination is non-gracious and skips normal cleanup, unlike cancellation.
137+
"""
138+
84139
def __init__(self, message: str = "workflow was terminated") -> None:
85140
super().__init__(message)
86141

87142

88143
class WorkflowCancelled(DurableWorkflowError):
144+
"""A workflow was cancelled and finished in the ``cancelled`` state."""
145+
89146
def __init__(self, message: str = "workflow was cancelled") -> None:
90147
super().__init__(message)
91148

92149

93150
class ActivityCancelled(DurableWorkflowError):
151+
"""An in-flight activity was cancelled.
152+
153+
Raised inside :meth:`durable_workflow.ActivityContext.heartbeat` when the
154+
server reports that the owning workflow has asked for cancellation, so the
155+
activity can exit cleanly on its next heartbeat.
156+
"""
157+
94158
def __init__(self, message: str = "activity was cancelled") -> None:
95159
super().__init__(message)
96160

97161

98162
class NonRetryableError(DurableWorkflowError):
163+
"""Marker an activity can raise to fail its workflow without further retries.
164+
165+
The server stops retrying the activity and surfaces the failure to the
166+
workflow as a terminal activity error, regardless of the configured retry
167+
policy.
168+
"""
169+
99170
def __init__(self, message: str, *, cause: Exception | None = None) -> None:
100171
super().__init__(message)
101172
self.__cause__ = cause

src/durable_workflow/metrics.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
"""Pluggable metrics hooks for the Durable Workflow client and worker.
2+
3+
Pass any :class:`MetricsRecorder` implementation as ``metrics=`` on
4+
:class:`~durable_workflow.Client` or :class:`~durable_workflow.Worker`. The
5+
SDK ships three recorders out of the box: :class:`NoopMetrics` (the default),
6+
:class:`InMemoryMetrics` for tests and small exporter loops, and
7+
:class:`PrometheusMetrics` which forwards to the optional ``prometheus-client``
8+
package. Custom recorders implement two methods — :meth:`MetricsRecorder.increment`
9+
and :meth:`MetricsRecorder.record` — and receive stable metric names and tag
10+
dicts defined as module-level constants in this file.
11+
"""
12+
113
from __future__ import annotations
214

315
from collections.abc import Mapping
@@ -30,10 +42,10 @@ class NoopMetrics:
3042
"""Default metrics recorder that intentionally drops all observations."""
3143

3244
def increment(self, name: str, value: float = 1.0, tags: MetricTags | None = None) -> None:
33-
pass
45+
"""Implements :meth:`MetricsRecorder.increment` as a no-op."""
3446

3547
def record(self, name: str, value: float, tags: MetricTags | None = None) -> None:
36-
pass
48+
"""Implements :meth:`MetricsRecorder.record` as a no-op."""
3749

3850

3951
NOOP_METRICS = NoopMetrics()
@@ -51,16 +63,20 @@ class InMemoryMetrics:
5163
histograms: dict[MetricKey, list[float]] = field(default_factory=dict)
5264

5365
def increment(self, name: str, value: float = 1.0, tags: MetricTags | None = None) -> None:
66+
"""Accumulate into an in-memory counter keyed by ``name`` + sorted ``tags``."""
5467
key = _metric_key(name, tags)
5568
self.counters[key] = self.counters.get(key, 0.0) + value
5669

5770
def record(self, name: str, value: float, tags: MetricTags | None = None) -> None:
71+
"""Append an observation to an in-memory histogram keyed by ``name`` + sorted ``tags``."""
5872
self.histograms.setdefault(_metric_key(name, tags), []).append(value)
5973

6074
def counter_value(self, name: str, tags: MetricTags | None = None) -> float:
75+
"""Return the current value of a counter, or ``0.0`` if it has never been incremented."""
6176
return self.counters.get(_metric_key(name, tags), 0.0)
6277

6378
def observations(self, name: str, tags: MetricTags | None = None) -> list[float]:
79+
"""Return a copy of the histogram observations recorded under ``name`` + ``tags``."""
6480
return list(self.histograms.get(_metric_key(name, tags), []))
6581

6682

@@ -84,6 +100,7 @@ def __init__(self, *, registry: Any | None = None) -> None:
84100
self._label_names: dict[tuple[str, str], tuple[str, ...]] = {}
85101

86102
def increment(self, name: str, value: float = 1.0, tags: MetricTags | None = None) -> None:
103+
"""Forward to a ``prometheus_client.Counter``, creating one on first use."""
87104
tag_values = dict(_metric_key(name, tags)[1])
88105
counter = self._metric("counter", name, tuple(tag_values))
89106
if tag_values:
@@ -92,6 +109,7 @@ def increment(self, name: str, value: float = 1.0, tags: MetricTags | None = Non
92109
counter.inc(value)
93110

94111
def record(self, name: str, value: float, tags: MetricTags | None = None) -> None:
112+
"""Forward to a ``prometheus_client.Histogram``, creating one on first use."""
95113
tag_values = dict(_metric_key(name, tags)[1])
96114
histogram = self._metric("histogram", name, tuple(tag_values))
97115
if tag_values:

src/durable_workflow/retry_policy.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
"""HTTP transport retry policy used inside :class:`~durable_workflow.Client`.
2+
3+
.. warning::
4+
5+
This :class:`RetryPolicy` covers **only client-side HTTP retries** for
6+
transient transport errors (connection failures, timeouts, 5xx responses,
7+
429 rate-limiting). It is **not** the activity retry policy. Activity-level
8+
retry and timeout configuration is tracked in
9+
https://github.com/zorporation/durable-workflow/issues/392 and will land on
10+
``ctx.schedule_activity(..., retry_policy=...)``.
11+
"""
12+
113
from __future__ import annotations
214

315
import asyncio

src/durable_workflow/worker.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
"""Long-polling worker that runs workflow and activity tasks.
2+
3+
:class:`Worker` registers itself with the server for a given task queue, then
4+
spawns poll loops for both workflow tasks and activity tasks. Each received
5+
task is dispatched to the registered workflow class or activity function,
6+
results are serialized, and success/failure commands are sent back to the
7+
server. Workers drain in-flight tasks on shutdown up to a configurable
8+
``shutdown_timeout``.
9+
10+
Most applications create one :class:`Worker` per task queue and pass it the
11+
same :class:`~durable_workflow.Client` used for control-plane calls, plus
12+
lists of workflow classes and activity callables registered via
13+
:func:`durable_workflow.workflow.defn` and :func:`durable_workflow.activity.defn`.
14+
"""
15+
116
from __future__ import annotations
217

318
import asyncio

src/durable_workflow/workflow.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
"""Workflow authoring primitives: decorators, context, commands, and replayer.
2+
3+
A workflow is a Python class registered with :func:`defn`. Its ``run`` method
4+
is a generator that yields command dataclasses (``ScheduleActivity``,
5+
``StartTimer``, ``StartChildWorkflow``, …) — the worker's replayer drives the
6+
generator forward by resolving each yielded command against the current
7+
history of the workflow run. Yield a *list* of commands to run them in
8+
parallel.
9+
10+
Determinism-sensitive helpers live on the :class:`WorkflowContext` passed to
11+
``run``: :meth:`WorkflowContext.random`, :meth:`WorkflowContext.uuid4`,
12+
:meth:`WorkflowContext.now`, and :meth:`WorkflowContext.side_effect` all
13+
produce values that are recorded on first execution and replayed verbatim
14+
on every subsequent replay of the same history.
15+
"""
16+
117
from __future__ import annotations
218

319
import contextlib

0 commit comments

Comments
 (0)