jetstream_bridge 2.9.0 → 3.0.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +164 -0
  3. data/LICENSE +21 -0
  4. data/README.md +379 -0
  5. data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +65 -0
  6. data/lib/generators/jetstream_bridge/health_check/templates/health_controller.rb +38 -0
  7. data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +61 -13
  8. data/lib/generators/jetstream_bridge/install/install_generator.rb +4 -2
  9. data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +1 -0
  10. data/lib/jetstream_bridge/consumer/consumer.rb +50 -9
  11. data/lib/jetstream_bridge/consumer/dlq_publisher.rb +4 -1
  12. data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +8 -2
  13. data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +37 -61
  14. data/lib/jetstream_bridge/consumer/message_processor.rb +105 -33
  15. data/lib/jetstream_bridge/consumer/subscription_manager.rb +13 -2
  16. data/lib/jetstream_bridge/core/config.rb +37 -1
  17. data/lib/jetstream_bridge/core/connection.rb +80 -3
  18. data/lib/jetstream_bridge/core/connection_factory.rb +102 -0
  19. data/lib/jetstream_bridge/core/debug_helper.rb +107 -0
  20. data/lib/jetstream_bridge/core/duration.rb +8 -1
  21. data/lib/jetstream_bridge/core/logging.rb +20 -7
  22. data/lib/jetstream_bridge/core/model_utils.rb +4 -3
  23. data/lib/jetstream_bridge/core/retry_strategy.rb +135 -0
  24. data/lib/jetstream_bridge/errors.rb +39 -0
  25. data/lib/jetstream_bridge/inbox_event.rb +4 -4
  26. data/lib/jetstream_bridge/models/event_envelope.rb +133 -0
  27. data/lib/jetstream_bridge/models/subject.rb +94 -0
  28. data/lib/jetstream_bridge/outbox_event.rb +3 -1
  29. data/lib/jetstream_bridge/publisher/outbox_repository.rb +47 -28
  30. data/lib/jetstream_bridge/publisher/publisher.rb +12 -35
  31. data/lib/jetstream_bridge/railtie.rb +35 -1
  32. data/lib/jetstream_bridge/tasks/install.rake +99 -0
  33. data/lib/jetstream_bridge/topology/overlap_guard.rb +15 -1
  34. data/lib/jetstream_bridge/topology/stream.rb +16 -8
  35. data/lib/jetstream_bridge/topology/subject_matcher.rb +17 -7
  36. data/lib/jetstream_bridge/topology/topology.rb +1 -1
  37. data/lib/jetstream_bridge/version.rb +1 -1
  38. data/lib/jetstream_bridge.rb +63 -6
  39. metadata +51 -10
  40. data/lib/jetstream_bridge/consumer/backoff_strategy.rb +0 -24
  41. data/lib/jetstream_bridge/consumer/consumer_config.rb +0 -26
  42. data/lib/jetstream_bridge/consumer/message_context.rb +0 -22
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../errors'
4
+
3
5
  module JetstreamBridge
4
6
  class Config
5
7
  attr_accessor :destination_app, :nats_urls, :env, :app_name,
6
8
  :max_deliver, :ack_wait, :backoff,
7
9
  :use_outbox, :use_inbox, :inbox_model, :outbox_model,
8
- :use_dlq
10
+ :use_dlq, :logger
9
11
 
10
12
  def initialize
11
13
  @nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
@@ -22,6 +24,7 @@ module JetstreamBridge
22
24
  @use_dlq = true
23
25
  @outbox_model = 'JetstreamBridge::OutboxEvent'
24
26
  @inbox_model = 'JetstreamBridge::InboxEvent'
27
+ @logger = nil
25
28
  end
26
29
 
27
30
  # Single stream name per env
@@ -33,20 +36,53 @@ module JetstreamBridge
33
36
  # Producer publishes to: {env}.{app}.sync.{dest}
34
37
  # Consumer subscribes to: {env}.{dest}.sync.{app}
