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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +12 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +93 -91
  7. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
  8. data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
  9. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  10. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  11. data/lib/ruby_reactor/dsl/step_builder.rb +40 -14
  12. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  13. data/lib/ruby_reactor/executor/compensation_manager.rb +2 -2
  14. data/lib/ruby_reactor/executor/result_handler.rb +3 -2
  15. data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
  16. data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
  17. data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
  18. data/lib/ruby_reactor/step/compose_step.rb +8 -4
  19. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  20. data/lib/ruby_reactor/step/inputs.rb +57 -0
  21. data/lib/ruby_reactor/step/map_step.rb +32 -22
  22. data/lib/ruby_reactor/step.rb +13 -7
  23. data/lib/ruby_reactor/version.rb +1 -1
  24. data/lib/ruby_reactor.rb +3 -1
  25. data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
  26. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  27. data/specs/006-step-retry-declarations/data-model.md +58 -0
  28. data/specs/006-step-retry-declarations/plan.md +187 -0
  29. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  30. data/specs/006-step-retry-declarations/research.md +194 -0
  31. data/specs/006-step-retry-declarations/spec.md +453 -0
  32. data/specs/006-step-retry-declarations/tasks.md +382 -0
  33. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  34. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac772fc307621dcf2fba69d726aeb670a34201a0c06e17e1e9213b4779f4cd03
4
- data.tar.gz: 5ed6d5e4251643fc16fafaf48ecde75aa31f95cc09f9b1fe9d0b879b190fa61b
3
+ metadata.gz: 325464f0f2b9ee374b1267a4c7243121c9b564814d425bfa5ecce59891979468
4
+ data.tar.gz: 996363b210d54be36ac7065532aac6c0349334b18d8d14816896debd4e8993c4
5
5
  SHA512:
6
- metadata.gz: c61a1ec89ce310154e5bd9d2b09c3dc997e682a83d1bb5b18a87d47128f792133e27e7c8648db699cbfa5de8830522dad9bdfbb7b953addf89273fa3eb3042e0
7
- data.tar.gz: b78511087cfccec3eb6912000c3d744fa0f1cabca1f87eed9a500d223eb2aa4e6d1e4219a85eb2c6774f54e14493179d3c86ba836e4012cf9a86ec3a553955c1
6
+ metadata.gz: 3fcbdbe30cce51beec8dd715c543c8db15140de630d94ec3ae01a7ba803d28cb88f44e0ce1ee97c232cbed34863d81b8c4d0ed979f44acf52a27cfbc07f31b04
7
+ data.tar.gz: 946f4082ec39691d47e35983b9466a128246159f0b191aca061d6c984e2340cabd09da8e111e92b4799ec97a8f4e9753826163e97df48424b3a1583abe40afbc
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.8.3"
2
+ ".": "0.8.4"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "feature_directory": "specs/005-step-coordination-remediation"
2
+ "feature_directory": "specs/006-step-retry-declarations"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -204,6 +204,18 @@
204
204
  * Docs: a park keeps each level's lock without a second `:lock_acquired` only while the gap stays
205
205
  within the lock's `ttl`; a lapsed lock is acquired again.
206
206
 
