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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
module SenroUsecaser
|
|
6
|
+
# Module that provides dependency injection support
|
|
7
|
+
#
|
|
8
|
+
# This module can be extended into any class to enable the full DI functionality
|
|
9
|
+
# similar to UseCase and Hook classes, including:
|
|
10
|
+
# - `depends_on` for declaring dependencies
|
|
11
|
+
# - `namespace` for scoped dependency resolution
|
|
12
|
+
# - Automatic `infer_namespace_from_module` support
|
|
13
|
+
# - Default `initialize` that sets up dependency injection (uses SenroUsecaser.container if not provided)
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage (no initialize needed)
|
|
16
|
+
# class MyService
|
|
17
|
+
# extend SenroUsecaser::DependsOn
|
|
18
|
+
#
|
|
19
|
+
# depends_on :logger, Logger
|
|
20
|
+
# depends_on :repository
|
|
21
|
+
#
|
|
22
|
+
# # No initialize needed! Default is provided automatically.
|
|
23
|
+
#
|
|
24
|
+
# def perform
|
|
25
|
+
# logger.info("Performing...")
|
|
26
|
+
# repository.find(1)
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# service = MyService.new # Uses SenroUsecaser.container
|
|
31
|
+
# service.logger # => Logger instance
|
|
32
|
+
#
|
|
33
|
+
# @example Custom initialize with super
|
|
34
|
+
# class MyService
|
|
35
|
+
# extend SenroUsecaser::DependsOn
|
|
36
|
+
#
|
|
37
|
+
# depends_on :logger
|
|
38
|
+
# attr_reader :extra
|
|
39
|
+
#
|
|
40
|
+
# def initialize(extra:, container: nil)
|
|
41
|
+
# super(container: container) # Handles dependency resolution
|
|
42
|
+
# @extra = extra
|
|
43
|
+
# end
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# @example With explicit namespace
|
|
47
|
+
# class Admin::UserService
|
|
48
|
+
# extend SenroUsecaser::DependsOn
|
|
49
|
+
#
|
|
50
|
+
# namespace :admin
|
|
51
|
+
# depends_on :user_repository, UserRepository
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
# @example With infer_namespace_from_module (when configured)
|
|
55
|
+
# # When SenroUsecaser.configuration.infer_namespace_from_module = true
|
|
56
|
+
# class Admin::Orders::ProcessService
|
|
57
|
+
# extend SenroUsecaser::DependsOn
|
|
58
|
+
#
|
|
59
|
+
# depends_on :order_repository # resolved from "admin::orders" namespace
|
|
60
|
+
# end
|
|
61
|
+
module DependsOn
|
|
62
|
+
# Hook called when module is extended into a class
|
|
63
|
+
# Automatically includes InstanceMethods
|
|
64
|
+
#
|
|
65
|
+
def self.extended(base)
|
|
66
|
+
base.include(InstanceMethods)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Declares a dependency to be injected from the container
|
|
70
|
+
#
|
|
71
|
+
# @param name [Symbol] The name of the dependency
|
|
72
|
+
# @param type [Class, nil] Optional expected type for the dependency
|
|
73
|
+
#
|
|
74
|
+
# @example Basic dependency
|
|
75
|
+
# depends_on :logger
|
|
76
|
+
#
|
|
77
|
+
# @example Typed dependency
|
|
78
|
+
# depends_on :repository, UserRepository
|
|
79
|
+
#
|
|
80
|
+
#: (Symbol, ?Class) -> void
|
|
81
|
+
def depends_on(name, type = nil)
|
|
82
|
+
dependencies << name unless dependencies.include?(name)
|
|
83
|
+
dependency_types[name] = type if type
|
|
84
|
+
|
|
85
|
+
define_method(name) do # steep:ignore NoMethod
|
|
86
|
+
@_dependencies[name]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the list of declared dependencies
|
|
91
|
+
#
|
|
92
|
+
#: () -> Array[Symbol]
|
|
93
|
+
def dependencies
|
|
94
|
+
@dependencies ||= []
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Returns the dependency type mapping
|
|
98
|
+
#
|
|
99
|
+
#: () -> Hash[Symbol, Class]
|
|
100
|
+
def dependency_types
|
|
101
|
+
@dependency_types ||= {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Sets or returns the namespace for dependency resolution
|
|
105
|
+
#
|
|
106
|
+
# @param name [Symbol, String, nil] The namespace to set
|
|
107
|
+
# @return [Symbol, String, nil] The current namespace when no argument given
|
|
108
|
+
#
|
|
109
|
+
# @example Setting namespace
|
|
110
|
+
# namespace :admin
|
|
111
|
+
#
|
|
112
|
+
# @example Getting namespace
|
|
113
|
+
# current_ns = namespace
|
|
114
|
+
#
|
|
115
|
+
#: (?(Symbol | String)) -> (Symbol | String)?
|
|
116
|
+
def namespace(name = nil)
|
|
117
|
+
if name
|
|
118
|
+
@declared_namespace = name
|
|
119
|
+
else
|
|
120
|
+
@declared_namespace
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns the declared namespace
|
|
125
|
+
#
|
|
126
|
+
#: () -> (Symbol | String)?
|
|
127
|
+
def declared_namespace
|
|
128
|
+
@declared_namespace
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Copies dependency configuration to a subclass
|
|
132
|
+
#
|
|
133
|
+
# @param subclass [Class] The subclass to copy dependencies to
|
|
134
|
+
#
|
|
135
|
+
# @example In inherited hook
|
|
136
|
+
# def self.inherited(subclass)
|
|
137
|
+
# super
|
|
138
|
+
# copy_depends_on_to(subclass)
|
|
139
|
+
# end
|
|
140
|
+
#
|
|
141
|
+
#: (Class) -> void
|
|
142
|
+
def copy_depends_on_to(subclass)
|
|
143
|
+
subclass.instance_variable_set(:@dependencies, dependencies.dup)
|
|
144
|
+
subclass.instance_variable_set(:@dependency_types, dependency_types.dup)
|
|
145
|
+
subclass.instance_variable_set(:@declared_namespace, @declared_namespace)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Instance methods for dependency resolution
|
|
149
|
+
#
|
|
150
|
+
# These methods are automatically included when DependsOn is extended.
|
|
151
|
+
# They require @_container and @_dependencies instance variables to be set.
|
|
152
|
+
module InstanceMethods
|
|
153
|
+
# Default initialize for classes using DependsOn
|
|
154
|
+
#
|
|
155
|
+
# This provides a default initialize that sets up dependency injection.
|
|
156
|
+
# Classes can override this and call super to extend the behavior.
|
|
157
|
+
#
|
|
158
|
+
# @param container [Container, nil] The DI container to resolve dependencies from.
|
|
159
|
+
# If nil, uses SenroUsecaser.container.
|
|
160
|
+
#
|
|
161
|
+
# @example Default usage (no arguments needed)
|
|
162
|
+
# class MyService
|
|
163
|
+
# extend SenroUsecaser::DependsOn
|
|
164
|
+
# depends_on :logger
|
|
165
|
+
# end
|
|
166
|
+
# service = MyService.new # Uses SenroUsecaser.container
|
|
167
|
+
#
|
|
168
|
+
# @example With explicit container
|
|
169
|
+
# service = MyService.new(container: custom_container)
|
|
170
|
+
#
|
|
171
|
+
# @example Custom initialize with super
|
|
172
|
+
# class MyService
|
|
173
|
+
# extend SenroUsecaser::DependsOn
|
|
174
|
+
# depends_on :logger
|
|
175
|
+
# attr_reader :extra
|
|
176
|
+
#
|
|
177
|
+
# def initialize(extra:, container: nil)
|
|
178
|
+
# super(container: container)
|
|
179
|
+
# @extra = extra
|
|
180
|
+
# end
|
|
181
|
+
# end
|
|
182
|
+
#
|
|
183
|
+
#: (?container: Container?) -> void
|
|
184
|
+
def initialize(container: nil)
|
|
185
|
+
@_container = container || SenroUsecaser.container
|
|
186
|
+
@_dependencies = {} #: Hash[Symbol, untyped]
|
|
187
|
+
resolve_dependencies
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Resolves all declared dependencies from the container
|
|
191
|
+
#
|
|
192
|
+
# Call this in your initialize method after setting @_container and @_dependencies.
|
|
193
|
+
#
|
|
194
|
+
# @example
|
|
195
|
+
# def initialize(container:)
|
|
196
|
+
# @_container = container
|
|
197
|
+
# @_dependencies = {}
|
|
198
|
+
# resolve_dependencies
|
|
199
|
+
# end
|
|
200
|
+
#
|
|
201
|
+
#: () -> void
|
|
202
|
+
def resolve_dependencies
|
|
203
|
+
self.class.dependencies.each do |name| # steep:ignore NoMethod
|
|
204
|
+
@_dependencies[name] = resolve_from_container(name)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# Returns the effective namespace for dependency resolution
|
|
211
|
+
#
|
|
212
|
+
# Priority:
|
|
213
|
+
# 1. Explicitly declared namespace via `namespace :name`
|
|
214
|
+
# 2. Inferred namespace from module structure (if configured)
|
|
215
|
+
#
|
|
216
|
+
#: () -> (Symbol | String)?
|
|
217
|
+
def effective_namespace
|
|
218
|
+
declared = self.class.declared_namespace # steep:ignore NoMethod
|
|
219
|
+
return declared if declared
|
|
220
|
+
return nil unless SenroUsecaser.configuration.infer_namespace_from_module
|
|
221
|
+
|
|
222
|
+
infer_namespace_from_class
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Infers namespace from the class's module structure
|
|
226
|
+
#
|
|
227
|
+
# Converts CamelCase module names to snake_case and joins with "::"
|
|
228
|
+
# e.g., Admin::Orders::ProcessService -> "admin::orders"
|
|
229
|
+
#
|
|
230
|
+
#: () -> String?
|
|
231
|
+
def infer_namespace_from_class
|
|
232
|
+
class_name = self.class.name
|
|
233
|
+
return nil unless class_name
|
|
234
|
+
|
|
235
|
+
parts = class_name.split("::")
|
|
236
|
+
return nil if parts.length <= 1
|
|
237
|
+
|
|
238
|
+
module_parts = parts[0...-1] || [] #: Array[String]
|
|
239
|
+
return nil if module_parts.empty?
|
|
240
|
+
|
|
241
|
+
module_parts.map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase }.join("::")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Resolves a single dependency from the container
|
|
245
|
+
#
|
|
246
|
+
#: (Symbol) -> untyped
|
|
247
|
+
def resolve_from_container(name)
|
|
248
|
+
ns = effective_namespace
|
|
249
|
+
if ns
|
|
250
|
+
@_container.resolve_in(ns, name)
|
|
251
|
+
else
|
|
252
|
+
@_container.resolve(name)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
data/lib/senro_usecaser/hook.rb
CHANGED
|
@@ -38,57 +38,18 @@ module SenroUsecaser
|
|
|
38
38
|
# end
|
|
39
39
|
# end
|
|
40
40
|
class Hook
|
|
41
|
-
|
|
42
|
-
# Declares a dependency to be injected from the container
|
|
43
|
-
#
|
|
44
|
-
#: (Symbol, ?Class) -> void
|
|
45
|
-
def depends_on(name, type = nil)
|
|
46
|
-
dependencies << name unless dependencies.include?(name)
|
|
47
|
-
dependency_types[name] = type if type
|
|
48
|
-
|
|
49
|
-
define_method(name) do
|
|
50
|
-
@_dependencies[name]
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Returns the list of declared dependencies
|
|
55
|
-
#
|
|
56
|
-
#: () -> Array[Symbol]
|
|
57
|
-
def dependencies
|
|
58
|
-
@dependencies ||= []
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Returns the dependency type mapping
|
|
62
|
-
#
|
|
63
|
-
#: () -> Hash[Symbol, Class]
|
|
64
|
-
def dependency_types
|
|
65
|
-
@dependency_types ||= {}
|
|
66
|
-
end
|
|
41
|
+
extend DependsOn
|
|
67
42
|
|
|
68
|
-
|
|
69
|
-
#
|
|
70
|
-
#: (?(Symbol | String)) -> (Symbol | String)?
|
|
71
|
-
def namespace(name = nil)
|
|
72
|
-
if name
|
|
73
|
-
@hook_namespace = name
|
|
74
|
-
else
|
|
75
|
-
@hook_namespace
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Alias for namespace() without arguments
|
|
43
|
+
class << self
|
|
44
|
+
# Alias for backward compatibility
|
|
80
45
|
#
|
|
81
46
|
#: () -> (Symbol | String)?
|
|
82
|
-
|
|
83
|
-
@hook_namespace
|
|
84
|
-
end
|
|
47
|
+
alias hook_namespace declared_namespace
|
|
85
48
|
|
|
86
49
|
# @api private
|
|
87
50
|
def inherited(subclass)
|
|
88
51
|
super
|
|
89
|
-
subclass
|
|
90
|
-
subclass.instance_variable_set(:@dependency_types, dependency_types.dup)
|
|
91
|
-
subclass.instance_variable_set(:@hook_namespace, @hook_namespace)
|
|
52
|
+
copy_depends_on_to(subclass)
|
|
92
53
|
end
|
|
93
54
|
end
|
|
94
55
|
|
|
@@ -127,54 +88,39 @@ module SenroUsecaser
|
|
|
127
88
|
yield
|
|
128
89
|
end
|
|
129
90
|
|
|
91
|
+
# Called when the UseCase fails
|
|
92
|
+
# Override in subclass to add failure handling or rollback logic
|
|
93
|
+
#
|
|
94
|
+
# @example Basic logging
|
|
95
|
+
# def on_failure(input, result)
|
|
96
|
+
# logger.error("Failed: #{result.errors.first&.message}")
|
|
97
|
+
# end
|
|
98
|
+
#
|
|
99
|
+
# @example Request retry
|
|
100
|
+
# def on_failure(input, result, context)
|
|
101
|
+
# if result.errors.first&.code == :network_error && context.attempt < 3
|
|
102
|
+
# context.retry!(wait: 2.0)
|
|
103
|
+
# end
|
|
104
|
+
# end
|
|
105
|
+
#
|
|
106
|
+
#: (untyped, Result[untyped], ?RetryContext?) -> void
|
|
107
|
+
def on_failure(input, result, context = nil)
|
|
108
|
+
# Override in subclass
|
|
109
|
+
end
|
|
110
|
+
|
|
130
111
|
private
|
|
131
112
|
|
|
132
113
|
# Returns the effective namespace for dependency resolution
|
|
114
|
+
# Overrides DependsOn::InstanceMethods to add use_case_namespace fallback
|
|
133
115
|
#
|
|
134
116
|
#: () -> (Symbol | String)?
|
|
135
117
|
def effective_namespace
|
|
136
|
-
|
|
118
|
+
declared = self.class.declared_namespace
|
|
119
|
+
return declared if declared
|
|
137
120
|
return @_use_case_namespace if @_use_case_namespace
|
|
138
121
|
return nil unless SenroUsecaser.configuration.infer_namespace_from_module
|
|
139
122
|
|
|
140
123
|
infer_namespace_from_class
|
|
141
124
|
end
|
|
142
|
-
|
|
143
|
-
# Infers namespace from the class's module structure
|
|
144
|
-
#
|
|
145
|
-
#: () -> String?
|
|
146
|
-
def infer_namespace_from_class
|
|
147
|
-
class_name = self.class.name
|
|
148
|
-
return nil unless class_name
|
|
149
|
-
|
|
150
|
-
parts = class_name.split("::")
|
|
151
|
-
return nil if parts.length <= 1
|
|
152
|
-
|
|
153
|
-
module_parts = parts[0...-1] || [] #: Array[String]
|
|
154
|
-
return nil if module_parts.empty?
|
|
155
|
-
|
|
156
|
-
module_parts.map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase }.join("::")
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# Resolves dependencies from the container
|
|
160
|
-
#
|
|
161
|
-
#: () -> void
|
|
162
|
-
def resolve_dependencies
|
|
163
|
-
self.class.dependencies.each do |name|
|
|
164
|
-
@_dependencies[name] = resolve_from_container(name)
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# Resolves a single dependency from the container
|
|
169
|
-
#
|
|
170
|
-
#: (Symbol) -> untyped
|
|
171
|
-
def resolve_from_container(name)
|
|
172
|
-
namespace = effective_namespace
|
|
173
|
-
if namespace
|
|
174
|
-
@_container.resolve_in(namespace, name)
|
|
175
|
-
else
|
|
176
|
-
@_container.resolve(name)
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
125
|
end
|
|
180
126
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
module SenroUsecaser
|
|
6
|
+
# Configuration for automatic retry behavior
|
|
7
|
+
#
|
|
8
|
+
# This class defines when and how retries should occur based on
|
|
9
|
+
# error codes or exception classes, with configurable backoff strategies.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic retry configuration
|
|
12
|
+
# RetryConfiguration.new(
|
|
13
|
+
# matchers: [:network_error, Net::OpenTimeout],
|
|
14
|
+
# attempts: 3,
|
|
15
|
+
# wait: 1.0
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @example With exponential backoff
|
|
19
|
+
# RetryConfiguration.new(
|
|
20
|
+
# matchers: [:rate_limited],
|
|
21
|
+
# attempts: 5,
|
|
22
|
+
# wait: 2.0,
|
|
23
|
+
# backoff: :exponential,
|
|
24
|
+
# max_wait: 60
|
|
25
|
+
# )
|
|
26
|
+
class RetryConfiguration
|
|
27
|
+
# Returns the list of error matchers (Symbols for error codes, Classes for exceptions)
|
|
28
|
+
#: () -> Array[(Symbol | Class)]
|
|
29
|
+
attr_reader :matchers
|
|
30
|
+
|
|
31
|
+
# Returns the maximum number of attempts
|
|
32
|
+
#: () -> Integer
|
|
33
|
+
attr_reader :attempts
|
|
34
|
+
|
|
35
|
+
# Returns the base wait time in seconds
|
|
36
|
+
#: () -> (Float | Integer)
|
|
37
|
+
attr_reader :wait
|
|
38
|
+
|
|
39
|
+
# Returns the backoff strategy (:fixed, :linear, :exponential)
|
|
40
|
+
#: () -> Symbol
|
|
41
|
+
attr_reader :backoff
|
|
42
|
+
|
|
43
|
+
# Returns the maximum wait time in seconds
|
|
44
|
+
#: () -> (Float | Integer)
|
|
45
|
+
attr_reader :max_wait
|
|
46
|
+
|
|
47
|
+
# Returns the jitter factor (0.0 to 1.0)
|
|
48
|
+
#: () -> (Float | Integer)
|
|
49
|
+
attr_reader :jitter
|
|
50
|
+
|
|
51
|
+
# Initializes a new retry configuration
|
|
52
|
+
#
|
|
53
|
+
# @param matchers [Array<Symbol, Class>] Error codes or exception classes to match
|
|
54
|
+
# @param attempts [Integer] Maximum number of attempts (default: 3)
|
|
55
|
+
# @param wait [Numeric] Base wait time in seconds (default: 0)
|
|
56
|
+
# @param backoff [Symbol] Backoff strategy: :fixed, :linear, or :exponential (default: :fixed)
|
|
57
|
+
# @param max_wait [Numeric] Maximum wait time in seconds (default: 3600)
|
|
58
|
+
# @param jitter [Numeric] Jitter factor 0.0-1.0 to randomize wait times (default: 0)
|
|
59
|
+
#
|
|
60
|
+
# rubocop:disable Metrics/ParameterLists
|
|
61
|
+
#: (matchers: Array[(Symbol | Class)], ?attempts: Integer, ?wait: (Float | Integer),
|
|
62
|
+
#: ?backoff: Symbol, ?max_wait: (Float | Integer)?, ?jitter: (Float | Integer)) -> void
|
|
63
|
+
def initialize(matchers:, attempts: 3, wait: 0, backoff: :fixed, max_wait: nil, jitter: 0)
|
|
64
|
+
# rubocop:enable Metrics/ParameterLists
|
|
65
|
+
@matchers = matchers
|
|
66
|
+
@attempts = attempts
|
|
67
|
+
@wait = wait
|
|
68
|
+
@backoff = backoff
|
|
69
|
+
@max_wait = max_wait || 3600
|
|
70
|
+
@jitter = jitter
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Checks if this configuration matches the given result
|
|
74
|
+
#
|
|
75
|
+
#: (Result[untyped]) -> bool
|
|
76
|
+
def matches?(result)
|
|
77
|
+
return false unless result.failure?
|
|
78
|
+
|
|
79
|
+
result.errors.any? { |error| matches_error?(error) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Calculates the wait time for the given attempt number
|
|
83
|
+
#
|
|
84
|
+
#: (Integer) -> Float
|
|
85
|
+
def calculate_wait(attempt)
|
|
86
|
+
base = calculate_base_wait(attempt)
|
|
87
|
+
base = [base, @max_wait].min
|
|
88
|
+
apply_jitter(base)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Checks if an error matches any of the configured matchers
|
|
94
|
+
#
|
|
95
|
+
#: (Error) -> bool
|
|
96
|
+
def matches_error?(error)
|
|
97
|
+
@matchers.any? do |matcher|
|
|
98
|
+
case matcher
|
|
99
|
+
when Symbol
|
|
100
|
+
error.code == matcher
|
|
101
|
+
when Class
|
|
102
|
+
error.cause&.is_a?(matcher)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Calculates the base wait time based on backoff strategy
|
|
108
|
+
#
|
|
109
|
+
#: (Integer) -> (Float | Integer)
|
|
110
|
+
def calculate_base_wait(attempt)
|
|
111
|
+
case @backoff
|
|
112
|
+
when :linear
|
|
113
|
+
@wait * attempt
|
|
114
|
+
when :exponential
|
|
115
|
+
@wait * (2**(attempt - 1))
|
|
116
|
+
else # :fixed or any other value
|
|
117
|
+
@wait
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Applies jitter to the wait time
|
|
122
|
+
#
|
|
123
|
+
#: ((Float | Integer)) -> Float
|
|
124
|
+
def apply_jitter(base)
|
|
125
|
+
return base.to_f if @jitter <= 0
|
|
126
|
+
|
|
127
|
+
jitter_amount = (rand * @jitter * base * 2) - (@jitter * base)
|
|
128
|
+
[base + jitter_amount, 0.0].max.to_f
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
module SenroUsecaser
|
|
6
|
+
# Represents the context of a retry operation
|
|
7
|
+
#
|
|
8
|
+
# This class tracks the state of retry attempts including:
|
|
9
|
+
# - Current attempt number
|
|
10
|
+
# - Maximum attempts allowed
|
|
11
|
+
# - Elapsed time since first attempt
|
|
12
|
+
# - Whether a retry should occur
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage in on_failure hook
|
|
15
|
+
# on_failure do |input, result, context|
|
|
16
|
+
# if context.attempt < 3
|
|
17
|
+
# context.retry!(wait: 1.0)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example With modified input
|
|
22
|
+
# on_failure do |input, result, context|
|
|
23
|
+
# if result.errors.first&.code == :rate_limited
|
|
24
|
+
# context.retry!(input: input.with_reduced_batch_size, wait: 5.0)
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
class RetryContext
|
|
28
|
+
# Returns the current attempt number (1-indexed)
|
|
29
|
+
#: () -> Integer
|
|
30
|
+
attr_reader :attempt
|
|
31
|
+
|
|
32
|
+
# Returns the maximum number of attempts allowed
|
|
33
|
+
#: () -> Integer?
|
|
34
|
+
attr_reader :max_attempts
|
|
35
|
+
|
|
36
|
+
# Returns the time when the first attempt started
|
|
37
|
+
#: () -> Time
|
|
38
|
+
attr_reader :started_at
|
|
39
|
+
|
|
40
|
+
# Returns the error from the last failed attempt
|
|
41
|
+
#: () -> Error?
|
|
42
|
+
attr_reader :last_error
|
|
43
|
+
|
|
44
|
+
# Returns the input to use for the retry (nil means use original)
|
|
45
|
+
#: () -> untyped
|
|
46
|
+
attr_reader :retry_input
|
|
47
|
+
|
|
48
|
+
# Returns the wait time before retrying
|
|
49
|
+
#: () -> (Float | Integer)?
|
|
50
|
+
attr_reader :retry_wait
|
|
51
|
+
|
|
52
|
+
# Initializes a new retry context
|
|
53
|
+
#
|
|
54
|
+
#: (?max_attempts: Integer?) -> void
|
|
55
|
+
def initialize(max_attempts: nil)
|
|
56
|
+
@attempt = 1
|
|
57
|
+
@max_attempts = max_attempts
|
|
58
|
+
@started_at = Time.now
|
|
59
|
+
@last_error = nil
|
|
60
|
+
@should_retry = false
|
|
61
|
+
@retry_input = nil
|
|
62
|
+
@retry_wait = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns true if this is a retry (attempt > 1)
|
|
66
|
+
#
|
|
67
|
+
#: () -> bool
|
|
68
|
+
def retried?
|
|
69
|
+
@attempt > 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the elapsed time since the first attempt
|
|
73
|
+
#
|
|
74
|
+
#: () -> Float
|
|
75
|
+
def elapsed_time
|
|
76
|
+
Time.now - @started_at
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns true if max_attempts has been reached
|
|
80
|
+
#
|
|
81
|
+
#: () -> bool
|
|
82
|
+
def exhausted?
|
|
83
|
+
return false unless @max_attempts
|
|
84
|
+
|
|
85
|
+
@attempt >= @max_attempts
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns true if a retry has been requested
|
|
89
|
+
#
|
|
90
|
+
#: () -> bool
|
|
91
|
+
def should_retry?
|
|
92
|
+
@should_retry
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Requests a retry with optional modified input and wait time
|
|
96
|
+
#
|
|
97
|
+
# @example Retry with default settings
|
|
98
|
+
# context.retry!
|
|
99
|
+
#
|
|
100
|
+
# @example Retry with wait time
|
|
101
|
+
# context.retry!(wait: 2.0)
|
|
102
|
+
#
|
|
103
|
+
# @example Retry with modified input
|
|
104
|
+
# context.retry!(input: modified_input, wait: 1.0)
|
|
105
|
+
#
|
|
106
|
+
#: (?input: untyped, ?wait: (Float | Integer)?) -> void
|
|
107
|
+
def retry!(input: nil, wait: nil)
|
|
108
|
+
@should_retry = true
|
|
109
|
+
@retry_input = input
|
|
110
|
+
@retry_wait = wait
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Increments the attempt counter and resets retry state
|
|
114
|
+
# Called internally between retry attempts
|
|
115
|
+
#
|
|
116
|
+
#: (?last_error: Error?) -> void
|
|
117
|
+
def increment!(last_error: nil)
|
|
118
|
+
@attempt += 1
|
|
119
|
+
@last_error = last_error
|
|
120
|
+
reset_retry_state!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Resets the retry request state
|
|
124
|
+
# Called internally after processing retry decision
|
|
125
|
+
#
|
|
126
|
+
#: () -> void
|
|
127
|
+
def reset_retry_state!
|
|
128
|
+
@should_retry = false
|
|
129
|
+
@retry_input = nil
|
|
130
|
+
@retry_wait = nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/senro_usecaser.rb
CHANGED
|
@@ -8,6 +8,9 @@ require_relative "senro_usecaser/result"
|
|
|
8
8
|
require_relative "senro_usecaser/container"
|
|
9
9
|
require_relative "senro_usecaser/configuration"
|
|
10
10
|
require_relative "senro_usecaser/provider"
|
|
11
|
+
require_relative "senro_usecaser/retry_context"
|
|
12
|
+
require_relative "senro_usecaser/retry_configuration"
|
|
13
|
+
require_relative "senro_usecaser/depends_on"
|
|
11
14
|
require_relative "senro_usecaser/hook"
|
|
12
15
|
require_relative "senro_usecaser/base"
|
|
13
16
|
|