ibm_appconfiguration_ruby_sdk 0.1.0.pre.rc.0

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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +76 -0
  4. data/CONTRIBUTING.md +9 -0
  5. data/LICENSE +201 -0
  6. data/README.md +474 -0
  7. data/Rakefile +8 -0
  8. data/examples/README.md +60 -0
  9. data/examples/app.rb +104 -0
  10. data/lib/ibm_appconfiguration_ruby_sdk/app_configuration.rb +291 -0
  11. data/lib/ibm_appconfiguration_ruby_sdk/configurations/configuration_handler.rb +828 -0
  12. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/constants.rb +89 -0
  13. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/file_manager.rb +72 -0
  14. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/logger.rb +98 -0
  15. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/background_retry_manager.rb +284 -0
  16. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/config_fetcher.rb +254 -0
  17. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/utils.rb +240 -0
  18. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connection_manager.rb +501 -0
  19. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connectivity.rb +30 -0
  20. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/driver_socket.rb +28 -0
  21. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/retry_policy.rb +42 -0
  22. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/state.rb +24 -0
  23. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/watchdog.rb +50 -0
  24. data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/websocket_client.rb +43 -0
  25. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/feature.rb +121 -0
  26. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/property.rb +107 -0
  27. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/rule.rb +87 -0
  28. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/secret_property.rb +81 -0
  29. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment.rb +39 -0
  30. data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment_rules.rb +57 -0
  31. data/lib/ibm_appconfiguration_ruby_sdk/core/api_manager.rb +269 -0
  32. data/lib/ibm_appconfiguration_ruby_sdk/core/metering.rb +400 -0
  33. data/lib/ibm_appconfiguration_ruby_sdk/core/url_builder.rb +252 -0
  34. data/lib/ibm_appconfiguration_ruby_sdk/version.rb +20 -0
  35. data/lib/ibm_appconfiguration_ruby_sdk.rb +20 -0
  36. data/sig/ibm_appconfiguration_ruby_sdk.rbs +4 -0
  37. metadata +209 -0
