ruby_reactor 0.8.3 → 0.8.4
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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +12 -0
- data/CLAUDE.md +1 -1
- data/README.md +93 -91
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +40 -14
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +2 -2
- data/lib/ruby_reactor/executor/result_handler.rb +3 -2
- data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
- data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
- data/lib/ruby_reactor/step/compose_step.rb +8 -4
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +32 -22
- data/lib/ruby_reactor/step.rb +13 -7
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +3 -1
- data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +13 -1
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# Feature Specification: Step-Scoped Retry Declarations
|
|
2
|
+
|
|
3
|
+
**Feature Branch**: `retry_confs_in_steps`
|
|
4
|
+
|
|
5
|
+
**Created**: 2026-09-25
|
|
6
|
+
|
|
7
|
+
**Status**: Draft
|
|
8
|
+
|
|
9
|
+
**Input**: User description: "As we did previously with `locks` we have to continue moving
|
|
10
|
+
configurations from the main reactor to the steps. Currently:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
class PaymentReactor < RubyReactor::Reactor
|
|
14
|
+
background all: true
|
|
15
|
+
|
|
16
|
+
step :charge_card, ChargeCard do
|
|
17
|
+
retries max_attempts: 3, backoff: :exponential, base_delay: 5.seconds
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
But we are designing the DSL so that steps are encapsulated units of work that know the best way
|
|
23
|
+
to be executed. The preferred way:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
class ChargeCard < RubyReactor::Step
|
|
27
|
+
with_lock(...) # already implemented
|
|
28
|
+
input :email, :string, format?: EMAIL_REGEX
|
|
29
|
+
retries max_attempts: 3 # AND!!
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
logic
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
As always, inline reactor steps and reactor declarations are supported and maintained. This is
|
|
38
|
+
valid:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class PaymentReactor < RubyReactor::Reactor
|
|
42
|
+
background all: true
|
|
43
|
+
|
|
44
|
+
step :charge_card do
|
|
45
|
+
retries max_attempts: 3, backoff: :exponential, base_delay: 5.seconds
|
|
46
|
+
run { PaymentService.charge(card_token, amount) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
We have to emulate the logic and behaviour we implemented for locks and inputs."
|
|
52
|
+
|
|
53
|
+
Follow-up from the user: "And we should remove altogether the retry defaults (`retry_defaults`
|
|
54
|
+
on the reactor). Those are a bad practice and make retries unpredictable."
|
|
55
|
+
|
|
56
|
+
## Clarifications
|
|
57
|
+
|
|
58
|
+
### Session 2026-09-25
|
|
59
|
+
|
|
60
|
+
- Q: Should a direct call to a step class (outside any reactor) honor the class's retry
|
|
61
|
+
policy? → A: No. A direct call runs once. Only the reactor coordinates retries.
|
|
62
|
+
|
|
63
|
+
## User Scenarios & Testing *(mandatory)*
|
|
64
|
+
|
|
65
|
+
### User Story 1 - Remove reactor-wide retry defaults first (Priority: P1, delivered first)
|
|
66
|
+
|
|
67
|
+
An author reading a reactor must be able to tell how each step is retried by looking at that
|
|
68
|
+
step alone: its class or its step block. Reactor-wide retry defaults break this, because a
|
|
69
|
+
line at the top of a reactor silently changes how every step without its own policy behaves.
|
|
70
|
+
The same step class can be retried three times in one reactor and never in another, and
|
|
71
|
+
moving a line up or down in the reactor changes which steps it applies to.
|
|
72
|
+
|
|
73
|
+
Reactor-wide retry defaults are removed. A step that declares no policy runs once. Reactors
|
|
74
|
+
that still call the removed declaration fail to load with a message explaining the
|
|
75
|
+
replacement.
|
|
76
|
+
|
|
77
|
+
**Why this priority**: Reactor-wide defaults are the main source of unpredictable retries.
|
|
78
|
+
Removing them first also makes the rest of this feature simpler: once they are gone, a step's
|
|
79
|
+
policy can only come from two places (its step block or its step class), so no later story
|
|
80
|
+
has to decide how a step class policy ranks against a reactor default, or whether it depends
|
|
81
|
+
on where the defaults line sits in the reactor. This story is delivered and verified on its
|
|
82
|
+
own, before any step class declaration work starts (see Delivery order).
|
|
83
|
+
|
|
84
|
+
**Independent Test**: Define a reactor that uses the removed reactor-wide defaults declaration
|
|
85
|
+
and confirm it fails to load with a message naming the reactor and pointing to step-level
|
|
86
|
+
`retries`. Then define a reactor whose steps declare no policy and confirm each failing step
|
|
87
|
+
is attempted exactly once.
|
|
88
|
+
|
|
89
|
+
**Acceptance Scenarios**:
|
|
90
|
+
|
|
91
|
+
1. **Given** a reactor that declares reactor-wide retry defaults, **When** the reactor class is
|
|
92
|
+
loaded, **Then** loading fails with a message naming the reactor, stating that reactor-wide
|
|
93
|
+
defaults were removed, and telling the author to declare `retries` on each step class or
|
|
94
|
+
step block that needs it.
|
|
95
|
+
2. **Given** a step with no retry declaration in its class or its step block, **When** it
|
|
96
|
+
fails, **Then** it is attempted exactly once and the failure proceeds to rollback.
|
|
97
|
+
3. **Given** a reactor that did not use reactor-wide defaults before this change, **When** it
|
|
98
|
+
runs after the change, **Then** every step behaves exactly as before.
|
|
99
|
+
4. **Given** nested reactors placed with `compose` or `async_reactor`, **When** their step
|
|
100
|
+
blocks declare no `retries`, **Then** they are attempted once. They no longer read a
|
|
101
|
+
reactor-wide default.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### User Story 2 - A step class declares its own retry policy (Priority: P1)
|
|
106
|
+
|
|
107
|
+
A workflow author writes a step that talks to a flaky external service: charging a card,
|
|
108
|
+
calling a partner API, sending an email. The author knows how that operation should be retried
|
|
109
|
+
(how many attempts, how far apart), because the author knows the service. Today that
|
|
110
|
+
knowledge can only be written in each reactor that uses the step, so every reactor has to
|
|
111
|
+
repeat it and can get it wrong.
|
|
112
|
+
|
|
113
|
+
The author declares the retry policy in the step class, next to its inputs and its lock. Any
|
|
114
|
+
reactor that uses the step gets that policy without mentioning it.
|
|
115
|
+
|
|
116
|
+
**Why this priority**: This is the main feature. Without it, the step is not a complete unit
|
|
117
|
+
of work: part of how it should run still lives in every caller. It builds on US1: by the
|
|
118
|
+
time it starts, reactor-wide defaults no longer exist.
|
|
119
|
+
|
|
120
|
+
**Independent Test**: Define a step class that declares three attempts and fails on its first
|
|
121
|
+
two runs. Place it in a reactor with no retry wiring and confirm the step succeeds on its third
|
|
122
|
+
attempt. Then make it fail on every run and confirm the workflow fails after exactly three
|
|
123
|
+
attempts.
|
|
124
|
+
|
|
125
|
+
**Acceptance Scenarios**:
|
|
126
|
+
|
|
127
|
+
1. **Given** a step class declaring a maximum of N attempts, **When** a reactor uses the step
|
|
128
|
+
without any retry wiring and the step fails with a retryable failure, **Then** the step is
|
|
129
|
+
attempted again, up to N attempts in total.
|
|
130
|
+
2. **Given** the same step, **When** an attempt succeeds before N attempts are used,
|
|
131
|
+
**Then** the workflow continues with that attempt's result and makes no further attempts.
|
|
132
|
+
3. **Given** the same step, **When** all N attempts fail, **Then** the step's failure reports
|
|
133
|
+
the step name and the number of attempts made, and rollback proceeds as for any step
|
|
134
|
+
failure.
|
|
135
|
+
4. **Given** a step class declaring a backoff strategy and base delay, **When** attempts are
|
|
136
|
+
retried, **Then** the wait between attempts follows that strategy and delay.
|
|
137
|
+
5. **Given** a step class declaring retries with only some options (for example only the
|
|
138
|
+
number of attempts), **When** it runs, **Then** the options it left out take the same
|
|
139
|
+
defaults the reactor-side `retries` uses today.
|
|
140
|
+
6. **Given** a step fails with a failure marked as not retryable, **When** its class declares
|
|
141
|
+
retries, **Then** it is not attempted again, exactly as with reactor-side retries today.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### User Story 3 - Existing step-level declarations keep working unchanged (Priority: P1)
|
|
146
|
+
|
|
147
|
+
Authors already declare retries in a reactor's step block, for inline steps and for class
|
|
148
|
+
steps. Both keep working exactly as they do today. The class-level declaration is the
|
|
149
|
+
preferred form for class steps, not a replacement.
|
|
150
|
+
|
|
151
|
+
**Why this priority**: Inline steps are a supported style, and existing step-block
|
|
152
|
+
declarations must not break.
|
|
153
|
+
|
|
154
|
+
**Independent Test**: Run the existing step-level retry tests unchanged and confirm they pass.
|
|
155
|
+
Then declare `retries` inside an inline step block and confirm the behavior matches the class
|
|
156
|
+
form.
|
|
157
|
+
|
|
158
|
+
**Acceptance Scenarios**:
|
|
159
|
+
|
|
160
|
+
1. **Given** an inline step that declares `retries` and a `run` body in its block, **When** it
|
|
161
|
+
fails, **Then** it is retried according to that declaration, exactly as today.
|
|
162
|
+
2. **Given** a class step that declares no retries, **When** its reactor step block declares
|
|
163
|
+
`retries`, **Then** the step-block declaration applies, exactly as today.
|
|
164
|
+
3. **Given** an inline step and a class step with the same retry declaration, **When** both
|
|
165
|
+
fail the same way, **Then** their attempt counts, delays, and final outcome are the same.
|
|
166
|
+
4. **Given** an inline step's `retries` line, **When** the author moves the step into a class,
|
|
167
|
+
**Then** the same line works unchanged in the class body.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### User Story 4 - One declaration per step (Priority: P1)
|
|
172
|
+
|
|
173
|
+
A step's policy is declared in exactly one place. Declaring it in the step class and again in
|
|
174
|
+
the reactor's step block for that class is refused when the reactor is defined, the same way a
|
|
175
|
+
lock declared in both places is refused today.
|
|
176
|
+
|
|
177
|
+
**Why this priority**: If both declarations were accepted silently, one would be ignored and
|
|
178
|
+
the author would not know which. That is how retries end up wrong in production.
|
|
179
|
+
|
|
180
|
+
**Independent Test**: Declare retries on a step class, then add a `retries` line to a
|
|
181
|
+
reactor's step block for that class. Confirm the reactor fails to load with a message naming
|
|
182
|
+
the reactor, the step, and the class, and explaining how to resolve it.
|
|
183
|
+
|
|
184
|
+
**Acceptance Scenarios**:
|
|
185
|
+
|
|
186
|
+
1. **Given** a step class declaring retries, **When** a reactor also declares `retries` in that
|
|
187
|
+
step's block, **Then** the reactor is refused at definition time with a message naming the
|
|
188
|
+
reactor, the step, and the class, and telling the author to keep only one declaration.
|
|
189
|
+
2. **Given** a step class that declares no retries, **When** a reactor declares `retries` in
|
|
190
|
+
its step block, **Then** no conflict is raised (US3 scenario 2).
|
|
191
|
+
3. **Given** a step class declaring a single attempt, **When** it runs, **Then** it is not
|
|
192
|
+
retried. Declaring one attempt is a valid, explicit "do not retry".
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
### User Story 5 - The policy follows the step on every execution path (Priority: P2)
|
|
197
|
+
|
|
198
|
+
A step class's retry policy applies wherever the step runs: in the calling process, in a
|
|
199
|
+
background worker after the whole reactor is handed off, after a mid-workflow hand-off, as an
|
|
200
|
+
independently dispatched step, and after the workflow resumes from an interrupt. Attempts
|
|
201
|
+
already made are remembered when a retry is scheduled for later, so the attempt limit holds
|
|
202
|
+
across those gaps.
|
|
203
|
+
|
|
204
|
+
**Why this priority**: A policy that only applies on some paths is a trap, and background runs
|
|
205
|
+
are where retries matter most. But the synchronous path alone already delivers value.
|
|
206
|
+
|
|
207
|
+
**Independent Test**: Run the same reactor with the same always-failing class step
|
|
208
|
+
synchronously and in the background. Confirm both make exactly the declared number of
|
|
209
|
+
attempts, and that the background run schedules later attempts without blocking the worker.
|
|
210
|
+
|
|
211
|
+
**Acceptance Scenarios**:
|
|
212
|
+
|
|
213
|
+
1. **Given** a class step with a declared policy in a reactor that runs in the calling process,
|
|
214
|
+
**When** it fails, **Then** retries wait in-process between attempts, as reactor-side
|
|
215
|
+
retries do today.
|
|
216
|
+
2. **Given** the same step in a reactor that runs in a background worker, **When** it fails,
|
|
217
|
+
**Then** the next attempt is scheduled for later instead of blocking the worker, as
|
|
218
|
+
reactor-side retries do today.
|
|
219
|
+
3. **Given** the same step placed as an independently dispatched step, **When** it fails,
|
|
220
|
+
**Then** its declared policy governs its retries.
|
|
221
|
+
4. **Given** a retry scheduled for later, **When** the next attempt runs, **Then** the attempts
|
|
222
|
+
already made count toward the limit, and the workflow never exceeds the declared maximum.
|
|
223
|
+
5. **Given** a step that exhausts its attempts in any of these paths, **When** it fails for the
|
|
224
|
+
last time, **Then** earlier steps compensate exactly as they do for reactor-side retries.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
### User Story 6 - Step subclasses inherit the policy (Priority: P2)
|
|
229
|
+
|
|
230
|
+
A team keeps a base step class for all calls to one partner API, with the retry policy that
|
|
231
|
+
API needs. Each concrete step inherits that policy. A subclass that needs a different policy
|
|
232
|
+
declares its own, and this does not change its parent or its siblings.
|
|
233
|
+
|
|
234
|
+
**Why this priority**: Step inheritance is already supported for inputs and locks. Retries
|
|
235
|
+
must behave the same way, but the feature works without it.
|
|
236
|
+
|
|
237
|
+
**Independent Test**: Declare retries on a base step class, subclass it twice, override the
|
|
238
|
+
policy in one subclass, and confirm each class runs with the expected policy.
|
|
239
|
+
|
|
240
|
+
**Acceptance Scenarios**:
|
|
241
|
+
|
|
242
|
+
1. **Given** a base step class declaring retries, **When** a subclass declares none, **Then**
|
|
243
|
+
the subclass runs with the base policy.
|
|
244
|
+
2. **Given** a subclass that declares its own retries, **When** it runs, **Then** its own
|
|
245
|
+
policy applies, and the base class and other subclasses keep the base policy.
|
|
246
|
+
3. **Given** a reactor needs a different policy for a class step that already declares one,
|
|
247
|
+
**When** the author subclasses the step and declares the new policy there, **Then** the
|
|
248
|
+
reactor uses the subclass with no conflict. This is the documented way to vary the policy
|
|
249
|
+
per workflow.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
### User Story 7 - Tests and operators can see the policy (Priority: P2)
|
|
254
|
+
|
|
255
|
+
A developer writing a spec for a reactor uses the shipped test helpers to check that a
|
|
256
|
+
class-declared policy was applied: how many times a step was retried, and that it failed or
|
|
257
|
+
succeeded afterward. Operators and tooling can find out what retry policy a step will run with
|
|
258
|
+
and where it was declared.
|
|
259
|
+
|
|
260
|
+
**Why this priority**: Required by the project's testing and observability rules. The feature
|
|
261
|
+
works without it, but cannot be verified or debugged without it.
|
|
262
|
+
|
|
263
|
+
**Independent Test**: Write a reactor spec using only the shipped test surface that makes a
|
|
264
|
+
class step fail and asserts it was retried the declared number of times. Then look up the
|
|
265
|
+
step's effective policy from the reactor and confirm it reports the class as its source.
|
|
266
|
+
|
|
267
|
+
**Acceptance Scenarios**:
|
|
268
|
+
|
|
269
|
+
1. **Given** a class step with a declared policy, **When** it fails in a test using the shipped
|
|
270
|
+
test helpers, **Then** the existing retry assertions report the attempts it made.
|
|
271
|
+
2. **Given** a class step whose body is replaced by a test mock, **When** the mock fails,
|
|
272
|
+
**Then** the class's declared policy still applies, so tests exercise the real policy.
|
|
273
|
+
3. **Given** any step in a reactor, **When** its effective retry policy is looked up, **Then**
|
|
274
|
+
the answer includes the maximum attempts, backoff, base delay, and whether it came from the
|
|
275
|
+
step class, the step block, or no declaration.
|
|
276
|
+
4. **Given** a class step being retried, **When** instrumentation is enabled, **Then** each
|
|
277
|
+
retry attempt is reported with the step name and attempt number, exactly as for
|
|
278
|
+
reactor-side retries today.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
### Edge Cases
|
|
283
|
+
|
|
284
|
+
- **Invalid values**: an unknown backoff strategy, a negative delay, or an attempt count that
|
|
285
|
+
is not a positive whole number is refused when the step class (or inline step) is defined,
|
|
286
|
+
naming the step and the bad value. Today an unknown backoff strategy only fails at the first
|
|
287
|
+
retry, in production.
|
|
288
|
+
- **Direct invocation**: a step class that declares retries is called directly from
|
|
289
|
+
application code, or from another step's body, not through a reactor. The call runs once
|
|
290
|
+
and its retries do not apply: only a reactor coordinates retries. A failure is returned to
|
|
291
|
+
the caller straight away. This differs on purpose from locks, which a direct call does take.
|
|
292
|
+
- **Reactor subclasses and test doubles**: a reactor subclass, or a test copy of a reactor
|
|
293
|
+
made by the shipped test helpers, must not bring back reactor-wide defaults by copying them
|
|
294
|
+
from a parent.
|
|
295
|
+
- **Retries and locks**: a step class declares both a lock and retries. Their combined behavior
|
|
296
|
+
must be the same as when the same two declarations are made on the reactor side today. This
|
|
297
|
+
feature changes where the policy is declared, not how retries use locks.
|
|
298
|
+
- **Retries and input validation**: a step whose inputs fail its own contract is not retried.
|
|
299
|
+
Invalid inputs will not become valid on another attempt, and today reactor-side retries do
|
|
300
|
+
not retry contract failures either.
|
|
301
|
+
- **Skipped steps**: a step skipped by a condition or guard makes no attempts and uses none of
|
|
302
|
+
its retry budget.
|
|
303
|
+
- **Policy changed between attempts**: a retry scheduled for later runs under the policy in the
|
|
304
|
+
code that the worker is running. Attempts already made still count.
|
|
305
|
+
- **Duck-typed implementations**: a step implementation that is not a step class has no class
|
|
306
|
+
policy. Only its step-block declaration applies, and without one it runs once.
|
|
307
|
+
- **Nested reactors and map elements**: `compose`, `async_reactor`, and `map` place reactors,
|
|
308
|
+
not step classes. Their step-block `retries` declarations keep working. A step class used
|
|
309
|
+
inside a nested or mapped reactor brings its own policy there too.
|
|
310
|
+
|
|
311
|
+
## Requirements *(mandatory)*
|
|
312
|
+
|
|
313
|
+
### Functional Requirements
|
|
314
|
+
|
|
315
|
+
#### Declaration
|
|
316
|
+
|
|
317
|
+
- **FR-001**: A step class MUST be able to declare its retry policy (maximum attempts, backoff
|
|
318
|
+
strategy, base delay) using the same `retries` vocabulary and defaults as the reactor-side
|
|
319
|
+
step block.
|
|
320
|
+
- **FR-002**: An inline step MUST keep being able to declare `retries` in its reactor step
|
|
321
|
+
block, with identical behavior to the class form.
|
|
322
|
+
- **FR-003**: A class step that declares no retries MUST keep accepting a `retries`
|
|
323
|
+
declaration in its reactor step block, with today's behavior.
|
|
324
|
+
- **FR-004**: Invalid retry values MUST be refused at definition time, in either form, with a
|
|
325
|
+
message naming the step and the offending value.
|
|
326
|
+
|
|
327
|
+
#### Removal of reactor-wide defaults
|
|
328
|
+
|
|
329
|
+
- **FR-005**: The reactor-wide retry defaults declaration MUST be removed. A reactor that uses
|
|
330
|
+
it MUST fail to load with a message naming the reactor, stating that the declaration was
|
|
331
|
+
removed, and telling the author to declare `retries` on each step class or step block that
|
|
332
|
+
needs it. This follows how other removed reactor declarations are handled.
|
|
333
|
+
- **FR-006**: No step, nested reactor, or dispatched unit MAY take its retry policy from its
|
|
334
|
+
reactor. The only sources are the step class and the step block.
|
|
335
|
+
- **FR-007**: A step with no retry declaration in either place MUST be attempted exactly once.
|
|
336
|
+
|
|
337
|
+
#### Precedence and conflicts
|
|
338
|
+
|
|
339
|
+
- **FR-008**: A step's effective policy MUST come from its step block if it declares one,
|
|
340
|
+
otherwise from its step class, otherwise no retries.
|
|
341
|
+
- **FR-009**: Declaring `retries` both on a step class and in a reactor's step block for that
|
|
342
|
+
class MUST be refused at reactor definition time, with a message naming the reactor, the
|
|
343
|
+
step, and the class, and telling the author to keep one declaration. This matches how
|
|
344
|
+
conflicting lock declarations are refused.
|
|
345
|
+
|
|
346
|
+
#### Inheritance
|
|
347
|
+
|
|
348
|
+
- **FR-010**: A step subclass MUST inherit its parent's retry policy unless it declares its
|
|
349
|
+
own. Redeclaring MUST NOT affect the parent or its siblings.
|
|
350
|
+
|
|
351
|
+
#### Execution
|
|
352
|
+
|
|
353
|
+
- **FR-011**: A class-declared policy MUST apply on every path a reactor-side policy applies
|
|
354
|
+
to today: the calling process, background hand-off of the whole reactor or part of it,
|
|
355
|
+
independently dispatched steps, and runs resumed after an interrupt or a scheduled retry.
|
|
356
|
+
- **FR-012**: Retry behavior under a class-declared policy MUST match a step-block declaration
|
|
357
|
+
with the same values: which failures are retried, attempt counting across scheduled
|
|
358
|
+
retries, the wait between attempts, in-process waiting versus scheduling for later, the
|
|
359
|
+
final failure report, and compensation after the last attempt.
|
|
360
|
+
- **FR-013**: A direct invocation of a step class (outside any reactor, including from
|
|
361
|
+
another step's body) MUST run exactly once and MUST NOT apply the class's retry policy. Only
|
|
362
|
+
a reactor coordinates retries. Documentation MUST state this, and MUST state that it differs
|
|
363
|
+
from locks, which a direct call does take.
|
|
364
|
+
|
|
365
|
+
#### Visibility and verification
|
|
366
|
+
|
|
367
|
+
- **FR-014**: A step's effective retry policy and its source (step class, step block, or none)
|
|
368
|
+
MUST be available to tooling and tests.
|
|
369
|
+
- **FR-015**: The shipped test surface MUST verify class-declared retries with the existing
|
|
370
|
+
retry assertions, including when the step's body is replaced by a test mock.
|
|
371
|
+
- **FR-016**: Retry attempts under a class-declared policy MUST produce the same
|
|
372
|
+
instrumentation events and failure details as step-block retries.
|
|
373
|
+
|
|
374
|
+
#### Delivery order
|
|
375
|
+
|
|
376
|
+
- **FR-017**: The removal of reactor-wide defaults (US1, FR-005 to FR-007) MUST be delivered
|
|
377
|
+
first as a standalone change: removal, removal error, rewritten tests, documentation and
|
|
378
|
+
migration note, with the full suite passing. Step class declarations (US2 onward) MUST start
|
|
379
|
+
only after that change is complete, and MUST NOT include any handling of reactor-wide
|
|
380
|
+
defaults.
|
|
381
|
+
|
|
382
|
+
#### Delivery
|
|
383
|
+
|
|
384
|
+
- **FR-018**: The feature MUST ship a runnable demo reactor, a demo task, and a spec that uses
|
|
385
|
+
only the shipped test surface. Together they MUST show a class step that succeeds after
|
|
386
|
+
retries, a class step that exhausts its retries and triggers compensation, and a step with
|
|
387
|
+
no declaration that is attempted once.
|
|
388
|
+
- **FR-019**: Documentation MUST present the step class form as the preferred way to declare
|
|
389
|
+
retries, and MUST state the precedence order, the conflict rule, and how to vary a policy per
|
|
390
|
+
workflow by subclassing. Every example that uses reactor-wide defaults MUST be rewritten to
|
|
391
|
+
step-level declarations.
|
|
392
|
+
- **FR-020**: The release MUST carry a migration note explaining the removal of reactor-wide
|
|
393
|
+
defaults and showing how to move each default onto the steps that need it.
|
|
394
|
+
|
|
395
|
+
### Key Entities
|
|
396
|
+
|
|
397
|
+
- **Retry Policy**: how a step is re-attempted after a retryable failure. Attributes: maximum
|
|
398
|
+
attempts, backoff strategy (exponential, linear, fixed), base delay.
|
|
399
|
+
- **Policy Source**: where a step's effective policy came from. One of: step block (inline or
|
|
400
|
+
reactor-side), step class, or none.
|
|
401
|
+
- **Attempt Record**: the per-execution count of attempts made for each step. It is kept
|
|
402
|
+
across scheduled retries and hand-offs, and checked against the effective policy's maximum.
|
|
403
|
+
|
|
404
|
+
## Success Criteria *(mandatory)*
|
|
405
|
+
|
|
406
|
+
### Measurable Outcomes
|
|
407
|
+
|
|
408
|
+
- **SC-001**: A step class that declares its retry policy can be used in any number of reactors
|
|
409
|
+
with zero retry lines in those reactors, and gets the same policy in each.
|
|
410
|
+
- **SC-002**: Every existing step-level retry test passes unchanged. Tests that exercised
|
|
411
|
+
reactor-wide defaults are replaced by tests of the removal error.
|
|
412
|
+
- **SC-003**: For the same failure sequence, a class-declared policy and a step-block
|
|
413
|
+
declaration with the same values produce the same attempt count, the same waits between
|
|
414
|
+
attempts, and the same final outcome, on both the in-process and the background paths.
|
|
415
|
+
- **SC-004**: A step's retry policy can be determined by reading only that step's class or
|
|
416
|
+
step block, in 100% of cases.
|
|
417
|
+
- **SC-005**: 100% of reactors still using reactor-wide defaults, and 100% of conflicting
|
|
418
|
+
double declarations (class plus step block), are refused when the reactor loads. None reach
|
|
419
|
+
run time.
|
|
420
|
+
- **SC-006**: 100% of invalid retry values are refused when the step is defined. None surface
|
|
421
|
+
first at a retry in production.
|
|
422
|
+
- **SC-007**: A developer can find a step's effective policy and where it was declared without
|
|
423
|
+
reading the step or reactor source.
|
|
424
|
+
- **SC-008**: The demo runs end to end in the project's container setup and shows the
|
|
425
|
+
succeed-after-retry, exhaust-and-compensate, and single-attempt outcomes.
|
|
426
|
+
|
|
427
|
+
## Assumptions
|
|
428
|
+
|
|
429
|
+
- The audience is developers writing reactors and steps with this library.
|
|
430
|
+
- The feature changes where the retry policy is declared, not how retries run. The runtime
|
|
431
|
+
behavior of retries (retryable failures, backoff formulas, attempt counting, scheduling in
|
|
432
|
+
background runs) is reused unchanged.
|
|
433
|
+
- Delivering the removal first is deliberate: it deletes the reactor-default fallback before
|
|
434
|
+
a second source of policy (the step class) is added, so the new work only ever deals with
|
|
435
|
+
two sources and needs no ordering or precedence rules involving the reactor.
|
|
436
|
+
- Removing reactor-wide defaults is a breaking change to the public API. It is released under
|
|
437
|
+
the project's versioning policy for breaking changes, with a migration note. It follows the
|
|
438
|
+
existing pattern for removed declarations: the old call raises a clear error at load time
|
|
439
|
+
instead of being silently ignored.
|
|
440
|
+
- A reactor that never declared reactor-wide defaults already runs undeclared steps once, so
|
|
441
|
+
it sees no behavior change.
|
|
442
|
+
- The conflict rule for class-plus-step-block declarations follows the existing rule for
|
|
443
|
+
conflicting lock declarations: refuse, don't silently choose one. To use a different policy
|
|
444
|
+
in one workflow, the author subclasses the step (US6 scenario 3). There is no per-reactor
|
|
445
|
+
override of a class policy.
|
|
446
|
+
- The step-block `retries` form stays fully supported for inline steps, for class steps that
|
|
447
|
+
declare no policy, and for `compose`, `async_reactor`, and `map`. It is not deprecated in
|
|
448
|
+
this feature.
|
|
449
|
+
- Refusing invalid values at definition time tightens the inline form slightly. A value that
|
|
450
|
+
fails at the first retry today will fail when the reactor loads instead. Values that work
|
|
451
|
+
today keep working, with one exception: an attempt count of zero, which today silently means
|
|
452
|
+
"do not retry", must now be written as one.
|
|
453
|
+
- Step inheritance works for retries the same way it already works for inputs and locks.
|