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 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.
@@ -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