sms_traffic_sdk 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c877028062a36207636853033e3e623612a7ae44daf41d91686d86840f3b75c8
4
- data.tar.gz: bd57b25436c7ea9e76d0e68b213730e7f77cf271f28c170d70234a2080e38a97
3
+ metadata.gz: 7073f3fdf9537cf98008d5c6fac2bbc28457fbe4ef05852ccfd4e3b3a4606fbc
4
+ data.tar.gz: 7ad131b8ca90beebf7d4e227fa4031655bdc49d7e5db4f12c7ee11763cde8e0a
5
5
  SHA512:
6
- metadata.gz: 47f4123facc0dcb03f1fbf072cc266199df3fcf7483d6189fdfcee257988d188bec2607aae674575c9efb3153b36bdf69c9a6ac8c9af972bfdc878c0ca4d8577
7
- data.tar.gz: 96bf4d2e9ea69cc8d6aad0aa87165f57e88b5163b00fffd034af98ee269d87f257cffdce2731e5793bdb6c539f213f06420db805978591da118b3bcf5cf6cb6c
6
+ metadata.gz: f22009b2e84816ea0ad7294fd80ec8810dfc99c4ad46a7c66e9f9604f837c31fe5d6ae996639e776cb79501523b50257d20390069412993c704b2456ed3b5fb7
7
+ data.tar.gz: 4dc45cb30d25dd521d716f16bcb7a4ae530ee9fa3b8d14f0b8ec0cfde6a479c81d41268b1015a7518bdd16d79a11fc4f8c5663d2e8266dc7f34c1d0415202843
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-11-03
4
+
5
+ ### Added
6
+
7
+ - `Configuration#fallback_host` (default `nil`). When set, the client repeats a request once on that host after a
8
+ connection failure that proves the server was not reached (`SocketError`, `Errno::ECONNREFUSED`,
9
+ `Errno::EHOSTUNREACH`, `Errno::ENETUNREACH`, `Net::OpenTimeout`) or after API error 1000. `Client.status` and
10
+ `Client.balance` also retry after any `ConnectionError` and HTTP 5xx; `Client.deliver` never does, because the
11
+ provider may already have queued the SMS. With `debug`, the retry is logged in one line without credentials.
12
+ - `Client.balance` returns an `AccountReply`; `AccountReply#balance` is a `BigDecimal` (units as sent by the
13
+ provider). `bigdecimal` is a runtime dependency.
14
+ - `SmsTraffic::Status` (`FINAL`, `INTERMEDIATE`), `StatusReply::Entry#final?` / `#delivered?`, the same methods on
15
+ `StatusReply` (optional `sms_id`) and `Sms#final?` / `#delivered?`.
16
+ - `ApiError#retryable?`, `#access_denied?`, `#insufficient_funds?` and `#blocked_phone?`.
17
+
18
+ ### Fixed
19
+
20
+ - A provider reply with `<result>ERRO</result>` (the spelling in the provider's own example) is now an
21
+ `ApiError`. `Reply#api_error?` is true for any present `result` other than `OK`.
22
+ - `Sms#update_status` no longer records "No status returned" for an empty `<status>` element (an intermediate
23
+ provider status); the status stays `sent`. `StatusReply::Entry#status_reported?` tells an empty status from an
24
+ absent one.
25
+
3
26
  ## 0.1.0 - 2026-11-03
4
27
 
5
28
  ### Breaking changes
data/README.md CHANGED
@@ -13,6 +13,8 @@ sending of SMS messages and checking delivery statuses.
13
13
 
14
14
  - Send SMS messages to single or multiple recipients.
15
15
  - Check the delivery status of sent messages.
16
+ - Read the account balance.
17
+ - Fail over to the provider's backup host.
16
18
  - **TODO:** Support for various message formats and encodings.
17
19
 
18
20
 
@@ -44,6 +46,7 @@ SmsTraffic.configure do |config|
44
46
  config.login = 'login'
45
47
  config.password = 'password'
46
48
  config.host = 'https://api.smstraffic.ru'
49
+ config.fallback_host = 'https://api2.smstraffic.ru' # optional backup host, default nil
47
50
  config.originator = 'default_originator' # optional, can be given per SMS
48
51
  config.open_timeout = 10 # seconds, default 10
49
52
  config.read_timeout = 30 # seconds, default 30
@@ -96,7 +99,42 @@ reply.status('8287713301') # => 'Delivered'; the id may be omitted whe
96
99
  ```
97
100
 
98
101
  Each entry has `sms_id`, `status`, `error`, `submission_date`, `send_date`, `last_status_change_date`
99
- (strings as sent by the provider), `error?` and `not_found?`.
102
+ (strings as sent by the provider), `error?`, `not_found?`, `final?`, `delivered?` and `status_reported?`
103
+ (true when the provider sent a status element, even an empty one).
104
+
105
+ ### Final and delivered statuses
106
+
107
+ Provider statuses `Delivered`, `Non Delivered`, `Rejected`, `Expired`, `Deleted` and `Unknown status` are final;
108
+ an empty status and `Buffered SMSC` are intermediate (`SmsTraffic::Status::FINAL` / `INTERMEDIATE`).
109
+ Comparison is case-insensitive.
110
+
111
+ ```ruby
112
+ reply = SmsTraffic::Client.status('8287713301')
113
+ reply.final? # => true
114
+ reply.delivered? # => true; pass an sms id when the reply has several entries
115
+ reply.entry.final?
116
+
117
+ sms.update_status
118
+ sms.final? # => true for a final stored status; false for 'not-sent' and 'sent'
119
+ sms.delivered? # => true only for 'delivered'
120
+ ```
121
+
122
+ ### Balance
123
+
124
+ ```ruby
125
+ SmsTraffic::Client.balance.balance # => 0.10025e5 (BigDecimal), nil when absent or not a number
126
+ ```
127
+
128
+ The value is returned as the provider sends it; its units are not documented and are not converted.
129
+
130
+ ### Fallback host
131
+
132
+ Set `config.fallback_host` (the provider documents `https://api2.smstraffic.ru`) to repeat a failed request once
133
+ on the backup host. The request is repeated after `SocketError`, `Errno::ECONNREFUSED`, `Errno::EHOSTUNREACH`,
134
+ `Errno::ENETUNREACH`, `Net::OpenTimeout`, or API error 1000 (temporary server problem). `Client.status` and
135
+ `Client.balance` also retry after any `ConnectionError` (read timeout, reset connection, TLS failure) and HTTP 5xx.
136
+ `Client.deliver` does not, since the provider may already have queued the SMS. The error of the last attempt is raised. With `debug`, the retry is
137
+ logged in one line without credentials.
100
138
 
101
139
  ### Errors
102
140
 
@@ -104,7 +142,8 @@ All errors inherit from `SmsTraffic::Error`:
104
142
 
105
143
  - `SmsTraffic::ConnectionError` - network, timeout or TLS failure (the original exception is `cause`).
106
144
  - `SmsTraffic::HttpError` - non-2xx response; has `status` and `body`.
107
- - `SmsTraffic::ApiError` - the provider replied `ERROR`; has `code` and `description`.
145
+ - `SmsTraffic::ApiError` - the provider replied `ERROR` (or `ERRO`); has `code` and `description`, and the
146
+ predicates `retryable?` (1000), `access_denied?` (411, 412), `insufficient_funds?` (415) and `blocked_phone?` (432).
108
147
 
109
148
  ```ruby
110
149
  begin
@@ -0,0 +1,14 @@
1
+ require 'bigdecimal'
2
+
3
+ module SmsTraffic
4
+ class Client
5
+ class AccountReply < Reply
6
+ # @return [BigDecimal, nil] account balance as sent by the provider (units are not converted); nil when absent
7
+ # or not a number
8
+ def balance
9
+ value = fetch_value(to_h, :account).to_s.strip
10
+ BigDecimal(value, exception: false) unless value.empty?
11
+ end
12
+ end
13
+ end
14
+ end
@@ -1,8 +1,6 @@
1
1
  module SmsTraffic
2
2
  class Client
3
3
  class DeliverReply < Reply
4
- OK_RESULT = 'OK'.freeze
5
-
6
4
  # @return [Boolean] result is OK and at least one message was queued
7
5
  def ok?
8
6
  result == OK_RESULT && !messages.empty?
@@ -1,6 +1,7 @@
1
1
  module SmsTraffic
2
2
  class Client
3
3
  class Reply
4
+ OK_RESULT = 'OK'.freeze
4
5
  ERROR_RESULT = 'ERROR'.freeze
5
6
 
6
7
  attr_reader :xml, :to_h
@@ -23,8 +24,9 @@ module SmsTraffic
23
24
  fetch_value(to_h, :description)
24
25
  end
25
26
 
27
+ # The provider documents `ERROR` but its example replies `ERRO`, so any result other than OK is an error.
26
28
  def api_error?
27
- result == ERROR_RESULT
29
+ !result.nil? && result != OK_RESULT
28
30
  end
29
31
 
30
32
  private
@@ -4,8 +4,14 @@ module SmsTraffic
4
4
  NOT_FOUND = /no such message/i
5
5
 
6
6
  Entry = Struct.new(
7
- :sms_id, :status, :error, :submission_date, :send_date, :last_status_change_date, keyword_init: true
7
+ :sms_id, :status, :status_reported, :error, :submission_date, :send_date, :last_status_change_date,
8
+ keyword_init: true
8
9
  ) do
10
+ # @return [Boolean] the provider sent a status element, even an empty one (an intermediate status)
11
+ def status_reported?
12
+ status_reported == true
13
+ end
14
+
9
15
  def error?
10
16
  !error.nil?
11
17
  end
@@ -13,6 +19,14 @@ module SmsTraffic
13
19
  def not_found?
14
20
  error? && error.match?(NOT_FOUND)
15
21
  end
22
+
23
+ def final?
24
+ Status.final?(status)
25
+ end
26
+
27
+ def delivered?
28
+ Status.delivered?(status)
29
+ end
16
30
  end
17
31
 
18
32
  # @return [Array<Entry>]
@@ -33,7 +47,7 @@ module SmsTraffic
33
47
  define_method(name) { |sms_id = nil| entry(sms_id)&.public_send(name) }
34
48
  end
35
49
 
36
- %i[error? not_found?].each do |name|
50
+ %i[error? not_found? final? delivered?].each do |name|
37
51
  define_method(name) { |sms_id = nil| entry(sms_id)&.public_send(name) || false }
38
52
  end
39
53
 
@@ -57,6 +71,10 @@ module SmsTraffic
57
71
  to_h.empty? ? [] : [to_h]
58
72
  end
59
73
 
74
+ def key?(source, key)
75
+ source.is_a?(Hash) && (source.key?(key.to_s) || source.key?(key.to_sym))
76
+ end
77
+
60
78
  def blank_to_nil(value)
61
79
  stripped = value&.strip
62
80
  stripped unless stripped.nil? || stripped.empty?
@@ -66,6 +84,7 @@ module SmsTraffic
66
84
  Entry.new(
67
85
  sms_id: fetch_value(source, :sms_id),
68
86
  status: fetch_value(source, :status),
87
+ status_reported: key?(source, :status),
69
88
  error: blank_to_nil(fetch_value(source, :error)),
70
89
  # The provider spells this field "submition_date".
71
90
  submission_date: fetch_value(source, :submition_date),
@@ -4,6 +4,7 @@ require 'uri'
4
4
  require_relative 'client/reply'
5
5
  require_relative 'client/deliver_reply'
6
6
  require_relative 'client/status_reply'
7
+ require_relative 'client/account_reply'
7
8
 
8
9
  module SmsTraffic
9
10
  class Client
@@ -13,6 +14,10 @@ module SmsTraffic
13
14
  CONNECTION_ERRORS = [
14
15
  SocketError, SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
15
16
  ].freeze
17
+ # Failures that prove the request never reached the server.
18
+ UNREACHABLE_ERRORS = [
19
+ SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
20
+ ].freeze
16
21
 
17
22
  class << self
18
23
  # @param phone [String] phone number or phone numbers separated by comma
@@ -23,30 +28,65 @@ module SmsTraffic
23
28
  # @raise [ConnectionError, HttpError, ApiError]
24
29
  def deliver(phone, message, originator = nil, **options)
25
30
  originator ||= SmsTraffic.configuration.originator
26
- build_reply(DeliverReply, post(deliver_params(phone, message, originator, **options)))
31
+ request(DeliverReply, deliver_params(phone, message, originator, **options), read_only: false)
27
32
  end
28
33
 
29
34
  # @param sms_ids [Array<String>] sms ids
30
35
  # @return [StatusReply]
31
36
  # @raise [ConnectionError, HttpError, ApiError]
32
37
  def status(*sms_ids)
33
- build_reply(StatusReply, post(status_params(sms_ids)))
38
+ request(StatusReply, status_params(sms_ids), read_only: true)
39
+ end
40
+
41
+ # @return [AccountReply]
42
+ # @raise [ConnectionError, HttpError, ApiError]
43
+ def balance
44
+ request(AccountReply, account_params, read_only: true)
34
45
  end
35
46
 
36
47
  private
37
48
 
38
- def build_reply(reply_class, body)
39
- reply = reply_class.new(body)
49
+ # Sends the request to the configured host and, when a fallback host is set, repeats it there once if the
50
+ # failure allows it.
51
+ def request(reply_class, form_data, read_only:)
52
+ config = SmsTraffic.configuration
53
+ attempt(reply_class, form_data, config.host)
54
+ rescue Error => e
55
+ raise unless config.fallback_host && retry_on_fallback?(e, read_only:)
56
+
57
+ log("retry on #{config.fallback_host} after #{retry_reason(e)}")
58
+ attempt(reply_class, form_data, config.fallback_host)
59
+ end
60
+
61
+ # Names the root cause and, for an API error, its code; never the message text.
62
+ def retry_reason(error)
63
+ reason = (error.cause&.class || error.class).to_s
64
+ error.is_a?(ApiError) ? "#{reason} #{error.code}" : reason
65
+ end
66
+
67
+ def attempt(reply_class, form_data, host)
68
+ reply = reply_class.new(post(form_data, host))
40
69
  raise ApiError.new(reply.code, reply.description) if reply.api_error?
41
70
 
42
71
  reply
43
72
  end
44
73
 
74
+ # A read-only request is safe to repeat after any transport failure. A deliver is repeated only when the
75
+ # request never reached the server, as the provider may already have queued the SMS otherwise.
76
+ def retry_on_fallback?(error, read_only:)
77
+ case error
78
+ when ApiError then error.retryable?
79
+ when ConnectionError then read_only || UNREACHABLE_ERRORS.any? { |klass| error.cause.is_a?(klass) }
80
+ when HttpError then read_only && error.status >= 500
81
+ else false
82
+ end
83
+ end
84
+
45
85
  # @param form_data [Hash<Symbol, String|Integer>]
46
86
  # @return [String] response body
47
- def post(form_data)
87
+ def post(form_data, host)
48
88
  config = SmsTraffic.configuration
49
- uri = URI.join(config.host, ENDPOINT)
89
+ uri = URI.join(host, ENDPOINT)
50
90
  log("request POST #{uri} #{filter(form_data)}")
51
91
 
52
92
  response = perform(uri, form_data, config)
@@ -94,12 +134,15 @@ module SmsTraffic
94
134
  end
95
135
 
96
136
  def status_params(sms_ids)
97
- {
98
- login: SmsTraffic.configuration.login,
99
- password: SmsTraffic.configuration.password,
100
- operation: 'status',
101
- sms_id: sms_ids.join(',')
102
- }
137
+ operation_params('status').merge(sms_id: sms_ids.join(','))
138
+ end
139
+
140
+ def account_params
141
+ operation_params('account')
142
+ end
143
+
144
+ def operation_params(operation)
145
+ { login: SmsTraffic.configuration.login, password: SmsTraffic.configuration.password, operation: }
103
146
  end
104
147
  end
105
148
  end
@@ -3,9 +3,12 @@ require 'nori'
3
3
  module SmsTraffic
4
4
  class Configuration
5
5
  ATTRIBUTES = %i[
6
- login password host originator debug logger open_timeout read_timeout validate_phone xml_parser
6
+ login password host fallback_host originator debug logger open_timeout read_timeout validate_phone xml_parser
7
7
  ].freeze
8
8
 
9
+ # Attributes not listed here default to nil.
10
+ DEFAULTS = { debug: false, open_timeout: 10, read_timeout: 30, validate_phone: true }.freeze
11
+
9
12
  attr_accessor(*ATTRIBUTES)
10
13
 
11
14
  def initialize
@@ -13,16 +16,8 @@ module SmsTraffic
13
16
  end
14
17
 
15
18
  def reset!
16
- self.login = nil
17
- self.password = nil
18
- self.host = nil
19
- self.originator = nil
20
- self.debug = false
21
- self.logger = nil
22
- self.open_timeout = 10
23
- self.read_timeout = 30
24
- self.validate_phone = true
25
- self.xml_parser = Nori.new(advanced_typecasting: false)
19
+ ATTRIBUTES.each { |attribute| public_send("#{attribute}=", DEFAULTS[attribute]) }
20
+ self.xml_parser = Nori.new(advanced_typecasting: false)
26
21
  end
27
22
 
28
23
  def validate!
@@ -15,7 +15,7 @@ module SmsTraffic
15
15
  end
16
16
  end
17
17
 
18
- # HTTP 200 reply whose result is ERROR.
18
+ # HTTP 200 reply whose `result` is not `OK` (`ERROR`, `ERRO`).
19
19
  class ApiError < Error
20
20
  attr_reader :code, :description
21
21
 
@@ -24,5 +24,22 @@ module SmsTraffic
24
24
  @description = description
25
25
  super("#{code}: #{description}")
26
26
  end
27
+
28
+ # Temporary server problem: retry later or use the backup host.
29
+ def retryable?
30
+ code.to_i == 1000
31
+ end
32
+
33
+ def access_denied?
34
+ [411, 412].include?(code.to_i)
35
+ end
36
+
37
+ def insufficient_funds?
38
+ code.to_i == 415
39
+ end
40
+
41
+ def blocked_phone?
42
+ code.to_i == 432
43
+ end
27
44
  end
28
45
  end
@@ -37,6 +37,16 @@ module SmsTraffic
37
37
  status
38
38
  end
39
39
 
40
+ # @return [Boolean] the stored status is a final provider status
41
+ def final?
42
+ Status.final?(status)
43
+ end
44
+
45
+ # @return [Boolean] the stored status is Delivered
46
+ def delivered?
47
+ Status.delivered?(status)
48
+ end
49
+
40
50
  private
41
51
 
42
52
  def mark_sent
@@ -53,6 +63,9 @@ module SmsTraffic
53
63
  add_error(entry.error)
54
64
  elsif entry&.status
55
65
  @status = entry.status.downcase
66
+ elsif entry&.status_reported?
67
+ # An empty status element is an intermediate provider status: keep the current one.
68
+ nil
56
69
  else
57
70
  add_error("No status returned for sms #{id}")
58
71
  end
@@ -0,0 +1,26 @@
1
+ module SmsTraffic
2
+ # Provider delivery statuses (HTTP API v1, operation=status).
3
+ module Status
4
+ INTERMEDIATE = ['', 'Buffered SMSC'].freeze
5
+ DELIVERED = 'Delivered'.freeze
6
+ FINAL = [DELIVERED, 'Non Delivered', 'Rejected', 'Expired', 'Deleted', 'Unknown status'].freeze
7
+
8
+ class << self
9
+ # @param status [String, nil] provider status, compared case-insensitively
10
+ def final?(status)
11
+ matches?(FINAL, status)
12
+ end
13
+
14
+ def delivered?(status)
15
+ matches?([DELIVERED], status)
16
+ end
17
+
18
+ private
19
+
20
+ def matches?(list, status)
21
+ value = status.to_s.strip
22
+ list.any? { |item| item.casecmp?(value) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,3 @@
1
1
  module SmsTraffic
2
- VERSION = '0.1.0'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
  end
data/lib/sms_traffic.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require_relative 'sms_traffic/version'
2
2
  require_relative 'sms_traffic/errors'
3
+ require_relative 'sms_traffic/status'
3
4
  require_relative 'sms_traffic/configuration'
4
5
  require_relative 'sms_traffic/client'
5
6
  require_relative 'sms_traffic/sms'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sms_traffic_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Rybolovlev
@@ -10,6 +10,20 @@ bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bigdecimal
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.1'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: nokogiri
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -53,12 +67,14 @@ files:
53
67
  - README.md
54
68
  - lib/sms_traffic.rb
55
69
  - lib/sms_traffic/client.rb
70
+ - lib/sms_traffic/client/account_reply.rb
56
71
  - lib/sms_traffic/client/deliver_reply.rb
57
72
  - lib/sms_traffic/client/reply.rb
58
73
  - lib/sms_traffic/client/status_reply.rb
59
74
  - lib/sms_traffic/configuration.rb
60
75
  - lib/sms_traffic/errors.rb
61
76
  - lib/sms_traffic/sms.rb
77
+ - lib/sms_traffic/status.rb
62
78
  - lib/sms_traffic/version.rb
63
79
  - lib/sms_traffic_sdk.rb
64
80
  homepage: https://github.com/golifox/sms_traffic_sdk