35
38
  def source_subject
39
+ validate_subject_component!(env, 'env')
40
+ validate_subject_component!(app_name, 'app_name')
41
+ validate_subject_component!(destination_app, 'destination_app')
36
42
  "#{env}.#{app_name}.sync.#{destination_app}"
37
43
  end
38
44
 
39
45
  def destination_subject
46
+ validate_subject_component!(env, 'env')
47
+ validate_subject_component!(app_name, 'app_name')
48
+ validate_subject_component!(destination_app, 'destination_app')
40
49
  "#{env}.#{destination_app}.sync.#{app_name}"
41
50
  end
42
51
 
43
52
  # DLQ
44
53
  def dlq_subject
54
+ validate_subject_component!(env, 'env')
45
55
  "#{env}.sync.dlq"
46
56
  end
47
57
 
48
58
  def durable_name
49
59
  "#{env}-#{app_name}-workers"
50
60
  end
61
+
62
+ # Validate configuration settings
63
+ def validate!
64
+ errors = []
65
+ errors << 'destination_app is required' if destination_app.to_s.strip.empty?
66
+ errors << 'nats_urls is required' if nats_urls.to_s.strip.empty?
67
+ errors << 'env is required' if env.to_s.strip.empty?
68
+ errors << 'app_name is required' if app_name.to_s.strip.empty?
69
+ errors << 'max_deliver must be >= 1' if max_deliver.to_i < 1
70
+ errors << 'backoff must be an array' unless backoff.is_a?(Array)
71
+ errors << 'backoff must not be empty' if backoff.is_a?(Array) && backoff.empty?
72
+
73
+ raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
74
+
75
+ true
76
+ end
77
+
78
+ private
79
+
80
+ def validate_subject_component!(value, name)
81
+ str = value.to_s
82
+ if str.match?(/[.*>]/)
83
+ raise InvalidSubjectError, "#{name} cannot contain NATS wildcards (., *, >): #{value.inspect}"
84
+ end
85
+ raise MissingConfigurationError, "#{name} cannot be empty" if str.strip.empty?
86
+ end
51
87
  end
52
88
  end
@@ -9,7 +9,21 @@ require_relative 'config'
9
9
  require_relative '../topology/topology'
10
10
 
11
11
  module JetstreamBridge
12
- # Singleton connection to NATS.
12
+ # Singleton connection to NATS with thread-safe initialization.
13
+ #
14
+ # This class manages a single NATS connection for the entire application,
15
+ # ensuring thread-safe access in multi-threaded environments like Rails
16
+ # with Puma or Sidekiq.
17
+ #
18
+ # Thread Safety:
19
+ # - Connection initialization is synchronized with a mutex
20
+ # - The singleton pattern ensures only one connection instance exists
21
+ # - Safe to call from multiple threads/workers simultaneously
22
+ #
23
+ # Example:
24
+ # # Safe from any thread
25
+ # jts = JetstreamBridge::Connection.connect!
26
+ # jts.publish(...)
13
27
  class Connection
14
28
  include Singleton
15
29
 
@@ -23,6 +37,10 @@ module JetstreamBridge
23
37
  class << self
24
38
  # Thread-safe delegator to the singleton instance.
25
39
  # Returns a live JetStream context.
40
+ #
41
+ # Safe to call from multiple threads - uses mutex for synchronization.
42
+ #
43
+ # @return [NATS::JetStream::JS] JetStream context
26
44
  def connect!
27
45
  @__mutex ||= Mutex.new
28
46
  @__mutex.synchronize { instance.connect! }
@@ -56,13 +74,34 @@ module JetstreamBridge
56
74
  # Ensure topology (streams, subjects, overlap guard, etc.)
57
75
  Topology.ensure!(@jts)
58
76
 
77
+ @connected_at = Time.now.utc
59
78
  @jts
60
79
  end
61
80
 
