nats_pubsub 1.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 (87) hide show
  1. checksums.yaml +7 -0
  2. data/exe/nats_pubsub +44 -0
  3. data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
  4. data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
  5. data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
  6. data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
  7. data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
  8. data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
  9. data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
  10. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
  11. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
  12. data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
  13. data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
  14. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
  15. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
  16. data/lib/nats_pubsub/active_record/publishable.rb +192 -0
  17. data/lib/nats_pubsub/cli.rb +105 -0
  18. data/lib/nats_pubsub/core/base_repository.rb +73 -0
  19. data/lib/nats_pubsub/core/config.rb +152 -0
  20. data/lib/nats_pubsub/core/config_presets.rb +139 -0
  21. data/lib/nats_pubsub/core/connection.rb +103 -0
  22. data/lib/nats_pubsub/core/constants.rb +190 -0
  23. data/lib/nats_pubsub/core/duration.rb +113 -0
  24. data/lib/nats_pubsub/core/error_action.rb +288 -0
  25. data/lib/nats_pubsub/core/event.rb +275 -0
  26. data/lib/nats_pubsub/core/health_check.rb +470 -0
  27. data/lib/nats_pubsub/core/logging.rb +72 -0
  28. data/lib/nats_pubsub/core/message_context.rb +193 -0
  29. data/lib/nats_pubsub/core/presets.rb +222 -0
  30. data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
  31. data/lib/nats_pubsub/core/structured_logger.rb +141 -0
  32. data/lib/nats_pubsub/core/subject.rb +185 -0
  33. data/lib/nats_pubsub/instrumentation.rb +327 -0
  34. data/lib/nats_pubsub/middleware/active_record.rb +18 -0
  35. data/lib/nats_pubsub/middleware/chain.rb +92 -0
  36. data/lib/nats_pubsub/middleware/logging.rb +48 -0
  37. data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
  38. data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
  39. data/lib/nats_pubsub/models/event_model.rb +73 -0
  40. data/lib/nats_pubsub/models/inbox_event.rb +109 -0
  41. data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
  42. data/lib/nats_pubsub/models/model_utils.rb +57 -0
  43. data/lib/nats_pubsub/models/outbox_event.rb +113 -0
  44. data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
  45. data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
  46. data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
  47. data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
  48. data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
  49. data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
  50. data/lib/nats_pubsub/publisher/publisher.rb +156 -0
  51. data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
  52. data/lib/nats_pubsub/railtie.rb +52 -0
  53. data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
  54. data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
  55. data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
  56. data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
  57. data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
  58. data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
  59. data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
  60. data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
  61. data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
  62. data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
  63. data/lib/nats_pubsub/subscribers/pool.rb +166 -0
  64. data/lib/nats_pubsub/subscribers/registry.rb +114 -0
  65. data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
  66. data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
  67. data/lib/nats_pubsub/subscribers/worker.rb +152 -0
  68. data/lib/nats_pubsub/tasks/install.rake +10 -0
  69. data/lib/nats_pubsub/testing/helpers.rb +199 -0
  70. data/lib/nats_pubsub/testing/matchers.rb +208 -0
  71. data/lib/nats_pubsub/testing/test_harness.rb +250 -0
  72. data/lib/nats_pubsub/testing.rb +157 -0
  73. data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
  74. data/lib/nats_pubsub/topology/stream.rb +102 -0
  75. data/lib/nats_pubsub/topology/stream_support.rb +170 -0
  76. data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
  77. data/lib/nats_pubsub/topology/topology.rb +24 -0
  78. data/lib/nats_pubsub/version.rb +8 -0
  79. data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
  80. data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
  81. data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
  82. data/lib/nats_pubsub/web/views/layout.erb +68 -0
  83. data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
  84. data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
  85. data/lib/nats_pubsub/web.rb +181 -0
  86. data/lib/nats_pubsub.rb +290 -0
  87. metadata +225 -0
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nats/io/client'
4
+ require 'singleton'
5
+ require 'oj'
6
+ require_relative 'duration'
7
+ require_relative 'logging'
8
+ require_relative 'config'
9
+ require_relative '../topology/topology'
10
+
11
+ module NatsPubsub
12
+ # Singleton connection to NATS.
13
+ class Connection
14
+ include Singleton
15
+
16
+ DEFAULT_CONN_OPTS = {
17
+ reconnect: true,
18
+ reconnect_time_wait: 2,
19
+ max_reconnect_attempts: 10,
20
+ connect_timeout: 5
21
+ }.freeze
22
+
23
+ class << self
24
+ # Thread-safe delegator to the singleton instance.
25
+ # Returns a live JetStream context.
26
+ def connect!
27
+ @__mutex ||= Mutex.new
28
+ @__mutex.synchronize { instance.connect! }
29
+ end
30
+
31
+ # Optional accessors if callers need raw handles
32
+ def nc
33
+ instance.__send__(:nc)
34
+ end
35
+
36
+ def jetstream
37
+ instance.__send__(:jetstream)
38
+ end
39
+ end
40
+
41
+ # Idempotent: returns an existing, healthy JetStream context or establishes one.
42
+ # NOTE: This method only establishes the connection. Topology setup is separate.
43
+ # Call NatsPubsub.ensure_topology! explicitly after connection if needed.
44
+ def connect!
45
+ return @jts if connected?
46
+
47
+ servers = nats_servers
48
+ raise ConfigurationError, 'No NATS URLs configured' if servers.empty?
49
+
50
+ establish_connection(servers)
51
+
52
+ Logging.info(
53
+ "Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
54
+ "#{sanitize_urls(servers).join(', ')}",
55
+ tag: 'NatsPubsub::Connection'
56
+ )
57
+
58
+ @jts
59
+ end
60
+
61
+ private
62
+
63
+ def connected?
64
+ @nc&.connected?
65
+ end
66
+
67
+ def nats_servers
68
+ NatsPubsub.config.nats_urls
69
+ .to_s
70
+ .split(',')
71
+ .map(&:strip)
72
+ .reject(&:empty?)
73
+ end
74
+
75
+ def establish_connection(servers)
76
+ @nc = NATS::IO::Client.new
77
+ @nc.connect({ servers: servers }.merge(DEFAULT_CONN_OPTS))
78
+
79
+ # Create JetStream context
80
+ @jts = @nc.jetstream
81
+
82
+ # --- Compatibility shim: ensure JetStream responds to #nc for older/newer clients ---
83
+ return if @jts.respond_to?(:nc)
84
+
85
+ nc_ref = @nc
86
+ @jts.define_singleton_method(:nc) { nc_ref }
87
+ end
88
+
89
+ # Expose for class-level helpers (not part of public API)
90
+ attr_reader :nc
91
+
92
+ def jetstream
93
+ @jts
94
+ end
95
+
96
+ # Mask credentials in NATS URLs:
97
+ # - "nats://user:pass@host:4222" -> "nats://user:***@host:4222"
98
+ # - "nats://token@host:4222" -> "nats://***@host:4222"
99
+ def sanitize_urls(urls)
100
+ urls.map { |u| Logging.sanitize_url(u) }
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ # Centralized constants for the library
5
+ # Extracts magic numbers and strings into named, documented constants
6
+ module Constants
7
+ # Timeout-related constants
8
+ module Timeouts
9
+ # Default acknowledgment wait time in milliseconds
10
+ # Time before a message is considered unacknowledged and redelivered
11
+ ACK_WAIT_DEFAULT = 30_000 # 30 seconds
12
+
13
+ # Default idle wait time in milliseconds
14
+ # Time to wait when no messages are available before polling again
15
+ IDLE_WAIT_DEFAULT = 100 # 100ms
16
+
17
+ # Default connection timeout in seconds
18
+ CONNECTION_TIMEOUT = 5
19
+
20
+ # Default request timeout for management operations
21
+ MANAGEMENT_TIMEOUT = 10
22
+ end
23
+
24
+ # Retry strategy constants
25
+ module Retry
26
+ # Default exponential backoff delays in milliseconds
27
+ # Used for retrying failed message processing
28
+ DEFAULT_BACKOFF = [1_000, 5_000, 15_000, 30_000, 60_000].freeze
29
+
30
+ # Maximum number of delivery attempts before sending to DLQ
31
+ MAX_ATTEMPTS = 5
32
+
33
+ # Base delay for transient errors (milliseconds)
34
+ TRANSIENT_BASE_DELAY = 500
35
+
36
+ # Base delay for persistent errors (milliseconds)
37
+ PERSISTENT_BASE_DELAY = 2_000
38
+
39
+ # Maximum backoff delay cap (milliseconds)
40
+ MAX_BACKOFF_CAP = 60_000 # 60 seconds
41
+ end
42
+
43
+ # Dead Letter Queue constants
44
+ module DLQ
45
+ # Default retention period for DLQ messages (30 days in nanoseconds)
46
+ RETENTION_PERIOD = 30 * 24 * 60 * 60 * 1_000_000_000
47
+
48
+ # DLQ stream suffix
49
+ STREAM_SUFFIX = '-dlq'
50
+
51
+ # Maximum attempts before moving to DLQ
52
+ MAX_ATTEMPTS = 3
53
+ end
54
+
55
+ # Stream and consumer configuration constants
56
+ module Stream
57
+ # Default stream retention policy
58
+ RETENTION_POLICY = 'limits' # limits, interest, workqueue
59
+
60
+ # Default storage type
61
+ STORAGE_TYPE = 'file' # file or memory
62
+
63
+ # Default max message age (7 days in nanoseconds)
64
+ MAX_AGE = 7 * 24 * 60 * 60 * 1_000_000_000
65
+
66
+ # Default max messages per stream
67
+ MAX_MESSAGES = 1_000_000
68
+
69
+ # Default max bytes per stream (1GB)
70
+ MAX_BYTES = 1_073_741_824
71
+ end
72
+
73
+ # Consumer configuration constants
74
+ module Consumer
75
+ # Default concurrency level
76
+ DEFAULT_CONCURRENCY = 5
77
+
78
+ # Minimum concurrency
79
+ MIN_CONCURRENCY = 1
80
+
81
+ # Maximum concurrency (prevents resource exhaustion)
82
+ MAX_CONCURRENCY = 1000
83
+
84
+ # Default batch size for pull consumers
85
+ BATCH_SIZE = 10
86
+
87
+ # Maximum batch size
88
+ MAX_BATCH_SIZE = 256
89
+ end
90
+
91
+ # Subject pattern constants
92
+ module Subject
93
+ # Wildcard for matching one level
94
+ SINGLE_LEVEL_WILDCARD = '*'
95
+
96
+ # Wildcard for matching multiple levels
97
+ MULTI_LEVEL_WILDCARD = '>'
98
+
99
+ # Subject token separator
100
+ SEPARATOR = '.'
101
+
102
+ # Maximum subject length
103
+ MAX_LENGTH = 255
104
+ end
105
+
106
+ # Envelope schema constants
107
+ module Envelope
108
+ # Current schema version
109
+ SCHEMA_VERSION = 1
110
+
111
+ # Required envelope fields
112
+ REQUIRED_FIELDS = %w[
113
+ event_id
114
+ schema_version
115
+ producer
116
+ occurred_at
117
+ ].freeze
118
+
119
+ # Topic envelope required fields
120
+ TOPIC_REQUIRED_FIELDS = (REQUIRED_FIELDS + %w[topic message]).freeze
121
+
122
+ # Event envelope required fields (legacy)
123
+ EVENT_REQUIRED_FIELDS = (REQUIRED_FIELDS + %w[domain resource action payload]).freeze
124
+ end
125
+
126
+ # Error classification constants
127
+ module Errors
128
+ # Errors that should not be retried
129
+ UNRECOVERABLE_ERRORS = [
130
+ 'ArgumentError',
131
+ 'TypeError',
132
+ 'NoMethodError',
133
+ 'NameError',
134
+ 'SyntaxError'
135
+ ].freeze
136
+
137
+ # Errors indicating malformed messages
138
+ MALFORMED_ERRORS = [
139
+ 'JSON::ParserError',
140
+ 'Oj::ParseError',
141
+ 'EncodingError'
142
+ ].freeze
143
+
144
+ # Transient errors that should be retried
145
+ TRANSIENT_ERRORS = [
146
+ 'Timeout::Error',
147
+ 'IOError',
148
+ 'Errno::ECONNREFUSED',
149
+ 'Errno::ETIMEDOUT',
150
+ 'NATS::IO::Timeout',
151
+ 'NATS::IO::Error'
152
+ ].freeze
153
+ end
154
+
155
+ # Logging constants
156
+ module Logging
157
+ # Default log level
158
+ DEFAULT_LEVEL = :info
159
+
160
+ # Log levels
161
+ LEVELS = %i[debug info warn error fatal].freeze
162
+
163
+ # Structured log field names
164
+ FIELDS = {
165
+ event_id: 'event_id',
166
+ trace_id: 'trace_id',
167
+ subject: 'subject',
168
+ topic: 'topic',
169
+ delivery_count: 'delivery_count',
170
+ elapsed_ms: 'elapsed_ms',
171
+ error_class: 'error_class',
172
+ error_message: 'error_message'
173
+ }.freeze
174
+ end
175
+
176
+ # Health check constants
177
+ module HealthCheck
178
+ # Quick check timeout (seconds)
179
+ QUICK_TIMEOUT = 5
180
+
181
+ # Full check timeout (seconds)
182
+ FULL_TIMEOUT = 30
183
+
184
+ # Health check statuses
185
+ HEALTHY = 'healthy'
186
+ DEGRADED = 'degraded'
187
+ UNHEALTHY = 'unhealthy'
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # NatsPubsub
4
+ #
5
+ module NatsPubsub
6
+ # Utility for parsing human-friendly durations into milliseconds.
7
+ #
8
+ # Defaults to an :auto heuristic for Integer/Float values to preserve
9
+ # backward compatibility:
10
+ # - Integers < 1000 are treated as seconds (e.g., 30 -> 30_000ms)
11
+ # - Integers >= 1000 are treated as milliseconds (e.g., 1500 -> 1500ms)
12
+ # Prefer setting `default_unit:` to :s or :ms for unambiguous behavior.
13
+ #
14
+ # Examples:
15
+ # Duration.to_millis(30) #=> 30000 (auto)
16
+ # Duration.to_millis(1500) #=> 1500 (auto)
17
+ # Duration.to_millis("1500") #=> 1500 (auto)
18
+ # Duration.to_millis(1500, default_unit: :s) #=> 1_500_000
19
+ # Duration.to_millis("30s") #=> 30000
20
+ # Duration.to_millis("500ms") #=> 500
21
+ # Duration.to_millis("250us") #=> 0
22
+ # Duration.to_millis("1h") #=> 3_600_000
23
+ # Duration.to_millis(1_500_000_000, default_unit: :ns) #=> 1500
24
+ #
25
+ # Also:
26
+ # Duration.normalize_list_to_millis(%w[1s 5s 15s]) #=> [1000, 5000, 15000]
27
+ module Duration
28
+ # multipliers to convert 1 unit into milliseconds
29
+ MULTIPLIER_MS = {
30
+ 'ns' => 1.0e-6, # nanoseconds to ms
31
+ 'us' => 1.0e-3, # microseconds to ms
32
+ 'µs' => 1.0e-3, # alt microseconds symbol
33
+ 'ms' => 1, # milliseconds to ms
34
+ 's' => 1_000, # seconds to ms
35
+ 'm' => 60_000, # minutes to ms
36
+ 'h' => 3_600_000, # hours to ms
37
+ 'd' => 86_400_000 # days to ms
38
+ }.freeze
39
+
40
+ NUMBER_RE = /\A\d[\d_]*\z/
41
+ TOKEN_RE = /\A(\d[\d_]*(?:\.\d+)?)\s*(ns|us|µs|ms|s|m|h|d)\z/i
42
+
43
+ module_function
44
+
45
+ # default_unit:
46
+ # :auto (heuristic: int<1000 -> seconds, >=1000 -> ms)
47
+ # :ns, :us, :ms, :s, :m, :h, :d (explicit)
48
+ def to_millis(val, default_unit: :auto)
49
+ case val
50
+ when Integer then int_to_ms(val, default_unit: default_unit)
51
+ when Float then float_to_ms(val, default_unit: default_unit)
52
+ when String then string_to_ms(val, default_unit: default_unit)
53
+ else
54
+ raise ArgumentError, "invalid duration type: #{val.class}" unless val.respond_to?(:to_f)
55
+
56
+ float_to_ms(val.to_f, default_unit: default_unit)
57
+
58
+ end
59
+ end
60
+
61
+ # Normalize an array of durations into integer milliseconds.
62
+ def normalize_list_to_millis(values, default_unit: :auto)
63
+ vals = Array(values)
64
+ return [] if vals.empty?
65
+
66
+ vals.map { |v| to_millis(v, default_unit: default_unit) }
67
+ end
68
+
69
+ # --- internal helpers ---
70
+
71
+ def int_to_ms(num, default_unit:)
72
+ case default_unit
73
+ when :auto
74
+ # Preserve existing heuristic for compatibility
75
+ num >= 1_000 ? num : num * 1_000
76
+ else
77
+ coerce_numeric_to_ms(num.to_f, default_unit)
78
+ end
79
+ end
80
+
81
+ def float_to_ms(flt, default_unit:)
82
+ coerce_numeric_to_ms(flt, default_unit)
83
+ end
84
+
85
+ def string_to_ms(str, default_unit:)
86
+ s = str.strip
87
+ # Plain number strings are treated like integers so the :auto
88
+ # heuristic still applies (<1000 => seconds, >=1000 => ms).
89
+ return int_to_ms(s.delete('_').to_i, default_unit: default_unit) if NUMBER_RE.match?(s)
90
+
91
+ m = TOKEN_RE.match(s)
92
+ raise ArgumentError, "invalid duration: #{str.inspect}" unless m
93
+
94
+ num = m[1].delete('_').to_f
95
+ unit = m[2].downcase
96
+ (num * MULTIPLIER_MS.fetch(unit)).round
97
+ end
98
+
99
+ def coerce_numeric_to_ms(num, unit)
100
+ case unit
101
+ when :auto
102
+ # For floats, :auto treats as seconds (common developer intent)
103
+ (num * 1_000).round
104
+ else
105
+ u = unit.to_s
106
+ mult = MULTIPLIER_MS[u]
107
+ raise ArgumentError, "invalid unit for default_unit: #{unit.inspect}" unless mult
108
+
109
+ (num * mult).round
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Core
5
+ # Error action constants for fine-grained error handling control
6
+ #
7
+ # @example Using in a subscriber
8
+ # class PaymentSubscriber < NatsPubsub::Subscriber
9
+ # subscribe_to 'payment.process'
10
+ #
11
+ # def handle(message, context)
12
+ # process_payment(message)
13
+ # end
14
+ #
15
+ # def on_error(error, message, context)
16
+ # case error
17
+ # when ValidationError
18
+ # ErrorAction::DISCARD
19
+ # when NetworkError, Timeout::Error
20
+ # ErrorAction::RETRY
21
+ # else
22
+ # ErrorAction::DLQ
23
+ # end
24
+ # end
25
+ # end
26
+ #
27
+ module ErrorAction
28
+ # Retry the message with backoff strategy
29
+ RETRY = :retry
30
+
31
+ # Acknowledge and discard the message (no retry)
32
+ DISCARD = :discard
33
+
34
+ # Send message to dead letter queue
35
+ DLQ = :dlq
36
+
37
+ # All valid actions
38
+ ALL = [RETRY, DISCARD, DLQ].freeze
39
+
40
+ # Check if action is valid
41
+ #
42
+ # @param action [Symbol] Action to validate
43
+ # @return [Boolean] True if valid
44
+ def self.valid?(action)
45
+ ALL.include?(action)
46
+ end
47
+
48
+ # Get default action
49
+ #
50
+ # @return [Symbol] Default action (:retry)
51
+ def self.default
52
+ RETRY
53
+ end
54
+ end
55
+
56
+ # Error context passed to error handlers
57
+ #
58
+ # @!attribute [r] error
59
+ # @return [Exception] The error that occurred
60
+ # @!attribute [r] message
61
+ # @return [Hash] The message that failed
62
+ # @!attribute [r] context
63
+ # @return [MessageContext] Message context
64
+ # @!attribute [r] attempt_number
65
+ # @return [Integer] Current attempt number (1-based)
66
+ # @!attribute [r] max_attempts
67
+ # @return [Integer] Maximum delivery attempts configured
68
+ #
69
+ class ErrorContext
70
+ attr_reader :error, :message, :context, :attempt_number, :max_attempts
71
+
72
+ # Initialize a new error context
73
+ #
74
+ # @param error [Exception] The error
75
+ # @param message [Hash] The message
76
+ # @param context [MessageContext] Message context
77
+ # @param attempt_number [Integer] Attempt number
78
+ # @param max_attempts [Integer] Max attempts
79
+ def initialize(error:, message:, context:, attempt_number:, max_attempts:)
80
+ @error = error
81
+ @message = message
82
+ @context = context
83
+ @attempt_number = attempt_number
84
+ @max_attempts = max_attempts
85
+
86
+ freeze
87
+ end
88
+
89
+ # Check if this is the last attempt
90
+ #
91
+ # @return [Boolean] True if last attempt
92
+ def last_attempt?
93
+ attempt_number >= max_attempts
94
+ end
95
+
96
+ # Check if retries are exhausted
97
+ #
98
+ # @return [Boolean] True if exhausted
99
+ def retries_exhausted?
100
+ last_attempt?
101
+ end
102
+
103
+ # Get remaining attempts
104
+ #
105
+ # @return [Integer] Number of remaining attempts
106
+ def remaining_attempts
107
+ [max_attempts - attempt_number, 0].max
108
+ end
109
+
110
+ # Convert to hash
111
+ #
112
+ # @return [Hash] Hash representation
113
+ def to_h
114
+ {
115
+ error: error.class.name,
116
+ error_message: error.message,
117
+ message: message,
118
+ context: context.to_h,
119
+ attempt_number: attempt_number,
120
+ max_attempts: max_attempts,
121
+ last_attempt: last_attempt?,
122
+ remaining_attempts: remaining_attempts
123
+ }
124
+ end
125
+
126
+ alias to_hash to_h
127
+ end
128
+
129
+ # Retry strategy configuration
130
+ #
131
+ # @!attribute [r] max_attempts
132
+ # @return [Integer] Maximum number of retry attempts
133
+ # @!attribute [r] backoff
134
+ # @return [Symbol] Backoff strategy (:exponential, :linear, :fixed)
135
+ # @!attribute [r] initial_delay
136
+ # @return [Integer] Initial delay in milliseconds
137
+ # @!attribute [r] max_delay
138
+ # @return [Integer] Maximum delay in milliseconds
139
+ # @!attribute [r] multiplier
140
+ # @return [Float] Multiplier for exponential backoff
141
+ #
142
+ class RetryStrategy
143
+ attr_reader :max_attempts, :backoff, :initial_delay, :max_delay, :multiplier
144
+
145
+ # Default values
146
+ DEFAULT_MAX_ATTEMPTS = 5
147
+ DEFAULT_BACKOFF = :exponential
148
+ DEFAULT_INITIAL_DELAY = 1_000 # 1 second
149
+ DEFAULT_MAX_DELAY = 60_000 # 60 seconds
150
+ DEFAULT_MULTIPLIER = 2.0
151
+
152
+ # Initialize a new retry strategy
153
+ #
154
+ # @param max_attempts [Integer] Maximum attempts
155
+ # @param backoff [Symbol] Backoff strategy
156
+ # @param initial_delay [Integer] Initial delay in ms
157
+ # @param max_delay [Integer] Max delay in ms
158
+ # @param multiplier [Float] Backoff multiplier
159
+ def initialize(
160
+ max_attempts: DEFAULT_MAX_ATTEMPTS,
161
+ backoff: DEFAULT_BACKOFF,
162
+ initial_delay: DEFAULT_INITIAL_DELAY,
163
+ max_delay: DEFAULT_MAX_DELAY,
164
+ multiplier: DEFAULT_MULTIPLIER
165
+ )
166
+ @max_attempts = max_attempts
167
+ @backoff = backoff
168
+ @initial_delay = initial_delay
169
+ @max_delay = max_delay
170
+ @multiplier = multiplier
171
+
172
+ validate!
173
+ freeze
174
+ end
175
+
176
+ # Calculate delay for an attempt
177
+ #
178
+ # @param attempt [Integer] Attempt number (1-based)
179
+ # @return [Integer] Delay in milliseconds
180
+ def delay_for_attempt(attempt)
181
+ delay = case backoff
182
+ when :exponential
183
+ exponential_delay(attempt)
184
+ when :linear
185
+ linear_delay(attempt)
186
+ when :fixed
187
+ initial_delay
188
+ else
189
+ initial_delay
190
+ end
191
+
192
+ [delay, max_delay].min
193
+ end
194
+
195
+ # Convert to hash
196
+ #
197
+ # @return [Hash] Hash representation
198
+ def to_h
199
+ {
200
+ max_attempts: max_attempts,
201
+ backoff: backoff,
202
+ initial_delay: initial_delay,
203
+ max_delay: max_delay,
204
+ multiplier: multiplier
205
+ }
206
+ end
207
+
208
+ alias to_hash to_h
209
+
210
+ private
211
+
212
+ # Calculate exponential delay
213
+ #
214
+ # @param attempt [Integer] Attempt number
215
+ # @return [Integer] Delay in milliseconds
216
+ def exponential_delay(attempt)
217
+ (initial_delay * (multiplier**(attempt - 1))).to_i
218
+ end
219
+
220
+ # Calculate linear delay
221
+ #
222
+ # @param attempt [Integer] Attempt number
223
+ # @return [Integer] Delay in milliseconds
224
+ def linear_delay(attempt)
225
+ initial_delay * attempt
226
+ end
227
+
228
+ # Validate configuration
229
+ #
230
+ # @raise [ArgumentError] If configuration is invalid
231
+ def validate!
232
+ raise ArgumentError, 'max_attempts must be positive' unless max_attempts.positive?
233
+ raise ArgumentError, 'initial_delay must be positive' unless initial_delay.positive?
234
+ raise ArgumentError, 'max_delay must be positive' unless max_delay.positive?
235
+ raise ArgumentError, 'multiplier must be positive' unless multiplier.positive?
236
+
237
+ valid_backoffs = %i[exponential linear fixed]
238
+ return if valid_backoffs.include?(backoff)
239
+
240
+ raise ArgumentError, "backoff must be one of: #{valid_backoffs.join(', ')}"
241
+ end
242
+ end
243
+
244
+ # Circuit breaker configuration
245
+ #
246
+ # @!attribute [r] enabled
247
+ # @return [Boolean] Enable circuit breaker
248
+ # @!attribute [r] threshold
249
+ # @return [Integer] Number of failures before opening
250
+ # @!attribute [r] timeout
251
+ # @return [Integer] Time to keep circuit open in milliseconds
252
+ # @!attribute [r] half_open_max_calls
253
+ # @return [Integer] Number of test calls in half-open state
254
+ #
255
+ class CircuitBreakerConfig
256
+ attr_reader :enabled, :threshold, :timeout, :half_open_max_calls
257
+
258
+ # Initialize circuit breaker config
259
+ #
260
+ # @param enabled [Boolean] Enable circuit breaker
261
+ # @param threshold [Integer] Failure threshold
262
+ # @param timeout [Integer] Timeout in milliseconds
263
+ # @param half_open_max_calls [Integer] Max calls in half-open state
264
+ def initialize(enabled: false, threshold: 5, timeout: 60_000, half_open_max_calls: 3)
265
+ @enabled = enabled
266
+ @threshold = threshold
267
+ @timeout = timeout
268
+ @half_open_max_calls = half_open_max_calls
269
+
270
+ freeze
271
+ end
272
+
273
+ # Convert to hash
274
+ #
275
+ # @return [Hash] Hash representation
276
+ def to_h
277
+ {
278
+ enabled: enabled,
279
+ threshold: threshold,
280
+ timeout: timeout,
281
+ half_open_max_calls: half_open_max_calls
282
+ }
283
+ end
284
+
285
+ alias to_hash to_h
286
+ end
287
+ end
288
+ end