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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +120 -0
- data/Rakefile +42 -0
- data/lib/sender/core/capabilities.rb +22 -0
- data/lib/sender/core/circuit_breaker.rb +119 -0
- data/lib/sender/core/delivery.rb +97 -0
- data/lib/sender/core/delivery_event.rb +55 -0
- data/lib/sender/core/election.rb +63 -0
- data/lib/sender/core/errors.rb +71 -0
- data/lib/sender/core/health.rb +84 -0
- data/lib/sender/core/http.rb +162 -0
- data/lib/sender/core/message.rb +53 -0
- data/lib/sender/core/observability.rb +49 -0
- data/lib/sender/core/provider.rb +37 -0
- data/lib/sender/core/provider_configuration.rb +68 -0
- data/lib/sender/core/provider_registry.rb +122 -0
- data/lib/sender/core/router.rb +149 -0
- data/lib/sender/core/state_store.rb +49 -0
- data/lib/sender/core/version.rb +8 -0
- data/lib/sender/core.rb +29 -0
- data/sig/sender/core.rbs +78 -0
- metadata +72 -0
|
@@ -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
|
data/lib/sender/core.rb
ADDED
|
@@ -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"
|
data/sig/sender/core.rbs
ADDED
|
@@ -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: []
|