81
+ # Public API for checking connection status
82
+ # @return [Boolean] true if NATS client is connected and JetStream is healthy
83
+ def connected?
84
+ @nc&.connected? && @jts && jetstream_healthy?
85
+ end
86
+
87
+ # Public API for getting connection timestamp
88
+ # @return [Time, nil] timestamp when connection was established
89
+ def connected_at
90
+ @connected_at
91
+ end
92
+
62
93
  private
63
94
 
64
- def connected?
65
- @nc&.connected?
95
+ def jetstream_healthy?
96
+ # Verify JetStream responds to simple API call
97
+ @jts.account_info
98
+ true
99
+ rescue StandardError => e
100
+ Logging.warn(
101
+ "JetStream health check failed: #{e.class} #{e.message}",
102
+ tag: 'JetstreamBridge::Connection'
103
+ )
104
+ false
66
105
  end
67
106
 
68
107
  def nats_servers
@@ -75,6 +114,30 @@ module JetstreamBridge
75
114
 
76
115
  def establish_connection(servers)
77
116
  @nc = NATS::IO::Client.new
117
+
118
+ # Setup reconnect handler to refresh JetStream context
119
+ @nc.on_reconnect do
120
+ Logging.info(
121
+ 'NATS reconnected, refreshing JetStream context',
122
+ tag: 'JetstreamBridge::Connection'
123
+ )
124
+ refresh_jetstream_context
125
+ end
126
+
127
+ @nc.on_disconnect do |reason|
128
+ Logging.warn(
129
+ "NATS disconnected: #{reason}",
130
+ tag: 'JetstreamBridge::Connection'
131
+ )
132
+ end
133
+
134
+ @nc.on_error do |err|
135
+ Logging.error(
136
+ "NATS error: #{err}",
137
+ tag: 'JetstreamBridge::Connection'
138
+ )
139
+ end
140
+
78
141
  @nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS))
79
142
 
80
143
  # Create JetStream context
@@ -87,6 +150,20 @@ module JetstreamBridge
87
150
  @jts.define_singleton_method(:nc) { nc_ref }
88
151
  end
89
152
 
153
+ def refresh_jetstream_context
154
+ @jts = @nc.jetstream
155
+ nc_ref = @nc
156
+ @jts.define_singleton_method(:nc) { nc_ref } unless @jts.respond_to?(:nc)
157
+
158
+ # Re-ensure topology after reconnect
159
+ Topology.ensure!(@jts)
160
+ rescue StandardError => e
161
+ Logging.error(
162
+ "Failed to refresh JetStream context: #{e.class} #{e.message}",
163
+ tag: 'JetstreamBridge::Connection'
164
+ )
165
+ end
166
+
90
167
  # Expose for class-level helpers (not part of public API)
91
168
  attr_reader :nc
