sender-core 0.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.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sender
4
+ module Core
5
+ # Selects one eligible configured provider for a delivery.
6
+ class Router
7
+ # @param registry [ProviderRegistry] provider registry to route through
8
+ def initialize(registry: ProviderRegistry.new, circuit_options: {}, state_store: StateStore::Memory.new,
9
+ observer: Observability::Memory.new)
10
+ @registry = registry
11
+ @circuit_options = circuit_options
12
+ @state_store = state_store
13
+ @observer = observer
14
+ @election = Election.new(state_store: @state_store)
15
+ end
16
+
17
+ # Deliver a normalized message through the best eligible provider.
18
+ #
19
+ # Providers are ordered by descending priority, then by name for stable
20
+ # tie-breaking. Failover is bounded by the eligible provider count.
21
+ # @param message [Message] normalized outbound message
22
+ # @param provider [Symbol, String, nil] optional explicit provider
23
+ # @param max_attempts [Integer, nil] optional upper bound on attempts
24
+ # @return [Delivery] provider-neutral delivery result
25
+ def deliver(message, provider: nil, max_attempts: nil)
26
+ validate_message(message)
27
+ candidates = eligible_providers(provider, requirements: message.requirements)
28
+ attempt_limit = validate_max_attempts(max_attempts || candidates.length)
29
+ attempts = []
30
+ last_error = nil
31
+
32
+ candidates.first(attempt_limit).each do |candidate|
33
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ delivery = candidate.deliver(message)
35
+ previous_state = circuit_for(candidate).state
36
+ circuit_for(candidate).record_success
37
+ if previous_state != circuit_for(candidate).state
38
+ emit(type: :circuit_transition, provider: candidate.name, from: previous_state,
39
+ to: circuit_for(candidate).state)
40
+ end
41
+ health_for(candidate).record_success(
42
+ latency: elapsed_since(started_at), circuit_state: circuit_for(candidate).state
43
+ )
44
+ emit(type: :attempt, provider: candidate.name, status: :accepted, latency: elapsed_since(started_at))
45
+ return with_attempt(delivery, attempts, candidate.name, :accepted)
46
+ rescue Errors::Base => e
47
+ previous_state = circuit_for(candidate).state
48
+ circuit_for(candidate).record_failure(e)
49
+ if previous_state != circuit_for(candidate).state
50
+ emit(type: :circuit_transition, provider: candidate.name, from: previous_state,
51
+ to: circuit_for(candidate).state)
52
+ end
53
+ health_for(candidate).record_failure(
54
+ latency: elapsed_since(started_at), error: e, circuit_state: circuit_for(candidate).state
55
+ )
56
+ emit(type: :attempt, provider: candidate.name, status: :failed, error: e.category,
57
+ latency: elapsed_since(started_at))
58
+ attempts << attempt_record(candidate.name, :failed, e)
59
+ last_error = e
60
+ if e.failover? && attempts.length < attempt_limit
61
+ emit(type: :failover, from: candidate.name, to: candidates[attempts.length]&.name)
62
+ end
63
+ raise unless e.failover?
64
+ end
65
+
66
+ Delivery.new(status: :failed, provider: last_error.provider, attempts: attempts, error: last_error)
67
+ end
68
+
69
+ # @param name [Symbol, String] configured provider name
70
+ # @return [Hash] current provider health snapshot
71
+ def health(name)
72
+ health_for(@registry.provider(name)).snapshot
73
+ end
74
+
75
+ # @return [Array<Hash>] structured events emitted by this router
76
+ def events
77
+ @observer.events
78
+ end
79
+
80
+ private
81
+
82
+ def eligible_providers(name = nil, requirements: [])
83
+ candidates = name ? [@registry.provider(name)] : @registry.providers
84
+ selected = @election.rank(
85
+ candidates, requirements: requirements, circuits: circuit_map(candidates), health: health_map(candidates)
86
+ )
87
+ emit(type: :election, candidates: selected.map(&:name), selected: selected.first&.name)
88
+ return selected unless selected.empty?
89
+
90
+ raise Errors::ConfigurationError.new("no eligible provider is configured", category: :configuration)
91
+ end
92
+
93
+ def validate_max_attempts(value)
94
+ return value if value.is_a?(Integer) && value.positive?
95
+
96
+ raise ArgumentError, "max_attempts must be a positive Integer"
97
+ end
98
+
99
+ def circuit_map(providers)
100
+ providers.to_h { |provider| [provider.name, circuit_for(provider)] }
101
+ end
102
+
103
+ def health_map(providers)
104
+ providers.to_h { |provider| [provider.name, health_for(provider)] }
105
+ end
106
+
107
+ def circuit_for(provider)
108
+ @state_store.fetch([:circuit, provider.name]) { CircuitBreaker.new(**@circuit_options) }
109
+ end
110
+
111
+ def health_for(provider)
112
+ @state_store.fetch([:health, provider.name]) do
113
+ Health.new(
114
+ observation_window: @circuit_options.fetch(:observation_window, CircuitBreaker::DEFAULT_OBSERVATION_WINDOW)
115
+ )
116
+ end
117
+ end
118
+
119
+ def elapsed_since(started_at)
120
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
121
+ end
122
+
123
+ def emit(event)
124
+ @observer.emit(event)
125
+ end
126
+
127
+ def attempt_record(provider, status, error = nil)
128
+ { provider: provider, status: status, error: error&.category }.compact
129
+ end
130
+
131
+ def with_attempt(delivery, attempts, provider, status)
132
+ Delivery.new(
133
+ status: delivery.status,
134
+ provider: delivery.provider,
135
+ provider_message_id: delivery.provider_message_id,
136
+ attempts: attempts + [attempt_record(provider, status)],
137
+ error: delivery.error,
138
+ metadata: delivery.metadata
139
+ )
140
+ end
141
+
142
+ def validate_message(message)
143
+ return if message.is_a?(Message)
144
+
145
+ raise ArgumentError, "message must be a Sender::Core::Message"
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sender
4
+ module Core
5
+ # Namespace for replaceable runtime state persistence implementations.
6
+ module StateStore
7
+ # Thread-safe process-local state store.
8
+ class Memory
9
+ def initialize
10
+ @values = {}
11
+ @lock = Mutex.new
12
+ end
13
+
14
+ # @param key [Object] state key
15
+ # @return [Object, nil] stored value
16
+ def read(key)
17
+ @lock.synchronize { @values[key] }
18
+ end
19
+
20
+ # Store a value and return it.
21
+ # @param key [Object] state key
22
+ # @param value [Object] state value
23
+ # @return [Object] stored value
24
+ def write(key, value)
25
+ @lock.synchronize { @values[key] = value }
26
+ end
27
+
28
+ # Read a value or initialize it atomically.
29
+ # @param key [Object] state key
30
+ # @return [Object] existing or initialized value
31
+ def fetch(key)
32
+ @lock.synchronize { @values.fetch(key) { @values[key] = yield } }
33
+ end
34
+
35
+ # Remove a value.
36
+ # @param key [Object] state key
37
+ # @return [Object, nil] removed value
38
+ def delete(key)
39
+ @lock.synchronize { @values.delete(key) }
40
+ end
41
+
42
+ # Remove all state values.
43
+ def clear
44
+ @lock.synchronize { @values.clear }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sender
4
+ module Core
5
+ # Current sender-core version.
6
+ VERSION = "0.0.0"
7
+ end
8
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core/version"
4
+
5
+ # Top-level namespace for sender-related libraries.
6
+ module Sender
7
+ # Namespace for sender-related libraries.
8
+ module Core
9
+ # Channel-neutral delivery runtime shared by sender gems.
10
+ # Base error raised by sender-core.
11
+ class Error < StandardError; end
12
+ end
13
+ end
14
+
15
+ require_relative "core/capabilities"
16
+ require_relative "core/errors"
17
+ require_relative "core/message"
18
+ require_relative "core/delivery"
19
+ require_relative "core/delivery_event"
20
+ require_relative "core/provider_configuration"
21
+ require_relative "core/provider"
22
+ require_relative "core/state_store"
23
+ require_relative "core/observability"
24
+ require_relative "core/circuit_breaker"
25
+ require_relative "core/health"
26
+ require_relative "core/election"
27
+ require_relative "core/provider_registry"
28
+ require_relative "core/router"
29
+ require_relative "core/http"
@@ -0,0 +1,78 @@
1
+ module Sender
2
+ module Core
3
+ class Error < StandardError
4
+ end
5
+
6
+ VERSION: String
7
+
8
+ module Capabilities
9
+ KNOWN: Array[Symbol]
10
+
11
+ def self.normalize: (Array[Symbol | String] values) -> Array[Symbol]
12
+ end
13
+
14
+ module Errors
15
+ FAILOVER_CATEGORIES: Array[Symbol]
16
+ RETRYABLE_CATEGORIES: Array[Symbol]
17
+
18
+ class Base < Error
19
+ attr_reader category: Symbol
20
+ attr_reader provider: Symbol?
21
+
22
+ def initialize: (?String?, ?category: Symbol?, ?provider: Symbol?) -> void
23
+ def retryable?: () -> bool
24
+ def failover?: () -> bool
25
+ end
26
+
27
+ class ProviderError < Base
28
+ end
29
+ class Network < ProviderError
30
+ end
31
+ class Timeout < ProviderError
32
+ end
33
+ class RateLimited < ProviderError
34
+ end
35
+ class Authentication < ProviderError
36
+ end
37
+ class Authorization < ProviderError
38
+ end
39
+ class InvalidRequest < Base
40
+ end
41
+ class InvalidRecipient < Base
42
+ end
43
+ class ProviderUnavailable < ProviderError
44
+ end
45
+ class ProviderRejected < Base
46
+ end
47
+ class Unknown < Base
48
+ end
49
+ class ConfigurationError < Base
50
+ end
51
+ end
52
+
53
+ class Message
54
+ attr_reader to: String
55
+ attr_reader body: String
56
+ attr_reader metadata: Hash[untyped, untyped]
57
+ attr_reader requirements: Array[Symbol]
58
+
59
+ def initialize: (to: String, body: String, ?metadata: Hash[untyped, untyped], ?requirements: Array[Symbol | String]) -> void
60
+ end
61
+
62
+ class Delivery
63
+ STATUSES: Array[Symbol]
64
+ attr_reader status: Symbol
65
+ attr_reader provider: Symbol?
66
+ attr_reader provider_message_id: String?
67
+ attr_reader attempts: Array[Hash[untyped, untyped]]
68
+ attr_reader error: Errors::Base?
69
+ attr_reader metadata: Hash[untyped, untyped]
70
+
71
+ def initialize: (?status: Symbol | String, ?provider: Symbol | String?, ?provider_message_id: untyped, ?attempts: Array[Hash[untyped, untyped]], ?error: Errors::Base?, ?metadata: Hash[untyped, untyped]) -> void
72
+ def accepted?: () -> bool
73
+ def delivered?: () -> bool
74
+ def failed?: () -> bool
75
+ def with_event: (DeliveryEvent event) -> Delivery
76
+ end
77
+ end
78
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sender-core
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ken C. Demanawa
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ Sender Core provides provider-neutral message delivery contracts,
14
+ routing, failover, resilience, state, and observability primitives for
15
+ Ruby channel gems such as sms-sender and email-sender.
16
+ email:
17
+ - kenneth.c.demanawa@gmail.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - CODE_OF_CONDUCT.md
24
+ - LICENSE.txt
25
+ - README.md
26
+ - Rakefile
27
+ - lib/sender/core.rb
28
+ - lib/sender/core/capabilities.rb
29
+ - lib/sender/core/circuit_breaker.rb
30
+ - lib/sender/core/delivery.rb
31
+ - lib/sender/core/delivery_event.rb
32
+ - lib/sender/core/election.rb
33
+ - lib/sender/core/errors.rb
34
+ - lib/sender/core/health.rb
35
+ - lib/sender/core/http.rb
36
+ - lib/sender/core/message.rb
37
+ - lib/sender/core/observability.rb
38
+ - lib/sender/core/provider.rb
39
+ - lib/sender/core/provider_configuration.rb
40
+ - lib/sender/core/provider_registry.rb
41
+ - lib/sender/core/router.rb
42
+ - lib/sender/core/state_store.rb
43
+ - lib/sender/core/version.rb
44
+ - sig/sender/core.rbs
45
+ homepage: https://kanutocd.github.io/sender-core
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ allowed_push_host: https://rubygems.org
50
+ homepage_uri: https://kanutocd.github.io/sender-core
51
+ source_code_uri: https://github.com/kanutocd/sender-core
52
+ changelog_uri: https://github.com/kanutocd/sender-core/blob/main/CHANGELOG.md
53
+ bug_tracker_uri: https://github.com/kanutocd/sender-core/issues
54
+ rubygems_mfa_required: 'true'
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 3.2.0
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 4.0.19
70
+ specification_version: 4
71
+ summary: Channel-neutral delivery runtime for Ruby sms-sender and email-sender gems
72
+ test_files: []