|
| 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 | + |
1 | 10 | from __future__ import annotations |
2 | 11 |
|
3 | 12 | from typing import Any |
4 | 13 |
|
5 | 14 |
|
6 | 15 | class DurableWorkflowError(Exception): |
7 | | - pass |
| 16 | + """Base class for every exception raised by the SDK.""" |
8 | 17 |
|
9 | 18 |
|
10 | 19 | 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 | + |
11 | 26 | def __init__(self, status: int, body: object) -> None: |
12 | 27 | super().__init__(f"server returned {status}: {body!r}") |
13 | 28 | self.status = status |
14 | 29 | self.body = body |
15 | 30 |
|
16 | 31 | def reason(self) -> str | None: |
| 32 | + """Return the machine-readable ``reason`` field from the response body, if any.""" |
17 | 33 | if isinstance(self.body, dict): |
18 | 34 | return self.body.get("reason") |
19 | 35 | return None |
20 | 36 |
|
21 | 37 |
|
22 | 38 | 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 | + |
23 | 45 | def __init__(self, message: str, exception_class: str | None = None) -> None: |
24 | 46 | super().__init__(message) |
25 | 47 | self.exception_class = exception_class |
26 | 48 |
|
27 | 49 |
|
28 | 50 | class WorkflowNotFound(DurableWorkflowError): |
| 51 | + """The addressed workflow instance does not exist on the server.""" |
| 52 | + |
29 | 53 | def __init__(self, workflow_id: str) -> None: |
30 | 54 | super().__init__(f"workflow not found: {workflow_id}") |
31 | 55 | self.workflow_id = workflow_id |
32 | 56 |
|
33 | 57 |
|
34 | 58 | 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 | + |
35 | 65 | def __init__(self, workflow_id: str) -> None: |
36 | 66 | super().__init__(f"workflow already started: {workflow_id}") |
37 | 67 | self.workflow_id = workflow_id |
38 | 68 |
|
39 | 69 |
|
40 | 70 | class NamespaceNotFound(DurableWorkflowError): |
| 71 | + """The namespace configured on the :class:`~durable_workflow.Client` is unknown to the server.""" |
| 72 | + |
41 | 73 | def __init__(self, namespace: str) -> None: |
42 | 74 | super().__init__(f"namespace not found: {namespace}") |
43 | 75 | self.namespace = namespace |
44 | 76 |
|
45 | 77 |
|
46 | 78 | 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 | + |
47 | 85 | def __init__(self, message: str, errors: dict[str, Any] | None = None) -> None: |
48 | 86 | super().__init__(message) |
49 | 87 | self.errors = errors |
50 | 88 |
|
51 | 89 |
|
52 | 90 | class Unauthorized(DurableWorkflowError): |
| 91 | + """The request was rejected for missing or invalid authentication (HTTP 401).""" |
| 92 | + |
53 | 93 | def __init__(self, message: str = "unauthorized") -> None: |
54 | 94 | super().__init__(message) |
55 | 95 |
|
56 | 96 |
|
57 | 97 | class ScheduleNotFound(DurableWorkflowError): |
| 98 | + """The addressed schedule does not exist on the server.""" |
| 99 | + |
58 | 100 | def __init__(self, schedule_id: str) -> None: |
59 | 101 | super().__init__(f"schedule not found: {schedule_id}") |
60 | 102 | self.schedule_id = schedule_id |
61 | 103 |
|
62 | 104 |
|
63 | 105 | class ScheduleAlreadyExists(DurableWorkflowError): |
| 106 | + """A create-schedule request collided with an existing schedule id.""" |
| 107 | + |
64 | 108 | def __init__(self, schedule_id: str) -> None: |
65 | 109 | super().__init__(f"schedule already exists: {schedule_id}") |
66 | 110 | self.schedule_id = schedule_id |
67 | 111 |
|
68 | 112 |
|
69 | 113 | class QueryFailed(DurableWorkflowError): |
70 | | - pass |
| 114 | + """A workflow query was rejected or the workflow raised while handling it.""" |
71 | 115 |
|
72 | 116 |
|
73 | 117 | class UpdateRejected(DurableWorkflowError): |
74 | | - pass |
| 118 | + """A workflow update was rejected by the workflow's validator.""" |
75 | 119 |
|
76 | 120 |
|
77 | 121 | 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 | + |
78 | 128 | def __init__(self, message: str, exception_class: str | None = None) -> None: |
79 | 129 | super().__init__(message) |
80 | 130 | self.exception_class = exception_class |
81 | 131 |
|
82 | 132 |
|
83 | 133 | 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 | + |
84 | 139 | def __init__(self, message: str = "workflow was terminated") -> None: |
85 | 140 | super().__init__(message) |
86 | 141 |
|
87 | 142 |
|
88 | 143 | class WorkflowCancelled(DurableWorkflowError): |
| 144 | + """A workflow was cancelled and finished in the ``cancelled`` state.""" |
| 145 | + |
89 | 146 | def __init__(self, message: str = "workflow was cancelled") -> None: |
90 | 147 | super().__init__(message) |
91 | 148 |
|
92 | 149 |
|
93 | 150 | 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 | + |
94 | 158 | def __init__(self, message: str = "activity was cancelled") -> None: |
95 | 159 | super().__init__(message) |
96 | 160 |
|
97 | 161 |
|
98 | 162 | 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 | + |
99 | 170 | def __init__(self, message: str, *, cause: Exception | None = None) -> None: |
100 | 171 | super().__init__(message) |
101 | 172 | self.__cause__ = cause |
|
0 commit comments