92
169
 
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'connection'
4
+ require_relative '../errors'
5
+
6
+ module JetstreamBridge
7
+ module Core
8
+ # Factory for creating and managing NATS connections
9
+ class ConnectionFactory
10
+ # Connection options builder
11
+ class ConnectionOptions
12
+ DEFAULT_OPTS = {
13
+ reconnect: true,
14
+ reconnect_time_wait: 2,
15
+ max_reconnect_attempts: 10,
16
+ connect_timeout: 5
17
+ }.freeze
18
+
19
+ attr_accessor :servers, :reconnect, :reconnect_time_wait,
20
+ :max_reconnect_attempts, :connect_timeout,
21
+ :name, :user, :pass, :token
22
+ attr_reader :additional_opts
23
+
24
+ def initialize(servers: nil, **opts)
25
+ @servers = normalize_servers(servers) if servers
26
+ @additional_opts = {}
27
+
28
+ DEFAULT_OPTS.merge(opts).each do |key, value|
29
+ if respond_to?(:"#{key}=")
30
+ send(:"#{key}=", value)
31
+ else
32
+ @additional_opts[key] = value
33
+ end
34
+ end
35
+ end
36
+
37
+ def self.build(opts = {})
38
+ new(**opts)
39
+ end
40
+
41
+ def to_h
42
+ base = {
43
+ reconnect: @reconnect,
44
+ reconnect_time_wait: @reconnect_time_wait,
45
+ max_reconnect_attempts: @max_reconnect_attempts,
46
+ connect_timeout: @connect_timeout
47
+ }
48
+
49
+ base[:servers] = @servers if @servers
50
+ base[:name] = @name if @name
51
+ base[:user] = @user if @user
52
+ base[:pass] = @pass if @pass
53
+ base[:token] = @token if @token
54
+
55
+ base.merge(@additional_opts)
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_servers(servers)
61
+ Array(servers)
62
+ .flat_map { |s| s.to_s.split(',') }
63
+ .map(&:strip)
64
+ .reject(&:empty?)
65
+ end
66
+ end
67
+
68
+ class << self
69
+ # Create connection options from config
70
+ def build_options(config = JetstreamBridge.config)
71
+ servers = config.nats_urls
72
+ raise ConnectionNotEstablishedError, 'No NATS URLs configured' if servers.to_s.strip.empty?
73
+
74
+ ConnectionOptions.new(
75
+ servers: servers,
76
+ name: "#{config.app_name}-#{config.env}"
77
+ )
78
+ end
79
+
80
+ # Create a new NATS client
81
+ def create_client(options = nil)
82
+ opts = options || build_options
83
+ client = NATS::IO::Client.new
84
+ client.connect(opts.to_h)
85
+ client
86
+ end
87
+
88
+ # Create JetStream context with health monitoring
89
+ def create_jetstream(client)
90
+ jts = client.jetstream
91
+
92
+ # Ensure JetStream responds to #nc for compatibility
93
+ unless jts.respond_to?(:nc)
94
+ jts.define_singleton_method(:nc) { client }
95
+ end
96
+
97
+ jts
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+
5
+ module JetstreamBridge
6
+ # Debug helper for troubleshooting JetStream Bridge operations
7
+ module DebugHelper
8
+ class << self
9
+ # Print comprehensive debug information about the current setup
10
+ def debug_info
11
+ info = {
12
+ config: config_debug,
13
+ connection: connection_debug,
14
+ stream: stream_debug,
15
+ health: JetstreamBridge.health_check
16
+ }
17
+
18
+ Logging.info("=== JetStream Bridge Debug Info ===", tag: 'JetstreamBridge::Debug')
19
+ info.each do |section, data|
20
+ Logging.info("#{section.to_s.upcase}:", tag: 'JetstreamBridge::Debug')
21
+ log_hash(data, indent: 2)
22
+ end
23
+ Logging.info("=== End Debug Info ===", tag: 'JetstreamBridge::Debug')
24
+
25
+ info
26
+ end
27
+
28
+ private
29
+
30
+ def config_debug
31
+ cfg = JetstreamBridge.config
32
+ {
33
+ env: cfg.env,
34
+ app_name: cfg.app_name,
35
+ destination_app: cfg.destination_app,
36
+ stream_name: cfg.stream_name,
37
+ source_subject: (cfg.source_subject rescue 'ERROR'),
38
+ destination_subject: (cfg.destination_subject rescue 'ERROR'),
39
+ dlq_subject: (cfg.dlq_subject rescue 'ERROR'),
40
+ durable_name: cfg.durable_name,
41
+ nats_urls: cfg.nats_urls,
42
+ max_deliver: cfg.max_deliver,
43
+ ack_wait: cfg.ack_wait,
44
+ backoff: cfg.backoff,
45
+ use_outbox: cfg.use_outbox,
46
+ use_inbox: cfg.use_inbox,
47
+ use_dlq: cfg.use_dlq,
48
+ outbox_model: cfg.outbox_model,
49
+ inbox_model: cfg.inbox_model
50
+ }
51
+ end
52
+
53
+ def connection_debug
54
+ conn = Connection.instance
55
+ {
56
+ connected: conn.connected?,
57
+ connected_at: conn.connected_at&.iso8601,
58
+ nc_present: !conn.instance_variable_get(:@nc).nil?,
59
+ jts_present: !conn.instance_variable_get(:@jts).nil?
60
+ }
61
+ rescue StandardError => e
62
+ { error: "#{e.class}: #{e.message}" }
63
+ end
64
+
65
+ def stream_debug
66
+ return { error: 'Not connected' } unless Connection.instance.connected?
67
+
68
+ jts = Connection.jetstream
69
+ cfg = JetstreamBridge.config
70
+ info = jts.stream_info(cfg.stream_name)
71
+
72
+ {
73
+ name: cfg.stream_name,
74
+ exists: true,
75
+ subjects: info.config.subjects,
76
+ retention: info.config.retention,
77
+ storage: info.config.storage,
78
+ max_consumers: info.config.max_consumers,
79
+ messages: info.state.messages,
80
+ bytes: info.state.bytes,
81
+ first_seq: info.state.first_seq,
82
+ last_seq: info.state.last_seq
83
+ }
84
+ rescue StandardError => e
85
+ {
86
+ name: JetstreamBridge.config.stream_name,
87
+ exists: false,
88
+ error: "#{e.class}: #{e.message}"
89
+ }
90
+ end
91
+
92
+ def log_hash(hash, indent: 0)
93
+ prefix = ' ' * indent
94
+ hash.each do |key, value|
95
+ if value.is_a?(Hash)
96
+ Logging.info("#{prefix}#{key}:", tag: 'JetstreamBridge::Debug')
97
+ log_hash(value, indent: indent + 2)
98
+ elsif value.is_a?(Array)
99
+ Logging.info("#{prefix}#{key}: #{value.inspect}", tag: 'JetstreamBridge::Debug')
100
+ else
101
+ Logging.info("#{prefix}#{key}: #{value}", tag: 'JetstreamBridge::Debug')
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -71,7 +71,14 @@ module JetstreamBridge
71
71
  def int_to_ms(num, default_unit:)
