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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7b541836b2290d80cb723414c53339cafc3704af0ec52c4b8f3ab72696cb723e
|
|
4
|
+
data.tar.gz: aad6ca0e0ca13461430e02f1135eedc5d8cc01d7961752d502be2ba9a5967777
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4fa574c030ee74ad81e1090728363af0be00bb8c36f2d30ca0fa79b5fff5f372ab5529f17e688c0501d1dd5176f81d51b8e1963b229aec10f3ad240c5add1dcf
|
|
7
|
+
data.tar.gz: f8bb566e3b84e787151fd7e1f15a08ec5f23ed00796cf69512db956975af259c07867cb26a751e3a1161bb32f3ec03891e5a3fe6f6830604b1001711aadffd7c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- Add immutable provider-neutral message, delivery, capability, delivery-event,
|
|
6
|
+
and normalized-error contracts.
|
|
7
|
+
- Add provider configuration and lazy registry primitives for channel gems.
|
|
8
|
+
- Add bounded provider election, routing, failover, circuit protection, health
|
|
9
|
+
tracking, and attempt history.
|
|
10
|
+
- Add replaceable in-memory state and structured observability primitives.
|
|
11
|
+
- Add a standard-library HTTP transport with configurable request timeouts and
|
|
12
|
+
normalized network and timeout errors.
|
|
13
|
+
- Add the shared runtime integration boundary used by channel gems such as
|
|
14
|
+
`sms-sender` and `email-sender`.
|
|
15
|
+
|
|
16
|
+
### Quality
|
|
17
|
+
|
|
18
|
+
- Add focused tests, RBS signatures, and channel-independent defaults.
|
|
19
|
+
- Add the `bundle exec rake` quality harness covering tests, RuboCop, RBS, and
|
|
20
|
+
100% YARD documentation coverage.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"sender-core" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["kenneth.c.demanawa@gmail.com"](mailto:"kenneth.c.demanawa@gmail.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ken C. Demanawa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# sender-core
|
|
2
|
+
|
|
3
|
+
`sender-core` is the channel-neutral Ruby runtime for sender gems such as
|
|
4
|
+
[`sms-sender`](https://github.com/kanutocd/sms-sender) and `email-sender`.
|
|
5
|
+
It provides reusable delivery contracts, provider selection, failover,
|
|
6
|
+
resilience, state, and observability primitives.
|
|
7
|
+
|
|
8
|
+
## Origin
|
|
9
|
+
|
|
10
|
+
The sender gems were extracted from the Pink House SaaS project to make its
|
|
11
|
+
delivery capabilities reusable, independently testable, and composable across
|
|
12
|
+
communication channels. `sender-core` contains the shared runtime extracted
|
|
13
|
+
from the original application; channel gems remain responsible for their own
|
|
14
|
+
input validation and provider integrations.
|
|
15
|
+
|
|
16
|
+
Channel gems remain responsible for channel-specific validation, provider
|
|
17
|
+
adapters, authentication, payload mapping, and response mapping. The shared
|
|
18
|
+
runtime is independent of SMS, email, Rails, Redis, PostgreSQL, provider
|
|
19
|
+
SDKs, and live network services.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add the gem to your application's Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem "sender-core"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then install it with Bundler:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Architecture
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
channel facade
|
|
39
|
+
|
|
|
40
|
+
sender-core message and delivery contracts
|
|
41
|
+
|
|
|
42
|
+
election -> router -> channel provider adapter -> HTTP/provider API
|
|
43
|
+
|
|
|
44
|
+
health, circuit breaker, state store, and observability
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`sender-core` is not a standalone email or SMS client. It supplies the
|
|
48
|
+
provider-neutral runtime that channel gems compose into their public APIs.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
Channel gems normalize their input into a core message, configure one or more
|
|
53
|
+
providers, and delegate delivery to the shared router:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
message = Sender::Core::Message.new(
|
|
57
|
+
to: "recipient@example.test",
|
|
58
|
+
body: "Hello",
|
|
59
|
+
requirements: [:email]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
delivery = Sender::Core::Router.new(registry: registry).deliver(message)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The returned delivery exposes its normalized status, selected provider,
|
|
66
|
+
provider message ID, attempt history, and error. Channel-specific validation
|
|
67
|
+
and provider request mapping belong in the composing channel gem.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
Install development dependencies with:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
bin/setup
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Run the complete quality harness with:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
bundle exec rake
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The harness runs tests, RuboCop, RBS validation, YARD generation, and a 100%
|
|
84
|
+
YARD documentation coverage check. Tests must remain deterministic and must
|
|
85
|
+
not require credentials, network access, or live provider availability.
|
|
86
|
+
|
|
87
|
+
Useful individual tasks include:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
bundle exec rake test
|
|
91
|
+
bundle exec rake rubocop
|
|
92
|
+
bundle exec rake rbs
|
|
93
|
+
bundle exec rake yard
|
|
94
|
+
bundle exec rake yard:coverage
|
|
95
|
+
bundle exec rake build
|
|
96
|
+
bin/console
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Design principles
|
|
100
|
+
|
|
101
|
+
- Keep the core channel-neutral and provider-independent.
|
|
102
|
+
- Use immutable value objects for delivery context.
|
|
103
|
+
- Make retry and failover policy explicit and bounded.
|
|
104
|
+
- Keep mutable runtime state behind replaceable abstractions.
|
|
105
|
+
- Load providers lazily and keep adapters thin.
|
|
106
|
+
- Prefer standard-library boundaries and minimal dependencies.
|
|
107
|
+
- Keep live integration checks separate from the default quality gate.
|
|
108
|
+
|
|
109
|
+
## Contributing
|
|
110
|
+
|
|
111
|
+
Bug reports and pull requests are welcome at
|
|
112
|
+
[github.com/kanutocd/sender-core](https://github.com/kanutocd/sender-core).
|
|
113
|
+
Meaningful user, operator, integration, tooling, and architectural changes
|
|
114
|
+
should be recorded in `CHANGELOG.md` under `Unreleased`.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
This project is available under the [MIT License](LICENSE.txt).
|
|
119
|
+
|
|
120
|
+
See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community guidelines.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |task|
|
|
7
|
+
task.libs << "test"
|
|
8
|
+
task.libs << "lib"
|
|
9
|
+
task.test_files = FileList["test/**/test_*.rb", "test/**/*_test.rb"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require "rubocop/rake_task"
|
|
13
|
+
require "yard"
|
|
14
|
+
require "yard/rake/yardoc_task"
|
|
15
|
+
|
|
16
|
+
RuboCop::RakeTask.new do |task|
|
|
17
|
+
task.options = ["--cache", "false"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
YARD::Rake::YardocTask.new(:yard) do |task|
|
|
21
|
+
task.files = ["lib/**/*.rb"]
|
|
22
|
+
task.options = ["--output-dir", "doc", "--readme", "README.md", "--markup", "markdown"]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Validate RBS signatures"
|
|
26
|
+
task :rbs do
|
|
27
|
+
sh "rbs validate"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "Enforce YARD documentation coverage"
|
|
31
|
+
task "yard:coverage" => :yard do
|
|
32
|
+
output = `yard stats --no-progress --list-undoc --exclude sig`
|
|
33
|
+
puts output
|
|
34
|
+
coverage = output[/([0-9]+(?:\.[0-9]+)?)% documented/, 1]&.to_f
|
|
35
|
+
abort "Could not determine YARD documentation coverage" unless coverage
|
|
36
|
+
abort "YARD documentation coverage #{coverage}% is below 100%" if coverage < 100.0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "Run the test and lint quality gates"
|
|
40
|
+
task quality: %i[test rubocop rbs yard:coverage]
|
|
41
|
+
|
|
42
|
+
task default: :quality
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Shared capability vocabulary and normalization rules.
|
|
6
|
+
module Capabilities
|
|
7
|
+
# Initial capability names understood by routing policies.
|
|
8
|
+
KNOWN = %i[sms email unicode sender_id alphanumeric_sender delivery_receipts attachments].freeze
|
|
9
|
+
|
|
10
|
+
# Normalize capability values without preventing provider extensions.
|
|
11
|
+
# @param values [Array<Symbol, String>] capability names
|
|
12
|
+
# @return [Array<Symbol>] unique normalized names
|
|
13
|
+
def self.normalize(values)
|
|
14
|
+
raise ArgumentError, "capabilities must be an Array" unless values.is_a?(Array)
|
|
15
|
+
|
|
16
|
+
values.map(&:to_sym).uniq.freeze
|
|
17
|
+
rescue NoMethodError
|
|
18
|
+
raise ArgumentError, "capabilities must contain symbolizable values"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# In-memory circuit breaker for one provider.
|
|
6
|
+
class CircuitBreaker
|
|
7
|
+
# Circuit states exposed for observability and routing.
|
|
8
|
+
STATES = %i[closed open half_open].freeze
|
|
9
|
+
# Conservative default consecutive-failure threshold.
|
|
10
|
+
DEFAULT_FAILURE_THRESHOLD = 3
|
|
11
|
+
# Default period for retaining failure observations, in seconds.
|
|
12
|
+
DEFAULT_OBSERVATION_WINDOW = 60
|
|
13
|
+
# Default period before an open circuit can be probed, in seconds.
|
|
14
|
+
DEFAULT_OPEN_COOLDOWN = 30
|
|
15
|
+
# Default number of concurrent half-open probes.
|
|
16
|
+
DEFAULT_HALF_OPEN_PROBE_LIMIT = 1
|
|
17
|
+
|
|
18
|
+
# @param failure_threshold [Integer] failures required to open
|
|
19
|
+
# @param observation_window [Numeric] failure retention period
|
|
20
|
+
# @param open_cooldown [Numeric] open period before probing
|
|
21
|
+
# @param half_open_probe_limit [Integer] concurrent probe limit
|
|
22
|
+
# @param clock [#call] monotonic clock returning seconds
|
|
23
|
+
def initialize(failure_threshold: DEFAULT_FAILURE_THRESHOLD, observation_window: DEFAULT_OBSERVATION_WINDOW,
|
|
24
|
+
open_cooldown: DEFAULT_OPEN_COOLDOWN, half_open_probe_limit: DEFAULT_HALF_OPEN_PROBE_LIMIT,
|
|
25
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
26
|
+
@failure_threshold = positive_integer(failure_threshold, "failure_threshold")
|
|
27
|
+
@observation_window = positive_number(observation_window, "observation_window")
|
|
28
|
+
@open_cooldown = positive_number(open_cooldown, "open_cooldown")
|
|
29
|
+
@half_open_probe_limit = positive_integer(half_open_probe_limit, "half_open_probe_limit")
|
|
30
|
+
@clock = clock
|
|
31
|
+
@state = :closed
|
|
32
|
+
@failures = []
|
|
33
|
+
@opened_at = nil
|
|
34
|
+
@probes = 0
|
|
35
|
+
@lock = Mutex.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Symbol] current circuit state
|
|
39
|
+
def state
|
|
40
|
+
@lock.synchronize do
|
|
41
|
+
transition_if_ready
|
|
42
|
+
@state
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reserve permission for one request.
|
|
47
|
+
# @return [Boolean] whether the request may proceed
|
|
48
|
+
def allow?
|
|
49
|
+
@lock.synchronize do
|
|
50
|
+
transition_if_ready
|
|
51
|
+
return true if @state == :closed
|
|
52
|
+
return false if @state == :open
|
|
53
|
+
return false if @probes >= @half_open_probe_limit
|
|
54
|
+
|
|
55
|
+
@probes += 1
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Record a successful request and close the circuit.
|
|
61
|
+
def record_success
|
|
62
|
+
@lock.synchronize do
|
|
63
|
+
@state = :closed
|
|
64
|
+
@failures.clear
|
|
65
|
+
@opened_at = nil
|
|
66
|
+
@probes = 0
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Record a retryable provider failure.
|
|
71
|
+
# @param error [Errors::Base] normalized provider error
|
|
72
|
+
def record_failure(error)
|
|
73
|
+
return unless error.retryable?
|
|
74
|
+
|
|
75
|
+
@lock.synchronize do
|
|
76
|
+
if @state == :half_open
|
|
77
|
+
open_circuit
|
|
78
|
+
else
|
|
79
|
+
prune_failures
|
|
80
|
+
@failures << @clock.call
|
|
81
|
+
open_circuit if @failures.length >= @failure_threshold
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def transition_if_ready
|
|
89
|
+
return unless @state == :open && @clock.call - @opened_at >= @open_cooldown
|
|
90
|
+
|
|
91
|
+
@state = :half_open
|
|
92
|
+
@probes = 0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def open_circuit
|
|
96
|
+
@state = :open
|
|
97
|
+
@opened_at = @clock.call
|
|
98
|
+
@probes = 0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def prune_failures
|
|
102
|
+
cutoff = @clock.call - @observation_window
|
|
103
|
+
@failures.reject! { |timestamp| timestamp < cutoff }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def positive_integer(value, name)
|
|
107
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
108
|
+
|
|
109
|
+
raise ArgumentError, "#{name} must be a positive Integer"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def positive_number(value, name)
|
|
113
|
+
return value if value.is_a?(Numeric) && value.positive?
|
|
114
|
+
|
|
115
|
+
raise ArgumentError, "#{name} must be a positive number"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable provider-neutral delivery result.
|
|
6
|
+
class Delivery
|
|
7
|
+
# Statuses exposed by the provider-neutral delivery result.
|
|
8
|
+
STATUSES = %i[pending submitted accepted delivered failed unknown].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :status, :provider, :provider_message_id, :attempts, :error, :metadata
|
|
11
|
+
|
|
12
|
+
# @param status [Symbol] normalized delivery status
|
|
13
|
+
# @param provider [Symbol, nil] provider that handled the delivery
|
|
14
|
+
# @param provider_message_id [String, nil] provider-side identifier
|
|
15
|
+
# @param attempts [Array<Hash>] delivery attempt records
|
|
16
|
+
# @param error [Errors::Base, nil] normalized failure
|
|
17
|
+
# @param metadata [Hash] provider-neutral metadata
|
|
18
|
+
def initialize(status: :unknown, provider: nil, provider_message_id: nil, attempts: [], error: nil, metadata: {})
|
|
19
|
+
@status = normalize_status(status)
|
|
20
|
+
@provider = provider&.to_sym
|
|
21
|
+
@provider_message_id = provider_message_id&.to_s&.freeze
|
|
22
|
+
@attempts = normalize_attempts(attempts)
|
|
23
|
+
@error = validate_error(error)
|
|
24
|
+
@metadata = normalize_metadata(metadata)
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] whether the provider accepted the message
|
|
29
|
+
def accepted?
|
|
30
|
+
%i[submitted accepted].include?(status)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] whether delivery was confirmed
|
|
34
|
+
def delivered?
|
|
35
|
+
status == :delivered
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Boolean] whether delivery failed terminally
|
|
39
|
+
def failed?
|
|
40
|
+
status == :failed
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Apply a provider status event to this immutable delivery state.
|
|
44
|
+
# @param event [DeliveryEvent] normalized provider status update
|
|
45
|
+
# @return [Delivery] updated delivery state
|
|
46
|
+
def with_event(event)
|
|
47
|
+
raise ArgumentError, "event must be a Sender::Core::DeliveryEvent" unless event.is_a?(DeliveryEvent)
|
|
48
|
+
if provider && (event.provider != provider || event.provider_message_id != provider_message_id)
|
|
49
|
+
raise ArgumentError, "delivery event does not match this delivery"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Delivery.new(
|
|
53
|
+
status: event.status,
|
|
54
|
+
provider: event.provider,
|
|
55
|
+
provider_message_id: event.provider_message_id,
|
|
56
|
+
attempts: attempts,
|
|
57
|
+
error: event.error,
|
|
58
|
+
metadata: metadata.merge(event.metadata)
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def normalize_status(value)
|
|
65
|
+
status = value.to_sym
|
|
66
|
+
return status if STATUSES.include?(status)
|
|
67
|
+
|
|
68
|
+
raise ArgumentError, "unknown delivery status: #{value.inspect}"
|
|
69
|
+
rescue NoMethodError
|
|
70
|
+
raise ArgumentError, "status must be symbolizable"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_attempts(value)
|
|
74
|
+
raise ArgumentError, "attempts must be an Array" unless value.is_a?(Array)
|
|
75
|
+
|
|
76
|
+
value.map do |attempt|
|
|
77
|
+
raise ArgumentError, "each attempt must be a Hash" unless attempt.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
attempt.dup.freeze
|
|
80
|
+
end.freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_error(value)
|
|
84
|
+
return unless value
|
|
85
|
+
return value if value.is_a?(Errors::Base)
|
|
86
|
+
|
|
87
|
+
raise ArgumentError, "error must be a normalized sender error"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_metadata(value)
|
|
91
|
+
raise ArgumentError, "metadata must be a Hash" unless value.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
value.dup.freeze
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Provider-neutral delivery status update, suitable for webhook adapters.
|
|
6
|
+
class DeliveryEvent
|
|
7
|
+
attr_reader :provider, :provider_message_id, :status, :metadata, :error, :occurred_at
|
|
8
|
+
|
|
9
|
+
# @param provider [Symbol, String] source provider
|
|
10
|
+
# @param provider_message_id [String] provider-side message identifier
|
|
11
|
+
# @param status [Symbol] normalized delivery status
|
|
12
|
+
# @param metadata [Hash] provider event metadata
|
|
13
|
+
# @param error [Errors::Base, nil] normalized delivery error
|
|
14
|
+
# @param occurred_at [Time] provider event timestamp
|
|
15
|
+
def initialize(provider:, provider_message_id:, status:, metadata: {}, error: nil, occurred_at: Time.now)
|
|
16
|
+
@provider = provider.to_sym
|
|
17
|
+
@provider_message_id = provider_message_id.to_s
|
|
18
|
+
@status = normalize_status(status)
|
|
19
|
+
@metadata = validate_metadata(metadata)
|
|
20
|
+
@error = validate_error(error)
|
|
21
|
+
@occurred_at = validate_time(occurred_at)
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def normalize_status(value)
|
|
28
|
+
return value.to_sym if Delivery::STATUSES.include?(value.to_sym)
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "unknown delivery event status: #{value.inspect}"
|
|
31
|
+
rescue NoMethodError
|
|
32
|
+
raise ArgumentError, "status must be symbolizable"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_metadata(value)
|
|
36
|
+
raise ArgumentError, "metadata must be a Hash" unless value.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
value.dup.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_error(value)
|
|
42
|
+
return unless value
|
|
43
|
+
return value if value.is_a?(Errors::Base)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "error must be a normalized sender error"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_time(value)
|
|
49
|
+
return value if value.is_a?(Time)
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "occurred_at must be a Time"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Selects eligible providers using priority and lightweight health signals.
|
|
6
|
+
class Election
|
|
7
|
+
# @param state_store [StateStore::Memory] election state persistence
|
|
8
|
+
def initialize(state_store: StateStore::Memory.new)
|
|
9
|
+
@state_store = state_store
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Return providers in deterministic descending preference order.
|
|
13
|
+
# @param providers [Array<Provider>] configured providers
|
|
14
|
+
# @param requirements [Array<Symbol>] required message capabilities
|
|
15
|
+
# @param circuits [Hash<Symbol, CircuitBreaker>] provider circuits
|
|
16
|
+
# @param health [Hash<Symbol, Health>] provider health records
|
|
17
|
+
# @return [Array<Provider>] eligible providers
|
|
18
|
+
def rank(providers, requirements: [], circuits: {}, health: {})
|
|
19
|
+
result = providers
|
|
20
|
+
.select { |provider| eligible?(provider, requirements, circuits) }
|
|
21
|
+
.sort_by { |provider| election_key(provider, snapshot_for(health[provider.name])) }
|
|
22
|
+
.reverse
|
|
23
|
+
@state_store.write(:last_election, result.first&.name)
|
|
24
|
+
result
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Symbol, nil] most recently preferred provider
|
|
28
|
+
def last_selection
|
|
29
|
+
@state_store.read(:last_election)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def eligible?(provider, requirements, circuits)
|
|
35
|
+
provider.configuration.enabled? &&
|
|
36
|
+
requirements.all? { |requirement| provider.capabilities.include?(requirement.to_sym) } &&
|
|
37
|
+
(circuits[provider.name]&.allow? != false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def election_key(provider, snapshot)
|
|
41
|
+
priority = provider.configuration.priority
|
|
42
|
+
success_rate = success_rate(snapshot)
|
|
43
|
+
failure_penalty = snapshot ? snapshot[:recent_failures] + snapshot[:consecutive_failures] : 0
|
|
44
|
+
[priority + success_rate - failure_penalty, priority, provider.name.to_s]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def success_rate(snapshot)
|
|
48
|
+
return 0 unless snapshot
|
|
49
|
+
|
|
50
|
+
successes = snapshot[:recent_successes]
|
|
51
|
+
failures = snapshot[:recent_failures]
|
|
52
|
+
total = successes + failures
|
|
53
|
+
total.zero? ? 0 : successes.to_f / total
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def snapshot_for(value)
|
|
57
|
+
return unless value
|
|
58
|
+
|
|
59
|
+
value.respond_to?(:snapshot) ? value.snapshot : value
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Namespace for normalized delivery errors.
|
|
6
|
+
module Errors
|
|
7
|
+
# Errors that may be retried and sent to another provider.
|
|
8
|
+
FAILOVER_CATEGORIES = %i[network timeout rate_limited provider_unavailable].freeze
|
|
9
|
+
# Errors that may be retried against the same provider.
|
|
10
|
+
RETRYABLE_CATEGORIES = (FAILOVER_CATEGORIES + %i[authentication authorization]).freeze
|
|
11
|
+
|
|
12
|
+
# Base error raised by the sender runtime.
|
|
13
|
+
class Base < Core::Error
|
|
14
|
+
# @return [Symbol] normalized error category
|
|
15
|
+
attr_reader :category
|
|
16
|
+
# @return [Symbol, nil] associated provider
|
|
17
|
+
attr_reader :provider
|
|
18
|
+
|
|
19
|
+
# @param message [String] safe error message
|
|
20
|
+
# @param category [Symbol] normalized error category
|
|
21
|
+
# @param provider [Symbol, nil] provider associated with the error
|
|
22
|
+
def initialize(message = nil, category: nil, provider: nil)
|
|
23
|
+
@category = (category || inferred_category).to_sym
|
|
24
|
+
@provider = provider&.to_sym
|
|
25
|
+
super(message || @category.to_s)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Symbol] category inferred from the concrete error class
|
|
29
|
+
def inferred_category
|
|
30
|
+
name = self.class.name&.split("::")&.last
|
|
31
|
+
name ? name.gsub(/([a-z])([A-Z])/, '\\1_\\2').downcase.to_sym : :unknown
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] whether this error can be retried
|
|
35
|
+
def retryable?
|
|
36
|
+
RETRYABLE_CATEGORIES.include?(category)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] whether this error permits provider failover
|
|
40
|
+
def failover?
|
|
41
|
+
FAILOVER_CATEGORIES.include?(category)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Base class for errors originating from a provider or its transport.
|
|
46
|
+
class ProviderError < Base; end
|
|
47
|
+
# A network-level failure occurred before a response was received.
|
|
48
|
+
class Network < ProviderError; end
|
|
49
|
+
# The provider request exceeded its timeout.
|
|
50
|
+
class Timeout < ProviderError; end
|
|
51
|
+
# The provider rate-limited the request.
|
|
52
|
+
class RateLimited < ProviderError; end
|
|
53
|
+
# Provider authentication failed.
|
|
54
|
+
class Authentication < ProviderError; end
|
|
55
|
+
# Provider authorization failed.
|
|
56
|
+
class Authorization < ProviderError; end
|
|
57
|
+
# The outbound request was invalid.
|
|
58
|
+
class InvalidRequest < Base; end
|
|
59
|
+
# The recipient address was invalid.
|
|
60
|
+
class InvalidRecipient < Base; end
|
|
61
|
+
# The provider is unavailable and the request may be retried elsewhere.
|
|
62
|
+
class ProviderUnavailable < ProviderError; end
|
|
63
|
+
# The provider rejected the message for a request-specific reason.
|
|
64
|
+
class ProviderRejected < Base; end
|
|
65
|
+
# An error that could not be mapped to a more specific category.
|
|
66
|
+
class Unknown < Base; end
|
|
67
|
+
# A provider or provider configuration cannot be loaded or validated.
|
|
68
|
+
class ConfigurationError < Base; end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|