senro_usecaser 0.3.0 → 0.4.1
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/.rubocop.yml +4 -0
- data/README.md +655 -0
- data/examples/namespace_demo.rb +50 -15
- data/examples/order_system.rb +222 -34
- data/examples/sig/namespace_demo.rbs +35 -10
- data/examples/sig/order_system.rbs +196 -20
- data/lib/senro_usecaser/base.rb +308 -76
- data/lib/senro_usecaser/depends_on.rb +257 -0
- data/lib/senro_usecaser/hook.rb +28 -82
- data/lib/senro_usecaser/retry_configuration.rb +131 -0
- data/lib/senro_usecaser/retry_context.rb +133 -0
- data/lib/senro_usecaser/version.rb +1 -1
- data/lib/senro_usecaser.rb +3 -0
- data/sig/generated/senro_usecaser/base.rbs +143 -30
- data/sig/generated/senro_usecaser/depends_on.rbs +197 -0
- data/sig/generated/senro_usecaser/hook.rbs +23 -35
- data/sig/generated/senro_usecaser/retry_configuration.rbs +90 -0
- data/sig/generated/senro_usecaser/retry_context.rbs +101 -0
- data/sig/overrides.rbs +0 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a40e2440b8f5f624de415bc556c1fb3aed16cf6f5afdef5a6c1b36d8b71242f
|
|
4
|
+
data.tar.gz: ffd39400b4f7ebb5e89cd6e27aff1f41e5cfc4410584170498dd3f0a821b7821
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 26c5ba7d471feb89eb69353f16ae5a86ba6da67bf0fc057982a8ad48f8022f5a88ccc55ee566fa243e74a13957e7a3e824525985acd3975a82ae935e60853ef8
|
|
7
|
+
data.tar.gz: 4e27567ba9ef646b358f781c136e9840fcc57d644ea93d844925cde8d975f62665ec91694cdc6f447d2c020995373b3951e667ced56345ee3c6820cfb08e8f54
|
data/.rubocop.yml
CHANGED
data/README.md
CHANGED
|
@@ -935,6 +935,661 @@ class Admin::CreateUserUseCase < SenroUsecaser::Base
|
|
|
935
935
|
end
|
|
936
936
|
```
|
|
937
937
|
|
|
938
|
+
##### Using DependsOn in Custom Classes
|
|
939
|
+
|
|
940
|
+
The `SenroUsecaser::DependsOn` module can be used in any class to enable the same dependency injection features available in UseCase and Hook classes. This is useful for services, repositories, or other application components that need DI support.
|
|
941
|
+
|
|
942
|
+
**Basic Usage (No initialize needed)**
|
|
943
|
+
|
|
944
|
+
When you extend `DependsOn`, a default `initialize` is provided automatically. If no container is passed, it uses `SenroUsecaser.container`:
|
|
945
|
+
|
|
946
|
+
```ruby
|
|
947
|
+
class OrderService
|
|
948
|
+
extend SenroUsecaser::DependsOn
|
|
949
|
+
|
|
950
|
+
depends_on :order_repository, OrderRepository
|
|
951
|
+
depends_on :payment_gateway, PaymentGateway
|
|
952
|
+
depends_on :logger, Logger
|
|
953
|
+
|
|
954
|
+
# No initialize needed! Default is provided automatically.
|
|
955
|
+
|
|
956
|
+
def process_order(order_id)
|
|
957
|
+
order = order_repository.find(order_id)
|
|
958
|
+
logger.info("Processing order #{order_id}")
|
|
959
|
+
payment_gateway.charge(order.total)
|
|
960
|
+
end
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
# Usage - uses SenroUsecaser.container by default
|
|
964
|
+
service = OrderService.new
|
|
965
|
+
service.process_order(123)
|
|
966
|
+
|
|
967
|
+
# Or with explicit container
|
|
968
|
+
service = OrderService.new(container: custom_container)
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
**Custom initialize with super**
|
|
972
|
+
|
|
973
|
+
If you need additional parameters, define your own `initialize` and call `super`:
|
|
974
|
+
|
|
975
|
+
```ruby
|
|
976
|
+
class OrderService
|
|
977
|
+
extend SenroUsecaser::DependsOn
|
|
978
|
+
|
|
979
|
+
depends_on :order_repository, OrderRepository
|
|
980
|
+
attr_reader :default_currency
|
|
981
|
+
|
|
982
|
+
def initialize(default_currency: "JPY", container: nil)
|
|
983
|
+
super(container: container) # Handles dependency resolution
|
|
984
|
+
@default_currency = default_currency
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def process_order(order_id)
|
|
988
|
+
order = order_repository.find(order_id)
|
|
989
|
+
order.charge(currency: default_currency)
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# Uses SenroUsecaser.container by default
|
|
994
|
+
service = OrderService.new(default_currency: "USD")
|
|
995
|
+
service.default_currency # => "USD"
|
|
996
|
+
service.order_repository # => OrderRepository instance
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
**With Namespace**
|
|
1000
|
+
|
|
1001
|
+
```ruby
|
|
1002
|
+
class Admin::ReportService
|
|
1003
|
+
extend SenroUsecaser::DependsOn
|
|
1004
|
+
|
|
1005
|
+
namespace :admin
|
|
1006
|
+
depends_on :report_generator, ReportGenerator
|
|
1007
|
+
depends_on :logger, Logger # Falls back to root namespace
|
|
1008
|
+
|
|
1009
|
+
# No initialize needed!
|
|
1010
|
+
|
|
1011
|
+
def generate_monthly_report
|
|
1012
|
+
logger.info("Generating monthly report")
|
|
1013
|
+
report_generator.generate(:monthly)
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
**With Automatic Namespace Inference**
|
|
1019
|
+
|
|
1020
|
+
When `infer_namespace_from_module` is enabled, the namespace is automatically derived from the module structure:
|
|
1021
|
+
|
|
1022
|
+
```ruby
|
|
1023
|
+
SenroUsecaser.configure do |config|
|
|
1024
|
+
config.infer_namespace_from_module = true
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
module Admin
|
|
1028
|
+
module Reports
|
|
1029
|
+
class ExportService
|
|
1030
|
+
extend SenroUsecaser::DependsOn
|
|
1031
|
+
|
|
1032
|
+
# No explicit namespace needed!
|
|
1033
|
+
# Automatically uses "admin::reports" namespace
|
|
1034
|
+
depends_on :exporter, Exporter # from admin::reports
|
|
1035
|
+
depends_on :storage, Storage # from admin (fallback)
|
|
1036
|
+
depends_on :logger, Logger # from root (fallback)
|
|
1037
|
+
|
|
1038
|
+
# No initialize needed!
|
|
1039
|
+
|
|
1040
|
+
def export(data)
|
|
1041
|
+
result = exporter.export(data)
|
|
1042
|
+
storage.save(result)
|
|
1043
|
+
logger.info("Export completed")
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
**Features provided by DependsOn:**
|
|
1051
|
+
|
|
1052
|
+
- `depends_on :name, Type` - Declare dependencies with optional type hints
|
|
1053
|
+
- `namespace :name` - Set explicit namespace for dependency resolution
|
|
1054
|
+
- `declared_namespace` - Get the declared namespace
|
|
1055
|
+
- `dependencies` - List of declared dependency names
|
|
1056
|
+
- `dependency_types` - Hash of dependency name to type
|
|
1057
|
+
- `copy_depends_on_to(subclass)` - Copy configuration to subclasses (for inheritance)
|
|
1058
|
+
|
|
1059
|
+
**Instance methods (via InstanceMethods module):**
|
|
1060
|
+
|
|
1061
|
+
- `initialize(container: nil)` - Default initialize that sets up dependency injection. Uses `SenroUsecaser.container` if no container is provided.
|
|
1062
|
+
- `resolve_dependencies` - Resolve all declared dependencies from the container
|
|
1063
|
+
- `effective_namespace` - Get the namespace used for resolution (declared or inferred)
|
|
1064
|
+
|
|
1065
|
+
**Custom initialize (full override):**
|
|
1066
|
+
|
|
1067
|
+
If you need complete control, you can manually set up the required instance variables:
|
|
1068
|
+
|
|
1069
|
+
```ruby
|
|
1070
|
+
def initialize(extra:, container: nil)
|
|
1071
|
+
@_container = container || SenroUsecaser.container
|
|
1072
|
+
@_dependencies = {}
|
|
1073
|
+
@extra = extra
|
|
1074
|
+
resolve_dependencies
|
|
1075
|
+
end
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
##### on_failure Hook
|
|
1079
|
+
|
|
1080
|
+
The `on_failure` hook is called only when the UseCase execution results in a failure. Unlike `after` which is always called, `on_failure` provides a dedicated hook for error handling, logging, or recovery logic.
|
|
1081
|
+
|
|
1082
|
+
**Block Syntax**
|
|
1083
|
+
|
|
1084
|
+
```ruby
|
|
1085
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1086
|
+
depends_on :logger
|
|
1087
|
+
depends_on :error_notifier
|
|
1088
|
+
input Input
|
|
1089
|
+
|
|
1090
|
+
# on_failure block is executed in UseCase instance context
|
|
1091
|
+
# allowing access to dependencies
|
|
1092
|
+
on_failure do |input, result|
|
|
1093
|
+
logger.error("Failed to create user: #{result.errors.map(&:message).join(', ')}")
|
|
1094
|
+
error_notifier.notify(
|
|
1095
|
+
action: "create_user",
|
|
1096
|
+
input: input,
|
|
1097
|
+
errors: result.errors
|
|
1098
|
+
)
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
def call(input)
|
|
1102
|
+
user = User.create!(name: input.name, email: input.email)
|
|
1103
|
+
success(user)
|
|
1104
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
1105
|
+
failure(Error.new(code: :validation_error, message: e.message))
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
**Module Syntax (with extend_with)**
|
|
1111
|
+
|
|
1112
|
+
```ruby
|
|
1113
|
+
module ErrorLogging
|
|
1114
|
+
def self.on_failure(input, result)
|
|
1115
|
+
Rails.logger.error("UseCase failed: #{result.errors.first&.message}")
|
|
1116
|
+
end
|
|
1117
|
+
end
|
|
1118
|
+
|
|
1119
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1120
|
+
extend_with ErrorLogging
|
|
1121
|
+
|
|
1122
|
+
def call(input)
|
|
1123
|
+
# ...
|
|
1124
|
+
end
|
|
1125
|
+
end
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
**Hook Class Syntax**
|
|
1129
|
+
|
|
1130
|
+
```ruby
|
|
1131
|
+
class ErrorNotificationHook < SenroUsecaser::Hook
|
|
1132
|
+
depends_on :error_notifier
|
|
1133
|
+
depends_on :logger
|
|
1134
|
+
|
|
1135
|
+
def on_failure(input, result)
|
|
1136
|
+
logger.error("UseCase failed with #{result.errors.size} error(s)")
|
|
1137
|
+
error_notifier.notify(
|
|
1138
|
+
errors: result.errors,
|
|
1139
|
+
input_class: input.class.name,
|
|
1140
|
+
timestamp: Time.current
|
|
1141
|
+
)
|
|
1142
|
+
end
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1146
|
+
extend_with ErrorNotificationHook
|
|
1147
|
+
|
|
1148
|
+
def call(input)
|
|
1149
|
+
# ...
|
|
1150
|
+
end
|
|
1151
|
+
end
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
**Execution Order**
|
|
1155
|
+
|
|
1156
|
+
When a UseCase fails, hooks are executed in the following order:
|
|
1157
|
+
|
|
1158
|
+
1. `around` hooks (unwinding)
|
|
1159
|
+
2. `after` hooks (always called, regardless of success/failure)
|
|
1160
|
+
3. `on_failure` hooks (only called when `result.failure?` is true)
|
|
1161
|
+
|
|
1162
|
+
```ruby
|
|
1163
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1164
|
+
after do |input, result|
|
|
1165
|
+
puts "after: #{result.success? ? 'success' : 'failure'}"
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
on_failure do |input, result|
|
|
1169
|
+
puts "on_failure: handling error..."
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
def call(input)
|
|
1173
|
+
failure(Error.new(code: :error, message: "Something went wrong"))
|
|
1174
|
+
end
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
# Output:
|
|
1178
|
+
# after: failure
|
|
1179
|
+
# on_failure: handling error...
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
**Use Cases for on_failure**
|
|
1183
|
+
|
|
1184
|
+
- **Error logging**: Log detailed error information for debugging
|
|
1185
|
+
- **Error notification**: Send alerts to monitoring systems (Sentry, Bugsnag, etc.)
|
|
1186
|
+
- **Cleanup operations**: Rollback partial state changes on failure
|
|
1187
|
+
- **Retry preparation**: Queue failed operations for retry
|
|
1188
|
+
- **Metrics**: Increment failure counters for observability
|
|
1189
|
+
|
|
1190
|
+
##### on_failure in Pipelines (Rollback Behavior)
|
|
1191
|
+
|
|
1192
|
+
When using `organize` pipelines, the `on_failure` hook provides **rollback behavior**. If a step fails, the `on_failure` hooks of all previously successful steps are executed in **reverse order**.
|
|
1193
|
+
|
|
1194
|
+
This enables compensation logic (Saga pattern) where each step can define how to undo its changes when a later step fails.
|
|
1195
|
+
|
|
1196
|
+
```ruby
|
|
1197
|
+
class CreateOrderUseCase < SenroUsecaser::Base
|
|
1198
|
+
input Input
|
|
1199
|
+
|
|
1200
|
+
on_failure do |input, result|
|
|
1201
|
+
# Called if a later step fails
|
|
1202
|
+
Order.find_by(id: input.order_id)&.destroy
|
|
1203
|
+
puts "Rolled back: order creation"
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
def call(input)
|
|
1207
|
+
order = Order.create!(user_id: input.user_id)
|
|
1208
|
+
success(Output.new(order_id: order.id, user_id: input.user_id))
|
|
1209
|
+
end
|
|
1210
|
+
end
|
|
1211
|
+
|
|
1212
|
+
class ReserveInventoryUseCase < SenroUsecaser::Base
|
|
1213
|
+
input Input
|
|
1214
|
+
|
|
1215
|
+
on_failure do |input, result|
|
|
1216
|
+
# Called if a later step fails
|
|
1217
|
+
Inventory.release(order_id: input.order_id)
|
|
1218
|
+
puts "Rolled back: inventory reservation"
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
def call(input)
|
|
1222
|
+
Inventory.reserve(order_id: input.order_id)
|
|
1223
|
+
success(input)
|
|
1224
|
+
end
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
class ChargePaymentUseCase < SenroUsecaser::Base
|
|
1228
|
+
input Input
|
|
1229
|
+
|
|
1230
|
+
on_failure do |input, result|
|
|
1231
|
+
# Called when this step itself fails (no rollback needed for self)
|
|
1232
|
+
puts "Payment failed: #{result.errors.first&.message}"
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
def call(input)
|
|
1236
|
+
# Payment fails
|
|
1237
|
+
failure(Error.new(code: :payment_failed, message: "Insufficient funds"))
|
|
1238
|
+
end
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
class PlaceOrderUseCase < SenroUsecaser::Base
|
|
1242
|
+
input Input
|
|
1243
|
+
|
|
1244
|
+
organize do
|
|
1245
|
+
step CreateOrderUseCase # Step 1: Success
|
|
1246
|
+
step ReserveInventoryUseCase # Step 2: Success
|
|
1247
|
+
step ChargePaymentUseCase # Step 3: Failure!
|
|
1248
|
+
end
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
result = PlaceOrderUseCase.call(input)
|
|
1252
|
+
# Output (in order):
|
|
1253
|
+
# Payment failed: Insufficient funds <- ChargePaymentUseCase.on_failure
|
|
1254
|
+
# Rolled back: inventory reservation <- ReserveInventoryUseCase.on_failure
|
|
1255
|
+
# Rolled back: order creation <- CreateOrderUseCase.on_failure
|
|
1256
|
+
```
|
|
1257
|
+
|
|
1258
|
+
**Execution Flow on Pipeline Failure:**
|
|
1259
|
+
|
|
1260
|
+
```
|
|
1261
|
+
Step A (success) → Step B (success) → Step C (failure)
|
|
1262
|
+
↓
|
|
1263
|
+
C.on_failure (failed step)
|
|
1264
|
+
↓
|
|
1265
|
+
B.on_failure (rollback)
|
|
1266
|
+
↓
|
|
1267
|
+
A.on_failure (rollback)
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
**Important Notes:**
|
|
1271
|
+
|
|
1272
|
+
1. **Reverse order**: `on_failure` hooks are called in reverse order of successful execution, ensuring proper cleanup sequence.
|
|
1273
|
+
|
|
1274
|
+
2. **Input context**: Each step's `on_failure` receives the input that was passed to that specific step (the output of the previous step), not the original pipeline input.
|
|
1275
|
+
|
|
1276
|
+
3. **Failed step included**: The step that failed also has its `on_failure` called (first, before rollback of previous steps).
|
|
1277
|
+
|
|
1278
|
+
4. **on_failure: :continue steps**: Steps marked with `on_failure: :continue` that fail will have their `on_failure` hook called, but won't trigger rollback of previous steps since the pipeline continues.
|
|
1279
|
+
|
|
1280
|
+
5. **Independent of on_failure_strategy**: The rollback behavior works consistently with `:stop`, `:continue`, and `:collect` strategies. For `:collect`, rollback occurs after all steps have been attempted.
|
|
1281
|
+
|
|
1282
|
+
```ruby
|
|
1283
|
+
class PlaceOrderUseCase < SenroUsecaser::Base
|
|
1284
|
+
organize do
|
|
1285
|
+
step CreateOrderUseCase
|
|
1286
|
+
step SendNotificationUseCase, on_failure: :continue # Optional step
|
|
1287
|
+
step ChargePaymentUseCase
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
# If SendNotificationUseCase fails:
|
|
1292
|
+
# - SendNotificationUseCase.on_failure is called
|
|
1293
|
+
# - Pipeline continues (no rollback of CreateOrderUseCase)
|
|
1294
|
+
# - ChargePaymentUseCase executes
|
|
1295
|
+
|
|
1296
|
+
# If ChargePaymentUseCase fails:
|
|
1297
|
+
# - ChargePaymentUseCase.on_failure is called
|
|
1298
|
+
# - CreateOrderUseCase.on_failure is called (rollback)
|
|
1299
|
+
# - SendNotificationUseCase.on_failure is NOT called (it was optional and didn't cause the failure)
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
##### Retry on Failure
|
|
1303
|
+
|
|
1304
|
+
SenroUsecaser provides retry functionality similar to ActiveJob, allowing automatic retry of failed UseCases with configurable strategies.
|
|
1305
|
+
|
|
1306
|
+
###### Basic Retry Configuration
|
|
1307
|
+
|
|
1308
|
+
Use `retry_on` to configure automatic retry for specific error codes or exception types:
|
|
1309
|
+
|
|
1310
|
+
```ruby
|
|
1311
|
+
class FetchExternalDataUseCase < SenroUsecaser::Base
|
|
1312
|
+
input Input
|
|
1313
|
+
|
|
1314
|
+
# Retry up to 3 times for specific error codes
|
|
1315
|
+
retry_on :network_error, :timeout_error, attempts: 3
|
|
1316
|
+
|
|
1317
|
+
# Retry on specific exception types
|
|
1318
|
+
retry_on NetworkError, Timeout::Error, attempts: 5, wait: 1.second
|
|
1319
|
+
|
|
1320
|
+
def call(input)
|
|
1321
|
+
response = ExternalAPI.fetch(input.url)
|
|
1322
|
+
success(response)
|
|
1323
|
+
rescue Timeout::Error => e
|
|
1324
|
+
failure(Error.new(code: :timeout_error, message: e.message, cause: e))
|
|
1325
|
+
end
|
|
1326
|
+
end
|
|
1327
|
+
```
|
|
1328
|
+
|
|
1329
|
+
###### Retry Options
|
|
1330
|
+
|
|
1331
|
+
| Option | Description | Default |
|
|
1332
|
+
|--------|-------------|---------|
|
|
1333
|
+
| `attempts` | Maximum number of retry attempts | 3 |
|
|
1334
|
+
| `wait` | Time to wait between retries (seconds or Duration) | 0 |
|
|
1335
|
+
| `backoff` | Backoff strategy (`:fixed`, `:linear`, `:exponential`) | `:fixed` |
|
|
1336
|
+
| `max_wait` | Maximum wait time when using backoff | 1 hour |
|
|
1337
|
+
| `jitter` | Add randomness to wait time (0.0 to 1.0) | 0 |
|
|
1338
|
+
|
|
1339
|
+
```ruby
|
|
1340
|
+
class ProcessPaymentUseCase < SenroUsecaser::Base
|
|
1341
|
+
input Input
|
|
1342
|
+
|
|
1343
|
+
# Exponential backoff: 1s, 2s, 4s, 8s... (capped at 30s)
|
|
1344
|
+
retry_on :gateway_error,
|
|
1345
|
+
attempts: 5,
|
|
1346
|
+
wait: 1.second,
|
|
1347
|
+
backoff: :exponential,
|
|
1348
|
+
max_wait: 30.seconds
|
|
1349
|
+
|
|
1350
|
+
# Linear backoff with jitter: 2s, 4s, 6s... (±20% randomness)
|
|
1351
|
+
retry_on :rate_limited,
|
|
1352
|
+
attempts: 10,
|
|
1353
|
+
wait: 2.seconds,
|
|
1354
|
+
backoff: :linear,
|
|
1355
|
+
jitter: 0.2
|
|
1356
|
+
|
|
1357
|
+
def call(input)
|
|
1358
|
+
PaymentGateway.charge(input.amount)
|
|
1359
|
+
end
|
|
1360
|
+
end
|
|
1361
|
+
```
|
|
1362
|
+
|
|
1363
|
+
###### Backoff Strategies Explained
|
|
1364
|
+
|
|
1365
|
+
The `backoff` option controls how wait time increases between retry attempts:
|
|
1366
|
+
|
|
1367
|
+
**`:fixed` (default)** - Same wait time for every retry.
|
|
1368
|
+
|
|
1369
|
+
```
|
|
1370
|
+
wait: 2s
|
|
1371
|
+
Attempt 1 fails → wait 2s → Attempt 2
|
|
1372
|
+
Attempt 2 fails → wait 2s → Attempt 3
|
|
1373
|
+
Attempt 3 fails → wait 2s → Attempt 4
|
|
1374
|
+
```
|
|
1375
|
+
|
|
1376
|
+
Best for: Temporary errors expected to recover quickly.
|
|
1377
|
+
|
|
1378
|
+
**`:linear`** - Wait time increases by a fixed amount each retry.
|
|
1379
|
+
|
|
1380
|
+
```
|
|
1381
|
+
wait: 2s (formula: wait × attempt)
|
|
1382
|
+
Attempt 1 fails → wait 2s → Attempt 2
|
|
1383
|
+
Attempt 2 fails → wait 4s → Attempt 3
|
|
1384
|
+
Attempt 3 fails → wait 6s → Attempt 4
|
|
1385
|
+
Attempt 4 fails → wait 8s → Attempt 5
|
|
1386
|
+
```
|
|
1387
|
+
|
|
1388
|
+
Best for: Load-related errors where gradual recovery is expected.
|
|
1389
|
+
|
|
1390
|
+
**`:exponential`** - Wait time doubles each retry (most aggressive backoff).
|
|
1391
|
+
|
|
1392
|
+
```
|
|
1393
|
+
wait: 2s (formula: wait × 2^(attempt-1))
|
|
1394
|
+
Attempt 1 fails → wait 2s → Attempt 2
|
|
1395
|
+
Attempt 2 fails → wait 4s → Attempt 3
|
|
1396
|
+
Attempt 3 fails → wait 8s → Attempt 4
|
|
1397
|
+
Attempt 4 fails → wait 16s → Attempt 5
|
|
1398
|
+
```
|
|
1399
|
+
|
|
1400
|
+
Best for: Rate limiting, server overload, external API throttling.
|
|
1401
|
+
|
|
1402
|
+
**Summary:**
|
|
1403
|
+
|
|
1404
|
+
| Strategy | Formula | Use Case |
|
|
1405
|
+
|----------|---------|----------|
|
|
1406
|
+
| `:fixed` | `wait` | Quick recovery expected |
|
|
1407
|
+
| `:linear` | `wait × attempt` | Gradual recovery expected |
|
|
1408
|
+
| `:exponential` | `wait × 2^(attempt-1)` | Rate limits, heavy load |
|
|
1409
|
+
|
|
1410
|
+
**`max_wait`** caps the wait time to prevent excessive delays with `:exponential`:
|
|
1411
|
+
|
|
1412
|
+
```ruby
|
|
1413
|
+
retry_on :api_error,
|
|
1414
|
+
wait: 1.second,
|
|
1415
|
+
backoff: :exponential,
|
|
1416
|
+
max_wait: 30.seconds # Never wait more than 30s
|
|
1417
|
+
|
|
1418
|
+
# Without max_wait, attempt 10 would wait 512 seconds (8.5 minutes)!
|
|
1419
|
+
# With max_wait: 30s, it caps at 30 seconds
|
|
1420
|
+
```
|
|
1421
|
+
|
|
1422
|
+
**`jitter`** adds randomness to prevent thundering herd problem when many processes retry simultaneously:
|
|
1423
|
+
|
|
1424
|
+
```ruby
|
|
1425
|
+
retry_on :api_error,
|
|
1426
|
+
wait: 2.seconds,
|
|
1427
|
+
backoff: :exponential,
|
|
1428
|
+
jitter: 0.25 # ±25% randomness
|
|
1429
|
+
|
|
1430
|
+
# Attempt 2 wait: 4s ± 1s (3s to 5s)
|
|
1431
|
+
# Attempt 3 wait: 8s ± 2s (6s to 10s)
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
###### Manual Retry in on_failure
|
|
1435
|
+
|
|
1436
|
+
For more control, use `retry!` within an `on_failure` block:
|
|
1437
|
+
|
|
1438
|
+
```ruby
|
|
1439
|
+
class SendEmailUseCase < SenroUsecaser::Base
|
|
1440
|
+
input Input
|
|
1441
|
+
|
|
1442
|
+
on_failure do |input, result, context|
|
|
1443
|
+
if result.errors.any? { |e| e.code == :temporary_failure }
|
|
1444
|
+
# Retry with same input
|
|
1445
|
+
retry! if context.attempt < 3
|
|
1446
|
+
|
|
1447
|
+
# Or retry with modified input
|
|
1448
|
+
retry!(input: ModifiedInput.new(input, fallback: true))
|
|
1449
|
+
|
|
1450
|
+
# Or retry after delay
|
|
1451
|
+
retry!(wait: 5.seconds) if context.attempt < 5
|
|
1452
|
+
end
|
|
1453
|
+
end
|
|
1454
|
+
|
|
1455
|
+
def call(input)
|
|
1456
|
+
Mailer.send(input.to, input.subject, input.body)
|
|
1457
|
+
end
|
|
1458
|
+
end
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
###### Retry Context
|
|
1462
|
+
|
|
1463
|
+
The `on_failure` block receives a `context` object with retry information:
|
|
1464
|
+
|
|
1465
|
+
```ruby
|
|
1466
|
+
on_failure do |input, result, context|
|
|
1467
|
+
context.attempt # Current attempt number (1, 2, 3...)
|
|
1468
|
+
context.max_attempts # Maximum attempts configured (nil if unlimited)
|
|
1469
|
+
context.retried? # Whether this is a retry (attempt > 1)
|
|
1470
|
+
context.elapsed_time # Total time elapsed since first attempt
|
|
1471
|
+
context.last_error # The error from the previous attempt (if retried)
|
|
1472
|
+
end
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
###### Discard (Don't Retry)
|
|
1476
|
+
|
|
1477
|
+
Use `discard_on` to skip retry for specific errors:
|
|
1478
|
+
|
|
1479
|
+
```ruby
|
|
1480
|
+
class CreateUserUseCase < SenroUsecaser::Base
|
|
1481
|
+
input Input
|
|
1482
|
+
|
|
1483
|
+
# Always retry on these
|
|
1484
|
+
retry_on :database_error, attempts: 3
|
|
1485
|
+
|
|
1486
|
+
# Never retry on these (fail immediately)
|
|
1487
|
+
discard_on :validation_error, :duplicate_record
|
|
1488
|
+
|
|
1489
|
+
def call(input)
|
|
1490
|
+
User.create!(input.to_h)
|
|
1491
|
+
end
|
|
1492
|
+
end
|
|
1493
|
+
```
|
|
1494
|
+
|
|
1495
|
+
###### Callbacks for Retry Events
|
|
1496
|
+
|
|
1497
|
+
```ruby
|
|
1498
|
+
class ProcessOrderUseCase < SenroUsecaser::Base
|
|
1499
|
+
input Input
|
|
1500
|
+
|
|
1501
|
+
retry_on :payment_error, attempts: 3
|
|
1502
|
+
|
|
1503
|
+
# Called before each retry attempt
|
|
1504
|
+
before_retry do |input, result, context|
|
|
1505
|
+
logger.warn("Retrying attempt #{context.attempt + 1}...")
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
# Called when all retry attempts are exhausted
|
|
1509
|
+
after_retries_exhausted do |input, result, context|
|
|
1510
|
+
logger.error("All #{context.max_attempts} attempts failed")
|
|
1511
|
+
ErrorNotifier.notify(result.errors, attempts: context.attempt)
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1514
|
+
def call(input)
|
|
1515
|
+
# ...
|
|
1516
|
+
end
|
|
1517
|
+
end
|
|
1518
|
+
```
|
|
1519
|
+
|
|
1520
|
+
###### Retry in Pipelines
|
|
1521
|
+
|
|
1522
|
+
When a step in a pipeline fails and retries, the behavior depends on the retry outcome:
|
|
1523
|
+
|
|
1524
|
+
```ruby
|
|
1525
|
+
class PlaceOrderUseCase < SenroUsecaser::Base
|
|
1526
|
+
organize do
|
|
1527
|
+
step CreateOrderUseCase
|
|
1528
|
+
step ChargePaymentUseCase # Has retry_on :gateway_error, attempts: 3
|
|
1529
|
+
step SendConfirmationUseCase
|
|
1530
|
+
end
|
|
1531
|
+
end
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
**Retry succeeds**: Pipeline continues to the next step normally.
|
|
1535
|
+
|
|
1536
|
+
```
|
|
1537
|
+
CreateOrder ✓ → ChargePayment ✗ → (retry) → ChargePayment ✓ → SendConfirmation ✓
|
|
1538
|
+
```
|
|
1539
|
+
|
|
1540
|
+
**Retry exhausted**: Pipeline fails and rollback is triggered.
|
|
1541
|
+
|
|
1542
|
+
```
|
|
1543
|
+
CreateOrder ✓ → ChargePayment ✗ → (retry x3) → ChargePayment ✗ (exhausted)
|
|
1544
|
+
↓
|
|
1545
|
+
ChargePayment.on_failure
|
|
1546
|
+
↓
|
|
1547
|
+
CreateOrder.on_failure (rollback)
|
|
1548
|
+
```
|
|
1549
|
+
|
|
1550
|
+
###### Combining retry_on with on_failure
|
|
1551
|
+
|
|
1552
|
+
`retry_on` is evaluated first. If retries are exhausted or the error is discarded, `on_failure` is called:
|
|
1553
|
+
|
|
1554
|
+
```ruby
|
|
1555
|
+
class ProcessPaymentUseCase < SenroUsecaser::Base
|
|
1556
|
+
input Input
|
|
1557
|
+
|
|
1558
|
+
retry_on :gateway_error, attempts: 3
|
|
1559
|
+
discard_on :invalid_card
|
|
1560
|
+
|
|
1561
|
+
on_failure do |input, result, context|
|
|
1562
|
+
if context.retried?
|
|
1563
|
+
# All retries exhausted
|
|
1564
|
+
logger.error("Payment failed after #{context.attempt} attempts")
|
|
1565
|
+
else
|
|
1566
|
+
# First failure (discarded or non-retryable error)
|
|
1567
|
+
logger.error("Payment failed immediately: #{result.errors.first&.code}")
|
|
1568
|
+
end
|
|
1569
|
+
end
|
|
1570
|
+
|
|
1571
|
+
def call(input)
|
|
1572
|
+
# ...
|
|
1573
|
+
end
|
|
1574
|
+
end
|
|
1575
|
+
```
|
|
1576
|
+
|
|
1577
|
+
###### Execution Flow with Retry
|
|
1578
|
+
|
|
1579
|
+
```
|
|
1580
|
+
call(input)
|
|
1581
|
+
↓
|
|
1582
|
+
failure ←──────────────────────────────┐
|
|
1583
|
+
↓ │
|
|
1584
|
+
retry_on matches? ──yes──→ attempt < max?
|
|
1585
|
+
↓ no ↓ yes │ no
|
|
1586
|
+
↓ wait → retry ───┘
|
|
1587
|
+
↓ ↓
|
|
1588
|
+
└────────────────────────────┴──→ on_failure
|
|
1589
|
+
↓
|
|
1590
|
+
(rollback if pipeline)
|
|
1591
|
+
```
|
|
1592
|
+
|
|
938
1593
|
##### Input/Output Validation
|
|
939
1594
|
|
|
940
1595
|
Use `extend_with` to integrate validation libraries like ActiveModel::Validations:
|