72
72
  case default_unit
73
73
  when :auto
74
- # Preserve existing heuristic for compatibility
74
+ # Preserve existing heuristic for compatibility but log deprecation warning
75
+ if defined?(Logging) && num > 0 && num < 1_000
76
+ Logging.debug(
77
+ "Duration :auto heuristic treating #{num} as seconds. " \
78
+ "Consider specifying default_unit: :s or :ms for clarity.",
79
+ tag: 'JetstreamBridge::Duration'
80
+ )
81
+ end
75
82
  num >= 1_000 ? num : num * 1_000
76
83
  else
77
84
  coerce_numeric_to_ms(num.to_f, default_unit)
@@ -1,20 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'uri'
4
+ require 'logger'
4
5
 
5
6
  module JetstreamBridge
6
- # Logging helpers that route to Rails.logger when available,
7
- # falling back to STDOUT.
7
+ # Logging helpers that route to the configured logger when available,
8
+ # falling back to Rails.logger or STDOUT.
8
9
  module Logging
9
10
  module_function
10
11
 
12
+ def logger
13
+ JetstreamBridge.config.logger ||
14
+ (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) ||
15
+ default_logger
16
+ end
17
+
18
+ def default_logger
19
+ @default_logger ||= Logger.new($stdout)
20
+ end
21
+
11
22
  def log(level, msg, tag: nil)
12
23
  message = tag ? "[#{tag}] #{msg}" : msg
13
- if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
14
- Rails.logger.public_send(level, message)
15
- else
16
- puts "[#{level.to_s.upcase}] #{message}"
17
- end
24
+ logger.public_send(level, message)
25
+ end
26
+
27
+ def debug(msg, tag: nil)
28
+ log(:debug, msg, tag: tag)
18
29
  end
19
30
 
20
31
  def info(msg, tag: nil)
@@ -29,6 +40,7 @@ module JetstreamBridge
29
40
  log(:error, msg, tag: tag)
30
41
  end
