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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +76 -0
- data/CONTRIBUTING.md +9 -0
- data/LICENSE +201 -0
- data/README.md +474 -0
- data/Rakefile +8 -0
- data/examples/README.md +60 -0
- data/examples/app.rb +104 -0
- data/lib/ibm_appconfiguration_ruby_sdk/app_configuration.rb +291 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/configuration_handler.rb +828 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/constants.rb +89 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/file_manager.rb +72 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/logger.rb +98 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/background_retry_manager.rb +284 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/config_fetcher.rb +254 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/utils.rb +240 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connection_manager.rb +501 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connectivity.rb +30 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/driver_socket.rb +28 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/retry_policy.rb +42 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/state.rb +24 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/watchdog.rb +50 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/websocket_client.rb +43 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/feature.rb +121 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/property.rb +107 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/rule.rb +87 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/secret_property.rb +81 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment.rb +39 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment_rules.rb +57 -0
- data/lib/ibm_appconfiguration_ruby_sdk/core/api_manager.rb +269 -0
- data/lib/ibm_appconfiguration_ruby_sdk/core/metering.rb +400 -0
- data/lib/ibm_appconfiguration_ruby_sdk/core/url_builder.rb +252 -0
- data/lib/ibm_appconfiguration_ruby_sdk/version.rb +20 -0
- data/lib/ibm_appconfiguration_ruby_sdk.rb +20 -0
- data/sig/ibm_appconfiguration_ruby_sdk.rbs +4 -0
- 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
|