207
+ ## [0.8.4](https://github.com/arturictus/ruby_reactor/compare/v0.8.3...v0.8.4) (2026-09-26)
208
+
209
+
210
+ ### ⚠ BREAKING CHANGES
211
+
212
+ * `retry_defaults` on a reactor raises RubyReactor::Error::DeprecatedDslError. Migration: move the values onto each step that should retry (`retries max_attempts: 3, backoff: :exponential, base_delay: 2` inside the step block, or on the step class once supported). A step without `retries` runs once. `max_attempts: 0` is not valid: use `max_attempts: 1` (or omit `retries`) for a step that must never retry.
213
+
214
+ ### Features
215
+
216
+ * implement step-scoped retry declarations ([#61](https://github.com/arturictus/ruby_reactor/issues/61)) ([75d9c4e](https://github.com/arturictus/ruby_reactor/commit/75d9c4edca1a6ec53183d56c9c6261e792c98cc2))
217
+ * Inputs protection ([#63](https://github.com/arturictus/ruby_reactor/issues/63)) ([faf90e8](https://github.com/arturictus/ruby_reactor/commit/faf90e8dbfff87f6492af863b90e8247c84bd673))
218
+
207
219
  ## [0.8.3](https://github.com/arturictus/ruby_reactor/compare/v0.8.2...v0.8.3) (2026-09-25)
208
220
 
209
221
 
data/CLAUDE.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <!-- SPECKIT START -->
2
2
  For additional context about technologies to be used, project structure,
3
3
  shell commands, and other important information, read the current plan
4
- at specs/005-step-coordination-remediation/plan.md
4
+ at specs/006-step-retry-declarations/plan.md
5
5
  <!-- SPECKIT END -->
data/README.md CHANGED
@@ -21,7 +21,7 @@ The key value is **Reliability**: if any part of your workflow fails, Ruby React
21
21
  - **Background Execution**: Hand a whole reactor, or everything after a declared step, to a background job via Sidekiq or ActiveJob (so any ActiveJob-compatible queue — Resque, Solid Queue, GoodJob, etc. — works too).
22
22
  - **Async Steps & Reactors**: `async_step` and `async_reactor` dispatch independent units of work while the reactor keeps running; steps that read their result wait for it.
23
23
  - **Map & Parallel Execution**: Iterate over collections in parallel with the `map` step, distributing work across multiple workers.
24
- - **Retries**: Configurable retry logic for failed steps, with exponential backoff.
24
+ - **Retries**: per-step retry policies (declared on the step class or step block) with exponential, linear, or fixed backoff.
25
25
  - **Compensation**: Automatic rollback of completed steps when a failure occurs.
26
26
  - **Interrupts**: Pause and resume workflows to wait for external events (webhooks, user approvals).
27
27
  - **Input Validation**: Integrated with `dry-validation` for robust input checking.
@@ -247,14 +247,16 @@ One-line helpers end a step immediately from any call depth: `success!(value)`,
247
247
  class ReserveInventoryStep < RubyReactor::Step
248
248
  # The step's input contract: enforced before `run` on every execution path.
249
249
  input :order, :hash
250
+ # The step's retry policy: applies in every reactor that uses this step.
251
+ retries max_attempts: 3
250
252
 
251
253
  def run
252
- reservation_id = InventoryService.reserve(inputs[:order][:items])
254
+ reservation_id = InventoryService.reserve(inputs.order[:items])
253
255
  Success(reservation_id: reservation_id)
254
256
  end
255
257
 
256
258
  def compensate
257
- InventoryService.release_partial(inputs[:order][:items])
259
+ InventoryService.release_partial(inputs.order[:items])
258
260
  Success()
259
261
  end
260
262
 
@@ -327,7 +329,7 @@ require 'ruby_reactor'
327
329
 
328
330
  class ValidateEmailStep < RubyReactor::Step
329
331
  def run
330
- email = inputs[:email]
332
+ email = inputs.email
331
333
  email&.include?('@') ? Success(email.strip) : Failure("Email must contain @")
332
334
  end
333
335
  end
@@ -336,14 +338,14 @@ class CreateUserStep < RubyReactor::Step
336
338
  def run
337
339
  Success(
338
340
  id: rand(10000),
339
- email: inputs[:email],
340
- password_hash: inputs[:password_hash],
341
+ email: inputs.email,
342
+ password_hash: inputs.password_hash,
341
343
  created_at: Time.now
342
344
  )
343
345
  end
344
346
 
345
347
  def compensate
346
- Notify.to(inputs[:email])
348
+ Notify.to(inputs.email)
347
349
  Success()
348
350
  end
349
351
  end
@@ -358,9 +360,9 @@ class UserRegistrationReactor < RubyReactor::Reactor
358
360
 
359
361
  step :hash_password do
360
362
  argument :password, input(:password)
361
- run do |args, _context|
363
+ run do |inputs, _context|
362
364
  require 'digest'
363
- Success(Digest::SHA256.hexdigest(args[:password]))
365
+ Success(Digest::SHA256.hexdigest(inputs.password))
364
366
  end
365
367
  end
366
368
 
@@ -373,13 +375,13 @@ class UserRegistrationReactor < RubyReactor::Reactor
373
375
  argument :email, result(:validate_email)
374
376
  wait_for :create_user
375
377
 
376
- run do |args, _context|
377
- Email.send!(args[:email], "verify your email")
378
+ run do |inputs, _context|
379
+ Email.send!(inputs.email, "verify your email")
378
380
  Success()
379
381
  end
380
382
 
381
- compensate do |_error, args, _context|
382
- Email.send("support@acme.com", "Email verification for #{args[:email]} couldn't be sent")
383
+ compensate do |_error, inputs, _context|
384
+ Email.send("support@acme.com", "Email verification for #{inputs.email} couldn't be sent")
383
385
  Success()
384
386
  end
385
387
  end
@@ -443,12 +445,12 @@ class CreateUserReactor < RubyReactor::Reactor
443
445
  input :params
444
446
 
445
447
  step :validate_inputs do
446
- run { |args| validate(args[:params]) }
448
+ run { |inputs| validate(inputs.params) }
447
449
  end
448
450
 
449
451
  step :create_user do
450
452
  argument :params, result(:validate_inputs)
451
- run { |args| User.create(args[:params]) }
453
+ run { |inputs| User.create(inputs.params) }
452
454
  end
453
455
 
454
456
  # :create_user is the LAST step to run in the calling process.
@@ -457,13 +459,13 @@ class CreateUserReactor < RubyReactor::Reactor
457
459
 
458
460
  step :open_account do
459
461
  argument :user, result(:create_user)
460
- run { |args| Bank.open_account(args[:user]) }
462
+ run { |inputs| Bank.open_account(inputs.user) }
461
463
  end
462
464
 
463
465
  step :report_new_user do
464
466
  argument :user, result(:create_user)
465
467
  wait_for :open_account
466
- run { |args| Analytics.track(args[:user]) }
468
+ run { |inputs| Analytics.track(inputs.user) }
467
469
  end
468
470
  end
469
471
 
@@ -514,19 +516,19 @@ class SignupReactor < RubyReactor::Reactor
514
516
 
515
517
  async_step :send_email do
516
518
  argument :to, input(:email)
517
- run { |args| Mailer.welcome(args[:to]).deliver_now; Success(:sent) }
519
+ run { |inputs| Mailer.welcome(inputs.to).deliver_now; Success(:sent) }
518
520
  end
519
521
 
520
522
  # Does NOT wait — it has no dependency on :send_email.
521
523
  step :record_signup do
522
524
  argument :email, input(:email)
523
- run { |args| Success(Signup.create!(email: args[:email])) }
525
+ run { |inputs| Success(Signup.create!(email: inputs.email)) }
524
526
  end
525
527
 
526
528
  # DOES wait, because it reads the result.
527
529
  step :confirm_delivery do
528
530
  argument :delivery, result(:send_email)
529
- run { |args| Success("confirmed") }
531
+ run { |inputs| Success("confirmed") }
530
532
  end
531
533
  end
532
534
  ```
@@ -564,8 +566,8 @@ class SignupReactor < RubyReactor::Reactor
564
566
 
565
567
  step :verify do
566
568
  argument :account, result(:provision_account) # waits for the child
567
- run do |args|
568
- args[:account].success? ? Success(args[:account].value) : Failure(args[:account].error)
569
+ run do |inputs|
570
+ inputs.account.success? ? Success(inputs.account.value) : Failure(inputs.account.error)
569
571
  end
570
572
  end
571
573
  end
@@ -667,7 +669,7 @@ Pause execution to wait for external events like webhooks or user approvals.
667
669
  ```ruby
668
670
  class ApprovalReactor < RubyReactor::Reactor
669
671
  step :submit_request do
670
- run { |args| RequestService.submit(args) }
672
+ run { |inputs| RequestService.submit(inputs.to_h) }
671
673
  end
672
674
 
673
675
  interrupt :wait_for_manager do
@@ -678,8 +680,8 @@ class ApprovalReactor < RubyReactor::Reactor
678
680
 
679
681
  step :process_decision do
680
682
  argument :decision, result(:wait_for_manager)
681
- run do |args|
682
- args[:decision] == 'approved' ? Success() : Failure("Rejected")
683
+ run do |inputs|
684
+ inputs.decision == 'approved' ? Success() : Failure("Rejected")
683
685
  end
684
686
  end
685
687
  end
@@ -716,7 +718,7 @@ class RefundOrderReactor < RubyReactor::Reactor
716
718
 
717
719
  step :refund do
718
720
  argument :order_id, input(:order_id)
719
- run { |args| PaymentGateway.refund(args[:order_id]) }
721
+ run { |inputs| PaymentGateway.refund(inputs.order_id) }
720
722
  end
721
723
  end
722
724
 
@@ -745,7 +747,7 @@ class GeocodeReactor < RubyReactor::Reactor
745
747
 
746
748
  step :geocode do
747
749
  argument :address, input(:address)
748
- run { |args| Geocoder.lookup(args[:address]) }
750
+ run { |inputs| Geocoder.lookup(inputs.address) }
749
751
  end
750
752
  end
751
753
 
@@ -759,7 +761,7 @@ class MonthlyBillingReactor < RubyReactor::Reactor
759
761
 
760
762
  step :build do
761
763
  argument :org_id, input(:org_id)
762
- run { |args| Billing.generate(args[:org_id]) }
764
+ run { |inputs| Billing.generate(inputs.org_id) }
763
765
  end
764
766
  end
765
767
 
@@ -775,7 +777,7 @@ class ChargeReactor < RubyReactor::Reactor
775
777
 
776
778
  step :charge do
777
779
  argument :account_id, input(:account_id)
778
- run { |args| Stripe.charge(args[:account_id]) }
780
+ run { |inputs| Stripe.charge(inputs.account_id) }
779
781
  end
780
782
  end
781
783
 
@@ -792,7 +794,7 @@ class OrderedTransactionReactor < RubyReactor::Reactor
792
794
 
793
795
  step :apply do
794
796
  argument :transaction, input(:transaction)
795
- run { |args| Ledger.apply(args[:transaction]) }
797
+ run { |inputs| Ledger.apply(inputs.transaction) }
796
798
  end
797
799
  end
798
800
 
@@ -816,7 +818,7 @@ class ChargeReactor < RubyReactor::Reactor
816
818
 
817
819
  step :charge do
818
820
  argument :account_id, input(:account_id)
819
- run { |args| Stripe.charge(args[:account_id]) }
821
+ run { |inputs| Stripe.charge(inputs.account_id) }
820
822
  end
821
823
  end
822
824
  ```
@@ -841,9 +843,9 @@ A step's `run` block can also return `Halt(reason: "...")` to stop the reactor c
841
843
  ```ruby
842
844
  step :ensure_active do
843
845
  argument :user, result(:fetch_user)
844
- run do |args, _ctx|
845
- next Halt(reason: "user_opted_out") if args[:user].opted_out?
846
- Success(args[:user])
846
+ run do |inputs, _ctx|
847
+ next Halt(reason: "user_opted_out") if inputs.user.opted_out?
848
+ Success(inputs.user)
847
849
  end
848
850
  end
849
851
  ```
@@ -853,9 +855,9 @@ To skip a *single* step while the reactor continues — the step did nothing, bu
853
855
  ```ruby
854
856
  step :maybe_sync do
855
857
  argument :user, result(:fetch_user)
856
- run do |args, _ctx|
857
- next Skipped(args[:user]) if args[:user].already_synced?
858
- Success(sync!(args[:user]))
858
+ run do |inputs, _ctx|
859
+ next Skipped(inputs.user) if inputs.user.already_synced?
860
+ Success(sync!(inputs.user))
859
861
  end
860
862
  end
861
863
  ```
@@ -879,7 +881,7 @@ class DataProcessingReactor < RubyReactor::Reactor
879
881
 
880
882
  step :transform do
881
883
  argument :item, input(:item)
882
- run { |args| transform_item(args[:item]) }
884
+ run { |inputs| transform_item(inputs.item) }
883
885
  end
884
886
 
885
887
  returns :transform
@@ -913,7 +915,7 @@ map :archive_old_users do
913
915
 
914
916
  step :archive do
915
917
  argument :user, input(:user)
916
- run { |args| args[:user].archive! }
918
+ run { |inputs| inputs.user.archive! }
917
919
  end
918
920
 
919
921
  returns :archive
@@ -921,9 +923,9 @@ end
921
923
 
922
924
  step :summary do
923
925
  argument :results, result(:archive_old_users)
924
- run do |args|
925
- puts "Archived: #{args[:results].successes.count}"
926
- puts "Failed: #{args[:results].failures.count}"
926
+ run do |inputs|
927
+ puts "Archived: #{inputs.results.successes.count}"
928
+ puts "Failed: #{inputs.results.failures.count}"
927
929
  Success()
928
930
  end
929
931
  end
@@ -1005,12 +1007,12 @@ class ValidatedUserReactor < RubyReactor::Reactor
1005
1007
  argument :age, input(:age)
1006
1008
  argument :bio, input(:bio)
1007
1009
 
1008
- run do |args, context|
1010
+ run do |inputs, context|
1009
1011
  profile = {
1010
- name: args[:name],
1011
- email: args[:email],
1012
- age: args[:age],
1013
- bio: args[:bio] || "No bio provided",
1012
+ name: inputs.name,
1013
+ email: inputs.email,
1014
+ age: inputs.age,
1015
+ bio: inputs.bio || "No bio provided",
1014
1016
  created_at: Time.now
1015
1017
  }
1016
1018
  Success(profile)
@@ -1124,7 +1126,7 @@ step :charge do
1124
1126
  argument :amount, input(:amount)
1125
1127
  argument :currency, input(:currency)
1126
1128
 
1127
- run { |args, _| charge!(args) }
1129
+ run { |inputs, _| charge!(inputs) }
1128
1130
  end
1129
1131
  ```
1130
1132
 
@@ -1201,9 +1203,9 @@ class OrderProcessingReactor < RubyReactor::Reactor
1201
1203
  step :validate_user do
1202
1204
  argument :user_id, input(:user_id)
1203
1205
 
1204
- run do |args, context|
1206
+ run do |inputs, context|
1205
1207
  # Check if user exists and has permission to purchase
1206
- user = find_user(args[:user_id])
1208
+ user = find_user(inputs.user_id)
1207
1209
  user ? Success(user) : Failure("User not found")
1208
1210
  end
1209
1211
  end
@@ -1211,8 +1213,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
1211
1213
  step :validate_products do
1212
1214
  argument :product_ids, input(:product_ids)
1213
1215
 
1214
- run do |args, context|
1215
- products = args[:product_ids].map { |id| find_product(id) }
1216
+ run do |inputs, context|
1217
+ products = inputs.product_ids.map { |id| find_product(id) }
1216
1218
  if products.all?
1217
1219
  Success(products)
1218
1220
  else
@@ -1224,8 +1226,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
1224
1226
  step :calculate_total do
1225
1227
  argument :products, result(:validate_products)
1226
1228
 
1227
- run do |args, context|
1228
- total = args[:products].sum { |p| p[:price] }
1229
+ run do |inputs, context|
1230
+ total = inputs.products.sum { |p| p[:price] }
1229
1231
  Success(total)
1230
1232
  end
1231
1233
  end
@@ -1233,8 +1235,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
1233
1235
  step :check_inventory do
1234
1236
  argument :products, result(:validate_products)
1235
1237
 
1236
- run do |args, context|
1237
- available = args[:products].all? { |p| p[:stock] > 0 }
1238
+ run do |inputs, context|
1239
+ available = inputs.products.all? { |p| p[:stock] > 0 }
1238
1240
  available ? Success(true) : Failure("Out of stock")
1239
1241
  end
1240
1242
  end
@@ -1243,15 +1245,15 @@ class OrderProcessingReactor < RubyReactor::Reactor
1243
1245
  argument :user, result(:validate_user)
1244
1246
  argument :total, result(:calculate_total)
1245
1247
 
1246
- run do |args, context|
1248
+ run do |inputs, context|
1247
1249
  # Process payment logic here
1248
- payment_id = process_payment(args[:user][:id], args[:total])
1250
+ payment_id = process_payment(inputs.user[:id], inputs.total)
1249
1251
  Success(payment_id)
1250
1252
  end
1251
1253
 
1252
- undo do |error, args, context|
1254
+ undo do |error, inputs, context|
1253
1255
  # Refund payment on failure
1254
- refund_payment(args[:payment_id])
1256
+ refund_payment(inputs.payment_id)
1255
1257
  Success()
1256
1258
  end
1257
1259
  end
@@ -1261,14 +1263,14 @@ class OrderProcessingReactor < RubyReactor::Reactor
1261
1263
  argument :products, result(:validate_products)
1262
1264
  argument :payment_id, result(:process_payment)
1263
1265
 
1264
- run do |args, context|
1265
- order = create_order_record(args[:user], args[:products], args[:payment_id])
1266
+ run do |inputs, context|
1267
+ order = create_order_record(inputs.user, inputs.products, inputs.payment_id)
1266
1268
  Success(order)
1267
1269
  end
1268
1270
 
1269
- undo do |error, args, context|
1271
+ undo do |error, inputs, context|
1270
1272
  # Cancel order and update inventory
1271
- cancel_order(args[:order][:id])
1273
+ cancel_order(inputs.order[:id])
1272
1274
  Success()
1273
1275
  end
1274
1276
  end
@@ -1276,14 +1278,14 @@ class OrderProcessingReactor < RubyReactor::Reactor
1276
1278
  step :update_inventory do
1277
1279
  argument :products, result(:validate_products)
1278
1280
 
1279
- run do |args, context|
1280
- args[:products].each { |p| decrement_stock(p[:id]) }
1281
+ run do |inputs, context|
1282
+ inputs.products.each { |p| decrement_stock(p[:id]) }
1281
1283
  Success(true)
1282
1284
  end
1283
1285
 
1284
- undo do |error, args, context|
1286
+ undo do |error, inputs, context|
1285
1287
  # Restock products
1286
- args[:products].each { |p| increment_stock(p[:id]) }
1288
+ inputs.products.each { |p| increment_stock(p[:id]) }
1287
1289
  Success()
1288
1290
  end
1289
1291
  end
@@ -1292,8 +1294,8 @@ class OrderProcessingReactor < RubyReactor::Reactor
1292
1294
  argument :user, result(:validate_user)
1293
1295
  argument :order, result(:create_order)
1294
1296
 
1295
- run do |args, context|
1296
- send_email(args[:user][:email], "Order confirmed", order_details(args[:order]))
1297
+ run do |inputs, context|
1298
+ send_email(inputs.user[:email], "Order confirmed", order_details(inputs.order))
1297
1299
  Success(true)
1298
1300
  end
1299
1301
  end
@@ -1316,9 +1318,9 @@ class TransactionReactor < RubyReactor::Reactor
1316
1318
  argument :from_account, input(:from_account)
1317
1319
  argument :to_account, input(:to_account)
1318
1320
 
1319
- run do |args, context|
1320
- from = find_account(args[:from_account])
1321
- to = find_account(args[:to_account])
1321
+ run do |inputs, context|
1322
+ from = find_account(inputs.from_account)
1323
+ to = find_account(inputs.to_account)
1322
1324
 
1323
1325
  if from && to && from != to
1324
1326
  Success({from: from, to: to})
@@ -1332,9 +1334,9 @@ class TransactionReactor < RubyReactor::Reactor
1332
1334
  argument :accounts, result(:validate_accounts)
1333
1335
  argument :amount, input(:amount)
1334
1336
 
1335
- run do |args, context|
1336
- if args[:accounts][:from][:balance] >= args[:amount]
1337
- Success(args[:accounts])
1337
+ run do |inputs, context|
1338
+ if inputs.accounts[:from][:balance] >= inputs.amount
1339
+ Success(inputs.accounts)
1338
1340
  else
1339
1341
  Failure("Insufficient funds")
1340
1342
  end
@@ -1345,14 +1347,14 @@ class TransactionReactor < RubyReactor::Reactor
1345
1347
  argument :accounts, result(:check_balance)
1346
1348
  argument :amount, input(:amount)
1347
1349
 
1348
- run do |args, context|
1349
- debit(args[:accounts][:from][:id], args[:amount])
1350
- Success(args[:accounts])
1350
+ run do |inputs, context|
1351
+ debit(inputs.accounts[:from][:id], inputs.amount)
1352
+ Success(inputs.accounts)
1351
1353
  end
1352
1354
 
1353
- undo do |error, args, context|
1355
+ undo do |error, inputs, context|
1354
1356
  # Credit the amount back
1355
- credit(args[:accounts][:from][:id], args[:amount])
1357
+ credit(inputs.accounts[:from][:id], inputs.amount)
1356
1358
  Success()
1357
1359
  end
1358
1360
  end
@@ -1361,14 +1363,14 @@ class TransactionReactor < RubyReactor::Reactor
1361
1363
  argument :accounts, result(:debit_account)
1362
1364
  argument :amount, input(:amount)
1363
1365
 
1364
- run do |args, context|
1365
- credit(args[:accounts][:to][:id], args[:amount])
1366
+ run do |inputs, context|
1367
+ credit(inputs.accounts[:to][:id], inputs.amount)
1366
1368
  Success({transaction_id: generate_transaction_id()})
1367
1369
  end
1368
1370
 
1369
- undo do |error, args, context|
1371
+ undo do |error, inputs, context|
1370
1372
  # Debit the amount back from recipient
1371
- debit(args[:accounts][:to][:id], args[:amount])
1373
+ debit(inputs.accounts[:to][:id], inputs.amount)
1372
1374
  Success()
1373
1375
  end
1374
1376
  end
@@ -1377,9 +1379,9 @@ class TransactionReactor < RubyReactor::Reactor
1377
1379
  argument :accounts, result(:validate_accounts)
1378
1380
  wait_for :credit_account, :debit_account
1379
1381
 
1380
- run do |args, context|
1381
- Notify.to(args[:accounts][:from])
1382
- Notify.to(args[:accounts][:to])
1382
+ run do |inputs, context|
1383
+ Notify.to(inputs.accounts[:from])
1384
+ Notify.to(inputs.accounts[:to])
1383
1385
  end
1384
1386
 
1385
1387
  end
@@ -1430,8 +1432,8 @@ class SchemaValidatedReactor < RubyReactor::Reactor
1430
1432
  step :process_user do
1431
1433
  argument :user, input(:user)
1432
1434
 
1433
- run do |args, context|
1434
- Success(args[:user])
1435
+ run do |inputs, context|
1436
+ Success(inputs.user)
1435
1437
  end
1436
1438
  end
1437
1439
 
@@ -1483,7 +1485,7 @@ Discover how to build complex, modular workflows by composing reactors within ot
1483
1485
  Master the `map` feature for processing collections. Learn about parallel execution, batch processing for large datasets, and error handling strategies like fail-fast vs. partial result collection.
1484
1486
 
1485
1487
  ### [Retry Configuration](documentation/retry_configuration.md)
1486
- Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to configure retries at the reactor or step level, and how background retries work without blocking workers.
1488
+ Configure robust retry policies for your steps. This guide details the available backoff strategies (exponential, linear, fixed), how to declare retries on a step class or inline step, and how background retries work without blocking workers.
1487
1489
 
1488
1490
  ### [Interrupts](documentation/interrupts.md)
1489
1491
  Learn how to pause and resume reactors to handle long-running processes, manual approvals, and asynchronous callbacks. Patterns for correlation IDs, timeouts, and payload validation.
@@ -10,6 +10,7 @@ module RubyReactor
10
10
  # ordering nonce.
11
11
  class AsyncReactorBuilder
12
12
  include RubyReactor::Dsl::TemplateHelpers
13
+ include RubyReactor::Dsl::Retryable
13
14
 
14
15
  attr_accessor :name, :child_reactor_class, :argument_mappings
15
16
 
@@ -18,17 +19,13 @@ module RubyReactor
18
19
  @child_reactor_class = child_reactor_class
19
20
  @reactor = reactor
20
21
  @argument_mappings = {}
21
- @retry_config = {}
22
+ @retry_config = nil
22
23
  end
23
24
 
24
25
  def argument(child_input_name, source)
25
26
  @argument_mappings[child_input_name] = source
26
27
  end
27
28
 
28
- def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
29
- @retry_config = { max_attempts: max_attempts, backoff: backoff, base_delay: base_delay }
30
- end
31
-
32
29
  def build
33
30
  RubyReactor::Dsl::StepConfig.new(
34
31
  async_dispatch: :reactor,
@@ -49,7 +46,7 @@ module RubyReactor
49
46
  dependencies: dependencies_from_mappings,
50
47
  args_validator: nil,
51
48
  output_validator: nil,
52
- retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
49
+ retry_config: @retry_config
53
50
  )
54
51
  end
55
52
 
@@ -4,6 +4,7 @@ module RubyReactor
4
4
  module Dsl
5
5
  class ComposeBuilder
6
6
  include RubyReactor::Dsl::TemplateHelpers
7
+ include RubyReactor::Dsl::Retryable
7
8
 
8
9
  attr_accessor :name, :composed_reactor_class, :argument_mappings
9
10
 
@@ -20,7 +21,7 @@ module RubyReactor
20
21
  end
21
22
  @reactor = reactor
22
23
  @argument_mappings = {}
23
- @retry_config = {}
24
+ @retry_config = nil
24
25
  end
25
26
 
26
27
  def argument(composed_input_name, source)
@@ -44,14 +45,6 @@ module RubyReactor
44
45
  )
45
46
  end
46
47
 
47
- def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
48
- @retry_config = {
49
- max_attempts: max_attempts,
50
- backoff: backoff,
51
- base_delay: base_delay
52
- }
53
- end
54
-
55
48
  def build
56
49
  warn_if_child_has_ordered_lock!
57
50
  dependencies = extract_dependencies_from_mappings
@@ -71,7 +64,7 @@ module RubyReactor
71
64
  dependencies: dependencies,
72
65
  args_validator: nil,
73
66
  output_validator: nil,
74
- retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
67
+ retry_config: @retry_config
75
68
  }
76
69
 
77
70
  RubyReactor::Dsl::StepConfig.new(step_config)
@@ -11,7 +11,6 @@ module RubyReactor
11
11
  base.instance_variable_set(:@middlewares, [])
12
12
  base.instance_variable_set(:@input_validations, {})
13
13
  base.instance_variable_set(:@background_handoff, nil)
14
- base.instance_variable_set(:@retry_defaults, { max_attempts: 3, backoff: :exponential, base_delay: 1 })
15
14
  end
16
15
 
17
16
  module ClassMethods
@@ -55,16 +54,14 @@ module RubyReactor
55
54
  "inputs inside the worker."
56
55
  end
57
56
 
58
- def retry_defaults(**kwargs)
59
- if kwargs.empty?
60
- @retry_defaults ||= { max_attempts: 1, backoff: :exponential, base_delay: 1 }
61
- else
62
- @retry_defaults = {
63
- max_attempts: kwargs[:max_attempts] || 1,
64
- backoff: kwargs[:backoff] || :exponential,
65
- base_delay: kwargs[:base_delay] || 1
66
- }
67
- end
57
+ # Reactor-wide retry defaults are gone: the builders snapshotted them at
58
+ # `step` time, so they silently applied only to steps declared AFTER
59
+ # the `retry_defaults` line. A step's retry policy now lives on the step.
60
+ def retry_defaults(*, **)
61
+ raise RubyReactor::Error::DeprecatedDslError,
62
+ "`retry_defaults` has been removed from #{name || "this reactor"}: reactor-wide defaults " \
63
+ "silently applied only to steps declared after them. Declare `retries` on each step class " \
64
+ "(or step block) that should retry; a step with no `retries` runs once."
68
65
  end
69
66
 
70
67
  # rubocop:disable Metrics/ParameterLists