31
42
 
43
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
32
44
  def sanitize_url(url)
33
45
  uri = URI.parse(url)
34
46
  return url unless uri.user || uri.password
@@ -55,5 +67,6 @@ module JetstreamBridge
55
67
  "#{scheme}://#{masked}@"
56
68
  end
57
69
  end
70
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
58
71
  end
59
72
  end
@@ -14,10 +14,13 @@ module JetstreamBridge
14
14
  defined?(ActiveRecord::Base) && klass <= ActiveRecord::Base
15
15
  end
16
16
 
17
+ # rubocop:disable Naming/PredicatePrefix
17
18
  def has_columns?(klass, *cols)
18
19
  return false unless ar_class?(klass)
20
+
19
21
  cols.flatten.all? { |c| klass.column_names.include?(c.to_s) }
20
22
  end
23
+ # rubocop:enable Naming/PredicatePrefix
21
24
 
22
25
  def assign_known_attrs(record, attrs)
23
26
  attrs.each do |k, v|
@@ -30,9 +33,7 @@ module JetstreamBridge
30
33
  def find_or_init_by_best(klass, *keysets)
31
34
  keysets.each do |keys|
32
35
  next if keys.nil? || keys.empty?
33
- if has_columns?(klass, keys.keys)
34
- return klass.find_or_initialize_by(keys)
35
- end
36
+ return klass.find_or_initialize_by(keys) if has_columns?(klass, keys.keys)
36
37
  end
37
38
  klass.new
38
39
  end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'logging'