@@ -0,0 +1,89 @@
1
+ # Copyright 2026 IBM Corp. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # frozen_string_literal: true
16
+
17
+ ##
18
+ # This file defines the various constants used by the SDK.
19
+ #
20
+ module Constants
21
+ # Maximum number of retries for API requests
22
+ MAX_NUMBER_OF_RETRIES = 3
23
+
24
+ # HTTP Status codes
25
+ STATUS_CODE_OK = 200
26
+ STATUS_CODE_ACCEPTED = 202
27
+
28
+ # Socket constants
29
+ CUSTOM_SOCKET_CLOSE_REASON_CODE = 4001
30
+ SOCKET_CONNECTION_ERROR = "Socket connection error"
31
+ SOCKET_LOST_ERROR = "Socket connection lost"
32
+ SOCKET_CONNECTION_CLOSE = "Socket connection is closed"
33
+ SOCKET_INCOMING_DATA = "Received data from socket"
34
+ SOCKET_MESSAGE_RECEIVED = "Message received from server"
35
+ SOCKET_CALLBACK = "Message passed to handler"
36
+ SOCKET_MESSAGE_ERROR = "Message received from server is invalid"
37
+ SOCKET_CONNECTION_SUCCESS = "Successfully connected to App Configuration server"
38
+ APPCONFIGURATION_CLIENT_EMITTER = "configurationUpdate"
39
+
40
+ # Error messages
41
+ REGION_ERROR = "Provide a valid region in App Configuration init"
42
+ GUID_ERROR = "Provide a valid guid in App Configuration init"
43
+ APIKEY_ERROR = "Provide a valid apiKey in App Configuration init"
44
+ COLLECTION_ID_VALUE_ERROR = "Provide a valid collectionId in App Configuration setContext method."
45
+ ENVIRONMENT_ID_VALUE_ERROR = "Provide a valid environmentId in App Configuration setContext method."
46
+ COLLECTION_ID_ERROR = "Invalid action in App Configuration. This action can be performed only after a successful initialization."
47
+ COLLECTION_INIT_ERROR = "Invalid action in App Configuration. This action can be performed only after a successful initialization and setting the context."
48
+ INVALID_OPTIONS_PARAMTER = "options param passed to setContext is invalid. Should be a Hash"
49
+ CONFIGURATION_FILE_NOT_FOUND_ERROR = "bootstrapFile parameter should be provided while liveConfigUpdateEnabled is false during initialization."
50
+ PERSISTENT_CACHE_OPTION_ERROR = "setContext: [options.persistentCacheDirectory]. Invalid value -"
51
+ BOOTSTRAP_FILEPATH_OPTION_ERROR = "setContext: [options.bootstrapFile]. Invalid value -"
52
+ LIVE_CONFIG_UPDATE_OPTION_ERROR = "setContext: [options.liveConfigUpdateEnabled]. Invalid value -"
53
+ BOOTSTRAP_FILEPATH_NOT_FOUND_ERROR = "setContext: [options.bootstrapFile] parameter should be provided when [options.liveConfigUpdateEnabled] is false."
54
+ NO_INTERNET_CONNECTION_ERROR = "Check for network connectivity failed. Re-checking..."
55
+ INVALID_ENTITY_ID = "Invalid entityId passed to"
56
+ SINGLETON_EXCEPTION = "Initialize object first"
57
+
58
+ # Default values
59
+ DEFAULT_SEGMENT_ID = "$$null$$"
60
+ DEFAULT_ENTITY_ID = "$$null$$"
61
+ DEFAULT_USAGE_LIMIT = 10
62
+ DEFAULT_ROLLOUT_PERCENTAGE = "$default"
63
+ DEFAULT_FEATURE_VALUE = "$default"
64
+ DEFAULT_PROPERTY_VALUE = "$default"
65
+
66
+ # Secret Manager
67
+ INVALID_SECRET_MANAGER_CLIENT_MESSAGE = "Secret Manager object passed to getSecret method is null or undefined."
68
+ SECRETREF = "SECRETREF"
69
+
70
+ # Success messages
71
+ SUCCESSFULLY_FETCHED_THE_CONFIGURATIONS = "Successfully fetched the configurations"
72
+ SUCCESSFULLY_POSTED_METERING_DATA = "Successfully posted metering data"
73
+ SUCCESSFULLY_POSTED_EXPERIMENT_EVALUATION_EVENTS = "Successfully posted evaluation events"
74
+ SUCCESSFULLY_POSTED_EXPERIMENT_METRIC_EVENTS = "Successfully posted metric events"
75
+
76
+ # Error messages for posting data
77
+ ERROR_POSTING_METERING_DATA = "Error while posting metering data"
78
+ ERROR_POSTING_EXPERIMENT_EVALUATION_EVENTS = "Error while posting evaluation events"
79
+ ERROR_POSTING_EXPERIMENT_METRIC_EVENTS = "Error while posting metric events"
80
+ ERROR_NO_WRITE_PERMISSION = "Persistent cache directory provided doesn't have write permission. Make sure the directory has required access."
81
+ INPUT_PARAMETER_NOT_BOOLEAN = "Input parameter passed to usePrivateEndpoint() method is not boolean. Default value will be used."
82
+
83
+ # Rollout types
84
+ MANUAL = "MANUAL"
85
+ PROGRESSIVE = "PROGRESSIVE"
86
+
87
+ # Delimiter
88
+ DELIMITER = "\u001F"
89
+ end
@@ -0,0 +1,72 @@
1
+ # Copyright 2026 IBM Corp. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # frozen_string_literal: true
16
+
17
+ require "singleton"
18
+ require_relative "logger"
19
+
20
+ # This module provides methods that perform the store and retrieve operations on the
21
+ # file based cache of the SDK.
22
+ class FileManager
23
+ include Singleton
24
+
25
+ def initialize
26
+ @logger = Logger.instance
27
+ end
28
+
29
+ def store_files(json, file_path)
30
+ File.write(file_path, json)
31
+ end
32
+
33
+ def read_persistent_cache_configurations(file_path)
34
+ unless File.exist?(file_path)
35
+ @logger.log("configuration file in the persistent cache doesn't exist")
36
+ return ""
37
+ end
38
+
39
+ data = File.read(file_path).strip
40
+
41
+ if data.empty?
42
+ @logger.log("configuration file in the persistent cache is empty")
43
+ return ""
44
+ end
45
+
46
+ data
47
+ rescue StandardError
48
+ ""
49
+ end
50
+
51
+ def read_bootstrap_configurations_from_file(file_path)
52
+ raise "given bootstrap file path doesn't exist: #{file_path}" unless File.exist?(file_path)
53
+
54
+ data = File.read(file_path).strip
55
+
56
+ raise "given bootstrap file is empty: #{file_path}" if data.empty?
57
+
58
+ begin
59
+ data
60
+ rescue StandardError => e
61
+ raise "failed to parse the json from the given bootstrap file: #{file_path}. Error #{e}"
62
+ end
63
+ end
64
+
65
+ def delete_file_data(file_path)
66
+ return unless File.exist?(file_path)
67
+
68
+ File.truncate(file_path, 0)
69
+ rescue StandardError => e
70
+ @logger.warning(e)
71
+ end
72
+ end
@@ -0,0 +1,98 @@
1
+ # Copyright 2026 IBM Corp. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # frozen_string_literal: true
16
+
17
+ # Logger class for SDK logging with color-coded output
18
+ require "singleton"
19
+
20
+ class Logger
21
+ include Singleton
22
+
23
+ @debug = false
24
+
25
+ class << self
26
+ # Enable or disable debug logging
27
+ # @param value [Boolean] true to enable debug logging, false to disable
28
+ def set_debug(value = false)
29
+ @debug = value
30
+ end
31
+
32
+ # Check if debug logging is enabled
33
+ # @return [Boolean] true if debug is enabled, false otherwise
34
+ def debug?
35
+ @debug
36
+ end
37
+ end
38
+
39
+ # Generate timestamp for log messages
40
+ # @return [String] formatted timestamp
41
+ def timestamp
42
+ "#{Time.now.strftime("%Y-%m-%d %H:%M:%S")} AppConfiguration"
43
+ end
44
+
45
+ # Log debug message (only if debug is enabled)
46
+ # @param message [String] the message to log
47
+ def log(message)
48
+ return unless self.class.debug?
49
+
50
+ puts "#{timestamp} DEBUG #{message}"
51
+ end
52
+
53
+ # Log error message (always shown)
54
+ # @param message [String] the error message to log
55
+ def error(message)
56
+ puts "#{timestamp} ERROR #{colorize(message, :red)}"
57
+ end
58
+
59
+ # Log warning message (only if debug is enabled)
60
+ # @param message [String] the warning message to log
61
+ def warning(message)
62
+ return unless self.class.debug?
63
+
64
+ puts "#{timestamp} WARNING #{colorize(message, :yellow)}"
65
+ end
66
+
67
+ # Log success message (only if debug is enabled)
68
+ # @param message [String] the success message to log
69
+ def success(message)
70
+ return unless self.class.debug?
71
+
72
+ puts "#{timestamp} SUCCESS #{colorize(message, :green)}"
73
+ end
74
+
75
+ # Log info message (always shown)
76
+ # @param message [String] the info message to log
77
+ def info(message)
78
+ puts "#{timestamp} INFO #{colorize(message, :blue)}"
79
+ end
80
+
81
+ private
82
+
83
+ # Colorize text for terminal output
84
+ # @param text [String] the text to colorize
85
+ # @param color [Symbol] the color to apply (:red, :green, :yellow, :blue)
86
+ # @return [String] colorized text
87
+ def colorize(text, color)
88
+ color_codes = {
89
+ red: "\e[1;31m",
90
+ green: "\e[1;32m",
91
+ yellow: "\e[1;33m",
92
+ blue: "\e[1;44m",
93
+ reset: "\e[0m"
94
+ }
95
+
96
+ "#{color_codes[color]}#{text}#{color_codes[:reset]}"
97
+ end
98
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 IBM Corp. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require_relative "config_fetcher"
18
+ require_relative "../logger"
19
+
20
+ # BackgroundRetryManager
21
+ #
22
+ # Implements exponential backoff retry strategy for long-term configuration fetch failures.
23
+ # This is the "Tier 2" retry mechanism that activates after immediate retries fail.
24
+ #
25
+ # Key Features:
26
+ # - Exponential backoff: Delays increase exponentially (2^attempt)
27
+ # - Jitter: Random delays prevent thundering herd
28
+ # - Cap: Maximum delay capped at ~1 hour
29
+ # - Thread-safe: Uses Mutex for concurrent access
30
+ # - Graceful shutdown: Properly cleans up threads
31
+ #
32
+ # Retry Timeline Example:
33
+ # Attempt 1: ~2.3 minutes
34
+ # Attempt 2: ~4.7 minutes
35
+ # Attempt 3: ~9.1 minutes
36
+ # Attempt 4: ~18.5 minutes
37
+ # Attempt 5: ~37.2 minutes
38
+ # Attempt 6+: ~60 minutes (capped)
39
+ #
40
+ class BackgroundRetryManager
41
+ attr_reader :active, :attempt
42
+
43
+ # Initialize the retry manager
44
+ #
45
+ # @param collection_id [String] Collection ID for API request
46
+ # @param environment_id [String] Environment ID for API request
47
+ # @param logger [Logger] Optional logger instance (creates new one if not provided)
48
+ def initialize(collection_id:, environment_id:, logger: nil)
49
+ @collection_id = collection_id
50
+ @environment_id = environment_id
51
+ @logger = logger || Logger.instance
52
+
53
+ # Initialize ConfigFetcher
54
+ @config_fetcher = ConfigFetcher.new(
55
+ collection_id: collection_id,
56
+ environment_id: environment_id,
57
+ logger: @logger
58
+ )
59
+
60
+ # State management
61
+ @active = false
62
+ @attempt = 0
63
+ @cap_ms = nil
64
+
65
+ # Thread management
66
+ @retry_thread = nil
67
+ @mutex = Mutex.new
68
+
69
+ @logger.info("BackgroundRetryManager initialized")
70
+ end
71
+
72
+ # Start the background retry loop
73
+ #
74
+ # This method:
75
+ # 1. Checks if retry is already active (prevents duplicate retries)
76
+ # 2. Initializes retry state (attempt counter, cap delay)
77
+ # 3. Schedules the first retry attempt
78
+ #
79
+ # @param reason [String] The reason for starting retry (for logging)
80
+ # @return [Boolean] true if started, false if already active
81
+ def start(reason:)
82
+ # Thread-safe check and initialization
83
+ should_start = false
84
+
85
+ @mutex.synchronize do
86
+ if @active
87
+ @logger.info("⚠️ Background retry already active (attempt ##{@attempt}). Reason: #{reason}")
88
+ return false
89
+ end
90
+
91
+ # Initialize retry state
92
+ @active = true
93
+ @attempt = 0
94
+ @cap_ms = compute_cap_delay_ms
95
+ should_start = true
96
+ end
97
+
98
+ if should_start
99
+ cap_hours = (@cap_ms / 3_600_000.0).round(2)
100
+ @logger.info("🔄 Starting background retry (cap #{cap_hours} hours). Reason: #{reason}")
101
+ schedule_next_attempt(reason)
102
+ end
103
+
104
+ true
105
+ end
106
+
107
+ # Stop the background retry loop
108
+ #
109
+ # This method:
110
+ # 1. Sets active flag to false (stops scheduling new attempts)
111
+ # 2. Kills the current retry thread if running
112
+ # 3. Resets all state variables
113
+ #
114
+ # Thread-safe and idempotent (safe to call multiple times)
115
+ def stop
116
+ @mutex.synchronize do
117
+ return unless @active
118
+
119
+ @active = false
120
+ @attempt = 0
121
+ @cap_ms = nil
122
+ end
123
+
124
+ # Kill retry thread outside mutex to avoid deadlock
125
+ if @retry_thread&.alive?
126
+ @retry_thread.kill
127
+ @retry_thread = nil
128
+ end
129
+
130
+ @logger.info("✓ Background retry stopped")
131
+ end
132
+
133
+ # Check if retry is currently active
134
+ #
135
+ # @return [Boolean] true if retry loop is running
136
+ def active?
137
+ @mutex.synchronize { @active }
138
+ end
139
+
140
+ # Get current attempt number
141
+ #
142
+ # @return [Integer] Current retry attempt (0-based)
143
+ def current_attempt
144
+ @mutex.synchronize { @attempt }
145
+ end
146
+
147
+ private
148
+
149
+ # Schedule the next retry attempt
150
+ #
151
+ # This is the core of the retry mechanism:
152
+ # 1. Calculates delay using exponential backoff (0 for first attempt)
153
+ # 2. Logs the schedule
154
+ # 3. Spawns a thread that:
155
+ # - Sleeps for the calculated delay (0 for first attempt)
156
+ # - Executes the retry (calls fetch_from_api)
157
+ # - Decides next action based on result
158
+ #
159
+ # @param reason [String] Reason for this retry attempt
160
+ def schedule_next_attempt(reason)
161
+ # First attempt (attempt 0) happens immediately, subsequent attempts use exponential backoff
162
+ delay_ms = @attempt.zero? ? 0 : compute_next_delay_ms(@attempt, @cap_ms)
163
+ delay_sec = delay_ms / 1000.0
164
+ delay_min = (delay_ms / 60_000.0).round(2)
165
+
166
+ if @attempt.zero?
167
+ @logger.warning("⏰ #{reason} - Starting first retry attempt immediately")
168
+ else
169
+ @logger.warning("⏰ #{reason} - Retry scheduled in #{delay_min} minutes (attempt ##{@attempt + 1})")
170
+ end
171
+
172
+ # Spawn new thread for this retry attempt
173
+ @retry_thread = Thread.new do
174
+ # Sleep for the calculated delay
175
+ # First attempt: 0 seconds (immediate)
176
+ # Subsequent attempts: exponential backoff
177
+ sleep(delay_sec)
178
+
179
+ # Check if still active after sleep (might have been stopped)
180
+ next unless @active
181
+
182
+ @logger.info("🔄 Executing retry attempt ##{@attempt + 1}")
183
+
184
+ # Execute the retry by calling ConfigFetcher
185
+ result = @config_fetcher.fetch
186
+
187
+ # Decision tree based on result
188
+ if result[:ok]
189
+ # ✅ SUCCESS: Configuration fetched successfully
190
+ @logger.info("=" * 80)
191
+ @logger.info("✅ SUCCESS: Configurations fetched successfully!")
192
+ @logger.info("=" * 80)
193
+ @config_fetcher.display_response(result[:data])
194
+ @config_fetcher.process_and_load_configurations(result[:data])
195
+ stop
196
+ next
197
+ end
198
+
199
+ unless result[:retryable]
200
+ # ❌ NON-RETRYABLE ERROR: Client error (4xx except 429)
201
+ # Stop retrying - user needs to fix the issue
202
+ @logger.error("❌ Non-retryable error (#{result[:status]}) - stopping retry")
203
+ stop
204
+ next
205
+ end
206
+
207
+ # 🔄 RETRYABLE ERROR: 429 or 5xx
208
+ # Increment attempt counter and schedule next retry
209
+ @mutex.synchronize do
210
+ @attempt += 1
211
+ end
212
+
213
+ next_reason = "Failed to fetch configurations. Status: #{result[:status]}"
214
+ @logger.warning("⚠️ Retry attempt ##{@attempt} failed - scheduling next attempt")
215
+
216
+ # Recursive call to schedule next attempt with increased delay
217
+ schedule_next_attempt(next_reason)
218
+ rescue StandardError => e
219
+ # Handle any unexpected errors in the retry thread
220
+ @logger.error("❌ Error in retry thread: #{e.message}")
221
+ @logger.error(e.backtrace.join("\n"))
222
+
223
+ # Try to schedule next attempt if still active
224
+ if @active
225
+ @mutex.synchronize { @attempt += 1 }
226
+ schedule_next_attempt("Exception in retry: #{e.message}")
227
+ end
228
+ end
229
+ end
230
+
231
+ # Compute base delay with jitter
232
+ #
233
+ # Base delay: 2 minutes (120,000 ms)
234
+ # Jitter: 0-54 seconds (0-54,000 ms)
235
+ # Total: 2.0 - 2.9 minutes
236
+ #
237
+ # Jitter prevents synchronized retries across multiple clients
238
+ # (thundering herd problem)
239
+ #
240
+ # @return [Integer] Base delay in milliseconds
241
+ def compute_base_delay_ms
242
+ base_ms = 2 * 60 * 1000 # 2 minutes = 120,000 ms
243
+ jitter_ms = (0.9 * 60 * 1000 * rand).floor # 0-54 seconds
244
+ base_ms + jitter_ms
245
+ end
246
+
247
+ # Compute cap delay with jitter
248
+ #
249
+ # Cap: 1 hour (3,600,000 ms)
250
+ # Jitter: 0-59 seconds (0-59,000 ms)
251
+ # Total: 60:00 - 60:59 minutes
252
+ #
253
+ # This is the maximum delay between retries
254
+ #
255
+ # @return [Integer] Cap delay in milliseconds
256
+ def compute_cap_delay_ms
257
+ base_ms = 60 * 60 * 1000 # 1 hour = 3,600,000 ms
258
+ jitter_seconds = rand(60) # 0-59 seconds
259
+ base_ms + (jitter_seconds * 1000)
260
+ end
261
+
262
+ # Compute next delay using exponential backoff
263
+ #
264
+ # Formula: min(base_delay * 2^attempt, cap_delay)
265
+ #
266
+ # Example progression:
267
+ # Attempt 0: 2.3 min * 2^0 = 2.3 min
268
+ # Attempt 1: 2.3 min * 2^1 = 4.6 min
269
+ # Attempt 2: 2.3 min * 2^2 = 9.2 min
270
+ # Attempt 3: 2.3 min * 2^3 = 18.4 min
271
+ # Attempt 4: 2.3 min * 2^4 = 36.8 min
272
+ # Attempt 5: 2.3 min * 2^5 = 73.6 min → capped at 60 min
273
+ #
274
+ # @param attempt [Integer] Current attempt number (0-based)
275
+ # @param cap_ms [Integer] Maximum delay in milliseconds
276
+ # @return [Integer] Calculated delay in milliseconds
277
+ def compute_next_delay_ms(attempt, cap_ms)
278
+ # Calculate exponential delay
279
+ exp_ms = compute_base_delay_ms * (2**attempt)
280
+
281
+ # Cap at maximum delay
282
+ [exp_ms, cap_ms].min
283
+ end
284
+ end