4
+
5
+ begin
6
+ require 'nats/io/client'
7
+ rescue LoadError
8
+ # NATS not available, PublisherRetryStrategy won't work but that's ok for tests
9
+ end
10
+
11
+ module JetstreamBridge
12
+ # Base retry strategy interface
13
+ class RetryStrategy
14
+ class RetryExhausted < StandardError; end
15
+
16
+ def initialize(max_attempts:, backoffs: [], transient_errors: [])
17
+ @max_attempts = max_attempts
18
+ @backoffs = backoffs
19
+ @transient_errors = transient_errors
20
+ end
21
+
22
+ # Execute block with retry logic
23
+ # @yield Block to execute with retry
24
+ # @return Result of the block
25
+ # @raise RetryExhausted if all attempts fail
26
+ def execute(context: nil)
27
+ attempts = 0
28
+ last_error = nil
29
+
30
+ loop do
31
+ attempts += 1
32
+ begin
33
+ return yield
34
+ rescue *retryable_errors => e
35
+ last_error = e
36
+ raise e if attempts >= @max_attempts
37
+
38
+ delay = calculate_delay(attempts, e)
39
+ log_retry(attempts, e, delay, context)
40
+ sleep delay
41
+ end
42
+ end
43
+ rescue => e
44
+ raise unless retryable?(e)
45
+ raise RetryExhausted, "Failed after #{attempts} attempts: #{e.message}"
46
+ end
47
+
48
+ protected
49
+
50
+ def calculate_delay(attempt, _error)
51
+ @backoffs[attempt - 1] || @backoffs.last || 1.0
52
+ end
53
+
54
+ def retryable_errors
55
+ @transient_errors.empty? ? [StandardError] : @transient_errors
56
+ end
57
+
58
+ def retryable?(error)
59
+ retryable_errors.any? { |klass| error.is_a?(klass) }
60
+ end
61
+
62
+ def log_retry(attempt, error, delay, context)
63
+ ctx_info = context ? " [#{context}]" : ''
64
+ Logging.warn(
65
+ "Retry #{attempt}/#{@max_attempts}#{ctx_info}: #{error.class} - #{error.message}, " \
66
+ "waiting #{delay}s",
67
+ tag: 'JetstreamBridge::RetryStrategy'
68
+ )
69
+ end
70
+ end
71
+
72
+ # Exponential backoff retry strategy
73
+ class ExponentialBackoffStrategy < RetryStrategy
74
+ def initialize(max_attempts: 3, base_delay: 0.1, max_delay: 60, multiplier: 2, transient_errors: [], jitter: true)
75
+ @base_delay = base_delay
76
+ @max_delay = max_delay
77
+ @multiplier = multiplier
78
+ @jitter = jitter
79
+ backoffs = compute_backoffs(max_attempts)
80
+ super(max_attempts: max_attempts, backoffs: backoffs, transient_errors: transient_errors)
81
+ end
82
+
83
+ protected
84
+
85
+ def calculate_delay(attempt, _error)
86
+ @backoffs[attempt - 1] || @backoffs.last || @base_delay
87
+ end
88
+
89
+ private
90
+
91
+ def compute_backoffs(max_attempts)
92
+ (0...max_attempts).map do |i|
93
+ delay = @base_delay * (@multiplier**i)
94
+ if @jitter
95
+ # Add up to 10% jitter, but ensure we don't exceed max_delay
96
+ jitter_amount = delay * 0.1 * rand
97
+ delay = [delay + jitter_amount, @max_delay].min
98
+ else
99
+ delay = [delay, @max_delay].min
100
+ end
101
+ delay
102
+ end
103
+ end
104
+ end
105
+
106
+ # Linear backoff retry strategy
107
+ class LinearBackoffStrategy < RetryStrategy
108
+ def initialize(max_attempts: 3, delays: [0.25, 1.0, 2.0], transient_errors: [])
109
+ super(max_attempts: max_attempts, backoffs: delays, transient_errors: transient_errors)
110
+ end
111
+ end
112
+
113
+ # Publisher-specific retry strategy
114
+ class PublisherRetryStrategy < LinearBackoffStrategy
115
+ TRANSIENT_ERRORS = begin
116
+ errs = []
117
+ if defined?(NATS::IO)
118
+ errs << NATS::IO::Timeout if defined?(NATS::IO::Timeout)
119
+ errs << NATS::IO::Error if defined?(NATS::IO::Error)
120
+ errs << NATS::IO::NoServersError if defined?(NATS::IO::NoServersError)
121
+ errs << NATS::IO::SocketTimeoutError if defined?(NATS::IO::SocketTimeoutError)
122
+ errs << Errno::ECONNREFUSED
123
+ end
124
+ errs
125
+ end.freeze
126
+
127
+ def initialize(max_attempts: 2)
128
+ super(
129
+ max_attempts: max_attempts,
130
+ delays: [0.25, 1.0],
131
+ transient_errors: TRANSIENT_ERRORS
132
+ )
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JetstreamBridge
4
+ # Base error for all JetStream Bridge errors
5
+ class Error < StandardError; end
6
+
7
+ # Configuration errors
8
+ class ConfigurationError < Error; end
9
+ class InvalidSubjectError < ConfigurationError; end
10
+ class MissingConfigurationError < ConfigurationError; end
11
+
12
+ # Connection errors
13
+ class ConnectionError < Error; end
14
+ class ConnectionNotEstablishedError < ConnectionError; end
15
+ class HealthCheckFailedError < ConnectionError; end
16
+
17
+ # Publisher errors
18
+ class PublishError < Error; end
19
+ class PublishFailedError < PublishError; end
20
+ class OutboxError < PublishError; end
21
+
22
+ # Consumer errors
23
+ class ConsumerError < Error; end
24
+ class HandlerError < ConsumerError; end
25
+ class InboxError < ConsumerError; end
26
+
27
+ # Topology errors
28
+ class TopologyError < Error; end
29
+ class StreamNotFoundError < TopologyError; end
30
+ class SubjectOverlapError < TopologyError; end
31
+ class StreamCreationFailedError < TopologyError; end
32
+
33
+ # DLQ errors
34
+ class DlqError < Error; end
35
+ class DlqPublishFailedError < DlqError; end
36
+
37
+ # Retry errors (already defined in retry_strategy.rb)
38
+ # class RetryExhausted < Error; end
39
+ end