clicksend 1.0.0 → 1.1.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.
@@ -3,8 +3,79 @@
3
3
  require "time"
4
4
 
5
5
  module Clicksend
6
+ # What a failed (or successful) API call was: available as Error#request and
7
+ # Response#request. Safe to log: +path+ never includes the query string, and
8
+ # nothing here holds credentials, headers or bodies.
9
+ #
10
+ # +http_method+ is a lower-case Symbol (:get, :post, ...); +operation+ names the
11
+ # wrapped method that made the call (e.g. "sms.deliver"), or is whatever was
12
+ # passed to Client#request (nil by default); +attempts+ counts HTTP attempts,
13
+ # so it is 1 when nothing was retried.
14
+ RequestInfo = Data.define(:http_method, :path, :operation, :idempotent, :attempts) do
15
+ def to_s
16
+ "#{http_method.to_s.upcase} #{path}"
17
+ end
18
+
19
+ def inspect
20
+ "#<#{self.class.name} #{self} operation=#{operation.inspect} idempotent=#{idempotent} attempts=#{attempts}>"
21
+ end
22
+ end
23
+
6
24
  # Base class for every error raised by this gem.
7
- class Error < StandardError; end
25
+ class Error < StandardError
26
+ # @return [Clicksend::RequestInfo, nil] the API call that failed, when the
27
+ # error came from one
28
+ attr_reader :request
29
+
30
+ # @api private Set by the connection before the error is raised.
31
+ attr_writer :request
32
+
33
+ # Whether repeating the same request later is both safe (it cannot cause a
34
+ # second side effect, such as a second SMS) and might succeed.
35
+ #
36
+ # True for a 429, for connection failures that never reached ClickSend,
37
+ # and for connection failures and 5xx responses on idempotent requests.
38
+ # False for everything else, and always false when #ambiguous?.
39
+ def retryable?
40
+ false
41
+ end
42
+
43
+ # True when a request that is not safe to repeat (such as an SMS send) may
44
+ # or may not have been processed by ClickSend. Such errors are also
45
+ # Clicksend::AmbiguousRequestError, so they can be rescued as one.
46
+ def ambiguous?
47
+ is_a?(AmbiguousRequestError)
48
+ end
49
+
50
+ # @api private Marks this error as Clicksend::AmbiguousRequestError.
51
+ # (A +dup+ of the error is a fresh copy without the mark; +clone+ keeps it.)
52
+ def mark_ambiguous!
53
+ extend(AmbiguousRequestError)
54
+ end
55
+
56
+ # The message, followed by the request it came from, e.g.
57
+ # "HTTP 500 (POST /v3/sms/send)".
58
+ def to_s
59
+ request ? "#{super} (#{request})" : super
60
+ end
61
+ end
62
+
63
+ # Extended onto an error when a request that is not safe to repeat may have
64
+ # been processed: a timeout or connection failure after the request may have
65
+ # been written, a 5xx, an error reported inside a 2xx body, or a 2xx
66
+ # response that could not be read. The error keeps its class, so
67
+ #
68
+ # rescue Clicksend::AmbiguousRequestError => e
69
+ #
70
+ # catches every unknown-outcome failure, and existing rescues of
71
+ # TimeoutError, ServerError, ... keep working.
72
+ #
73
+ # For an SMS send it means the message may or may not have been accepted.
74
+ # The gem never retries it; see the README on reconciling with
75
+ # Resources::SMS#history before sending again. Error#ambiguous? is the
76
+ # predicate.
77
+ module AmbiguousRequestError
78
+ end
8
79
 
9
80
  # Missing or invalid client configuration (e.g. no API key).
10
81
  class ConfigurationError < Error; end
@@ -24,6 +95,12 @@ module Clicksend
24
95
  def request_may_have_been_sent?
25
96
  @request_sent != false
26
97
  end
98
+
99
+ def retryable?
100
+ return false if ambiguous?
101
+
102
+ !request_may_have_been_sent? || request&.idempotent == true
103
+ end
27
104
  end
28
105
 
29
106
  # Opening the connection or reading the response took longer than the
@@ -58,6 +135,12 @@ module Clicksend
58
135
  @body = body
59
136
  end
60
137
 
138
+ # Rate-limit headers sent with this response, if any. See Clicksend::RateLimit.
139
+ # @return [Clicksend::RateLimit, nil]
140
+ def rate_limit
141
+ RateLimit.from_headers(headers)
142
+ end
143
+
61
144
  private
62
145
 
63
146
  def default_message(http_status, response_code, response_msg)
@@ -81,7 +164,9 @@ module Clicksend
81
164
  # 404: the resource does not exist.
82
165
  class NotFoundError < ClientError; end
83
166
 
84
- # 429: rate limited. ClickSend did not process the request.
167
+ # 429: rate limited. ClickSend documents this as a request that "cannot be
168
+ # served", so it is treated as not processed (an inference, not a documented
169
+ # guarantee).
85
170
  class RateLimitError < ClientError
86
171
  # Seconds to wait before retrying, from the Retry-After header, if any.
87
172
  def retry_after
@@ -95,15 +180,26 @@ module Clicksend
95
180
  nil
96
181
  end
97
182
  end
183
+
184
+ def retryable?
185
+ !ambiguous?
186
+ end
98
187
  end
99
188
 
100
189
  # 5xx responses.
101
- class ServerError < APIError; end
190
+ class ServerError < APIError
191
+ def retryable?
192
+ !ambiguous? && request&.idempotent == true
193
+ end
194
+ end
102
195
 
103
196
  # A single message sent with Clicksend::Resources::SMS#deliver was not
104
197
  # accepted (its per-message status was not "SUCCESS"), even though the HTTP
105
198
  # request itself succeeded. Batch sends never raise this; inspect
106
199
  # Clicksend::SMS::Batch#rejected instead.
200
+ #
201
+ # Not #retryable?: ClickSend decided. (A "THROTTLED" status means an identical
202
+ # message was sent to the same recipient moments ago.)
107
203
  class MessageRejected < Error
108
204
  # The Clicksend::SMS::Message describing the rejected message.
109
205
  attr_reader :result
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksend
4
+ # Instrumentation hooks. Pass any object that responds to
5
+ #
6
+ # instrument(name, payload) { |payload| ... }
7
+ #
8
+ # as Client.new(instrumenter:). That is ActiveSupport::Notifications'
9
+ # signature, so a Rails application can pass ActiveSupport::Notifications
10
+ # itself and subscribe:
11
+ #
12
+ # ActiveSupport::Notifications.subscribe("request.clicksend") do |event|
13
+ # event.payload # => {http_method: :post, path: "/v3/sms/send", operation: "sms.deliver", ...}
14
+ # end
15
+ #
16
+ # Events:
17
+ #
18
+ # [request.clicksend] Wraps one logical API call, retries included. The
19
+ # payload has +:http_method+, +:path+, +:operation+ and +:idempotent+ when the
20
+ # block starts; when it ends, +:attempts+, +:http_status+ (nil if no
21
+ # response was received), +:response_code+ and +:ambiguous+ are added. If
22
+ # the call raised, ActiveSupport adds +:exception+ / +:exception_object+.
23
+ # [retry.clicksend] Published (without a block) before each retry, with
24
+ # +:http_method+, +:path+, +:operation+, +:attempt+ (1 for the first retry),
25
+ # +:delay+ (seconds), +:error_class+ and +:http_status+.
26
+ #
27
+ # Payloads never contain credentials, headers, query strings, request or
28
+ # response bodies, phone numbers or message text. +path+ is the request path
29
+ # without its query string; for some endpoints it includes a message ID.
30
+ #
31
+ # The instrumenter is called on the caller's thread and must be thread-safe.
32
+ module Instrumentation
33
+ # The default: runs the block and publishes nothing.
34
+ module Null
35
+ module_function
36
+
37
+ def instrument(_name, payload = {})
38
+ yield payload if block_given?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -32,6 +32,15 @@ module Clicksend
32
32
  end
33
33
  end
34
34
 
35
+ # An identifier or code that ClickSend sends as either a String or an
36
+ # Integer (e.g. list_id, history error_code) -> String
37
+ def code(value)
38
+ case value
39
+ when String then value
40
+ when Integer then value.to_s
41
+ end
42
+ end
43
+
35
44
  # Unix timestamp (Integer or numeric String) -> UTC Time
36
45
  def time(value)
37
46
  integer(value)&.then { |seconds| Time.at(seconds).utc }
@@ -23,7 +23,7 @@ module Clicksend
23
23
  # @api private Use Client#paginate or a resource method.
24
24
  #
25
25
  # @yieldparam item [Hash] a raw item, to be converted into a model
26
- def self.fetch(client, path, query: {}, page: nil, limit: nil, &build_item)
26
+ def self.fetch(client, path, query: {}, page: nil, limit: nil, operation: nil, &build_item)
27
27
  if page && !(page.is_a?(Integer) && page.positive?)
28
28
  raise ArgumentError, "page must be a positive Integer"
29
29
  end
@@ -32,9 +32,12 @@ module Clicksend
32
32
  end
33
33
 
34
34
  query = query.transform_keys(&:to_s)
35
- response = client.request(:get, path, query: query.merge("page" => page, "limit" => limit).compact)
36
- fetch_page = ->(number) { fetch(client, path, query: query, page: number, limit: limit, &build_item) }
35
+ response = client.request(:get, path, query: query.merge("page" => page, "limit" => limit).compact, operation: operation)
36
+ fetch_page = ->(number) { fetch(client, path, query: query, page: number, limit: limit, operation: operation, &build_item) }
37
37
  from_response(response, fetch_page, &build_item)
38
+ rescue MalformedResponseError => e
39
+ e.request ||= response&.request
40
+ raise
38
41
  end
39
42
 
40
43
  def self.from_response(response, fetch_page, &build_item)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksend
4
+ # Rate-limit counters ClickSend sent with a response, as exposed by
5
+ # Response#rate_limit and APIError#rate_limit.
6
+ #
7
+ # ClickSend does not document its rate limits or these headers. They were
8
+ # observed on GET /v3/account (2026-10-05): +x-ratelimit-limit+,
9
+ # +x-ratelimit-remaining+ and +ratelimit-reset+ (seconds until the window
10
+ # resets). Treat every field as advisory and possibly nil; nil overall means
11
+ # none of the headers were present.
12
+ #
13
+ # *Experimental*: because the headers are undocumented, this may change in
14
+ # a minor release if ClickSend changes them.
15
+ RateLimit = Data.define(:limit, :remaining, :reset_in)
16
+
17
+ class RateLimit
18
+ HEADERS = {
19
+ limit: "x-ratelimit-limit",
20
+ remaining: "x-ratelimit-remaining",
21
+ reset_in: "ratelimit-reset"
22
+ }.freeze
23
+
24
+ # @api private Use Response#rate_limit or APIError#rate_limit.
25
+ # @param headers [Hash] lower-cased response headers
26
+ # @return [Clicksend::RateLimit, nil]
27
+ def self.from_headers(headers)
28
+ return unless headers.is_a?(Hash)
29
+
30
+ values = HEADERS.transform_values { |name| non_negative_integer(headers[name]) }
31
+ new(**values) unless values.values.all?(&:nil?)
32
+ end
33
+
34
+ def self.non_negative_integer(value)
35
+ text = value.to_s.strip
36
+ Integer(text, 10) if text.match?(/\A\d{1,9}\z/)
37
+ end
38
+ private_class_method :non_negative_integer
39
+ end
40
+ end
@@ -15,7 +15,7 @@ module Clicksend
15
15
  #
16
16
  # @return [Clicksend::Account]
17
17
  def fetch
18
- Clicksend::Account.from_api(@client.request(:get, "/v3/account").data)
18
+ Clicksend::Account.from_api(@client.request(:get, "/v3/account", operation: "account.fetch").data)
19
19
  end
20
20
 
21
21
  def inspect
@@ -11,6 +11,7 @@ module Clicksend
11
11
  ].freeze
12
12
  DEFAULT_FIELDS = (MESSAGE_FIELDS - %i[to list_id body]).freeze
13
13
  MESSAGE_ID = /\A[A-Za-z0-9-]+\z/
14
+ HISTORY_ORDERS = %i[asc desc].freeze
14
15
 
15
16
  def initialize(client)
16
17
  @client = client
@@ -26,9 +27,9 @@ module Clicksend
26
27
  # succeeded.
27
28
  #
28
29
  # Sends are never retried after a timeout or 5xx, because ClickSend has
29
- # no idempotency key and the message may already have been accepted. A
30
- # Clicksend::TimeoutError therefore means "unknown": reconcile using
31
- # +custom_string+ before sending again.
30
+ # no idempotency key and the message may already have been accepted.
31
+ # Such failures are Clicksend::AmbiguousRequestError ("unknown"):
32
+ # reconcile with #history and your +custom_string+ before sending again.
32
33
  #
33
34
  # @param to [String] recipient in E.164 format, e.g. "+61411111111"
34
35
  # @param body [String] message text (Unicode is detected by ClickSend)
@@ -40,16 +41,20 @@ module Clicksend
40
41
  def deliver(to:, body:, from: nil, schedule: nil, custom_string: nil, country: nil, source: nil, from_email: nil, shorten_urls: nil)
41
42
  message = {to: to, body: body, from: from, schedule: schedule, custom_string: custom_string,
42
43
  country: country, source: source, from_email: from_email}
43
- batch = submit([normalize_message(message, nil)], shorten_urls)
44
+ batch, request = submit([normalize_message(message, nil)], shorten_urls, "sms.deliver")
44
45
  unless batch.messages.size == 1
45
- raise MalformedResponseError.new(
46
+ raise ambiguous(MalformedResponseError.new(
46
47
  "Expected one message result, got #{batch.messages.size} (blocked_count: #{batch.blocked_count.inspect})",
47
48
  body: batch.raw
48
- )
49
+ ), request)
49
50
  end
50
51
 
51
52
  result = batch.messages.first
52
- raise MessageRejected, result if result.rejected?
53
+ if result.rejected?
54
+ rejected = MessageRejected.new(result)
55
+ rejected.request = request
56
+ raise rejected
57
+ end
53
58
 
54
59
  result
55
60
  end
@@ -87,7 +92,7 @@ module Clicksend
87
92
 
88
93
  normalize_message(defaults.merge(message.transform_keys(&:to_sym)), index)
89
94
  end
90
- submit(normalized, shorten_urls)
95
+ submit(normalized, shorten_urls, "sms.deliver_batch").first
91
96
  end
92
97
 
93
98
  # Unread delivery receipts (GET /v3/sms/receipts).
@@ -101,21 +106,27 @@ module Clicksend
101
106
  #
102
107
  # @return [Clicksend::Page<Clicksend::SMS::Receipt>]
103
108
  def receipts(page: nil, limit: nil)
104
- Page.fetch(@client, "/v3/sms/receipts", page: page, limit: limit) { |item| Clicksend::SMS::Receipt.from_api(item) }
109
+ Page.fetch(@client, "/v3/sms/receipts", page: page, limit: limit, operation: "sms.receipts") { |item| Clicksend::SMS::Receipt.from_api(item) }
105
110
  end
106
111
 
107
112
  # One delivery receipt, read or not (GET /v3/sms/receipts/{message_id}).
108
113
  # @return [Clicksend::SMS::Receipt]
109
114
  def receipt(message_id)
110
- Clicksend::SMS::Receipt.from_api(@client.request(:get, "/v3/sms/receipts/#{message_id!(message_id)}").data)
115
+ Clicksend::SMS::Receipt.from_api(@client.request(:get, "/v3/sms/receipts/#{message_id!(message_id)}", operation: "sms.receipt").data)
111
116
  end
112
117
 
113
- # Marks delivery receipts as read (PUT /v3/sms/receipts-read): all of
114
- # them, or only those before +before+.
118
+ # Marks delivery receipts as read (PUT /v3/sms/receipts-read): only those
119
+ # before +before+, or, without it, every unread receipt at the moment
120
+ # ClickSend processes the call, including any that arrived after you
121
+ # last listed them. Prefer passing +before+.
122
+ #
123
+ # With +before+ the call is safe to repeat and is retried like a GET.
124
+ # Without it, it is not retried after a timeout or 5xx: a second "mark
125
+ # everything" could hide receipts that arrived in between.
115
126
  # @param before [Time, Integer, nil]
116
127
  # @return [nil]
117
128
  def mark_receipts_read(before: nil)
118
- @client.request(:put, "/v3/sms/receipts-read", body: date_before(before), idempotent: true)
129
+ @client.request(:put, "/v3/sms/receipts-read", body: date_before(before), idempotent: !before.nil?, operation: "sms.mark_receipts_read")
119
130
  nil
120
131
  end
121
132
 
@@ -126,24 +137,71 @@ module Clicksend
126
137
  #
127
138
  # @return [Clicksend::Page<Clicksend::SMS::InboundMessage>]
128
139
  def inbound(page: nil, limit: nil)
129
- Page.fetch(@client, "/v3/sms/inbound", page: page, limit: limit) { |item| Clicksend::SMS::InboundMessage.from_api(item) }
140
+ Page.fetch(@client, "/v3/sms/inbound", page: page, limit: limit, operation: "sms.inbound") { |item| Clicksend::SMS::InboundMessage.from_api(item) }
130
141
  end
131
142
 
132
- # Marks inbound SMS as read (PUT /v3/sms/inbound-read): all of them, or
133
- # only those before +before+.
143
+ # Marks inbound SMS as read (PUT /v3/sms/inbound-read): only those before
144
+ # +before+, or, without it, every unread inbound message at the moment
145
+ # ClickSend processes the call. Retried like #mark_receipts_read: only
146
+ # with +before+.
134
147
  # @return [nil]
135
148
  def mark_inbound_read(before: nil)
136
- @client.request(:put, "/v3/sms/inbound-read", body: date_before(before), idempotent: true)
149
+ @client.request(:put, "/v3/sms/inbound-read", body: date_before(before), idempotent: !before.nil?, operation: "sms.mark_inbound_read")
137
150
  nil
138
151
  end
139
152
 
140
153
  # Marks one inbound SMS as read (PUT /v3/sms/inbound-read/{message_id}).
141
154
  # @return [nil]
142
155
  def mark_inbound_message_read(message_id)
143
- @client.request(:put, "/v3/sms/inbound-read/#{message_id!(message_id)}", idempotent: true)
156
+ @client.request(:put, "/v3/sms/inbound-read/#{message_id!(message_id)}", idempotent: true, operation: "sms.mark_inbound_message_read")
144
157
  nil
145
158
  end
146
159
 
160
+ # Sent and received SMS (GET /v3/sms/history), as a Page of
161
+ # Clicksend::SMS::HistoryRecord, oldest first unless +order: :desc+.
162
+ #
163
+ # client.sms.history(to: "+61411111111", date_from: 10.minutes.ago).auto_paging_each do |record|
164
+ # record.custom_string
165
+ # end
166
+ #
167
+ # For history ClickSend documents a single +q=field:value+ filter (its
168
+ # general search docs describe more, but not for this endpoint), so at
169
+ # most one of +to+, +from+, +status+ and +message_id+ may be given.
170
+ # +custom_string+ is not a documented filter: match on it yourself.
171
+ #
172
+ # This is the closest documented way to look for an ambiguous send (an
173
+ # AmbiguousRequestError from #deliver). ClickSend does not document how
174
+ # soon a sent message appears here, so a message missing from history is
175
+ # not proof that it was not sent.
176
+ #
177
+ # @param date_from [Time, Integer, nil] earliest send time
178
+ # @param date_to [Time, Integer, nil] latest send time
179
+ # @param status [String, nil] a history status such as "Failed"
180
+ # @param order [:asc, :desc] by date
181
+ # @return [Clicksend::Page<Clicksend::SMS::HistoryRecord>]
182
+ def history(date_from: nil, date_to: nil, to: nil, from: nil, status: nil, message_id: nil, order: :asc, page: nil, limit: nil)
183
+ filters = {to: to, from: from, status: status, message_id: message_id}.compact
184
+ if filters.size > 1
185
+ raise ArgumentError, "pass at most one of to:, from:, status: and message_id: (ClickSend documents a single q=field:value filter)"
186
+ end
187
+ filters.each do |name, value|
188
+ unless value.is_a?(String) && !value.empty? && value == value.strip && !value.match?(/[,:[:cntrl:]]/)
189
+ raise ArgumentError, "#{name} must be a non-empty String without commas, colons or surrounding spaces"
190
+ end
191
+ end
192
+ raise ArgumentError, "order must be :asc or :desc" unless HISTORY_ORDERS.include?(order)
193
+
194
+ query = {
195
+ "date_from" => (unix_time(date_from, "date_from") unless date_from.nil?),
196
+ "date_to" => (unix_time(date_to, "date_to") unless date_to.nil?),
197
+ "order_by" => "date:#{order}",
198
+ "q" => filters.first&.then { |name, value| "#{name}:#{value}" }
199
+ }.compact
200
+ Page.fetch(@client, "/v3/sms/history", query: query, page: page, limit: limit, operation: "sms.history") do |item|
201
+ Clicksend::SMS::HistoryRecord.from_api(item)
202
+ end
203
+ end
204
+
147
205
  def inspect
148
206
  "#<#{self.class.name}>"
149
207
  end
@@ -161,10 +219,30 @@ module Clicksend
161
219
  before.nil? ? {} : {date_before: unix_time(before, "before")}
162
220
  end
163
221
 
164
- def submit(messages, shorten_urls)
222
+ # @return [Array(Clicksend::SMS::Batch, Clicksend::RequestInfo)]
223
+ def submit(messages, shorten_urls, operation)
165
224
  body = {messages: messages}
166
225
  body[:shorten_urls] = shorten_urls unless shorten_urls.nil?
167
- Clicksend::SMS::Batch.from_api(@client.request(:post, "/v3/sms/send", body: body).data)
226
+ response = @client.request(:post, "/v3/sms/send", body: body, operation: operation)
227
+ begin
228
+ batch = Clicksend::SMS::Batch.from_api(response.data)
229
+ # Without a readable per-message status there is no telling a
230
+ # rejection from an accepted message. Reporting it as "rejected"
231
+ # could lead a caller to send it again.
232
+ unless batch.messages.all? { |message| message.status.is_a?(String) && !message.status.empty? }
233
+ raise MalformedResponseError.new("A message in the send result has no status", body: batch.raw)
234
+ end
235
+ [batch, response.request]
236
+ rescue MalformedResponseError => e
237
+ # ClickSend answered 2xx, so the messages may well have been queued.
238
+ raise ambiguous(e, response.request)
239
+ end
240
+ end
241
+
242
+ def ambiguous(error, request)
243
+ error.request ||= request
244
+ error.mark_ambiguous!
245
+ error
168
246
  end
169
247
 
170
248
  def normalize_message(message, index)
@@ -9,7 +9,36 @@ module Clicksend
9
9
  # {"http_code": 200, "response_code": "SUCCESS", "response_msg": "...", "data": {...}}
10
10
  #
11
11
  # The envelope readers return nil when the body doesn't follow that shape.
12
+ #
13
+ # #request (a Clicksend::RequestInfo) says which call this was and how many
14
+ # attempts it took. It is deliberately not a Data member, so equality,
15
+ # +to_h+ and pattern matching (+in [status, headers, body]+,
16
+ # +in {http_status:}+) behave exactly as in 1.0.
12
17
  Response = Data.define(:http_status, :headers, :body) do
18
+ def initialize(http_status:, headers:, body:, request: nil)
19
+ @request = request
20
+ super(http_status: http_status, headers: headers, body: body)
21
+ end
22
+
23
+ # @return [Clicksend::RequestInfo, nil]
24
+ attr_reader :request
25
+
26
+ # Like Data#with, keeping #request unless a new one is given.
27
+ def with(**changes)
28
+ super(request: request, **changes)
29
+ end
30
+
31
+ # Marshal (e.g. Rails.cache) must rebuild the object through #initialize:
32
+ # a frozen Data instance can't receive #request afterwards.
33
+ def _dump(_level)
34
+ Marshal.dump([to_h, request])
35
+ end
36
+
37
+ def self._load(data)
38
+ members, request = Marshal.load(data) # rubocop:disable Security/MarshalLoad
39
+ new(**members, request: request)
40
+ end
41
+
13
42
  # The envelope's +data+ member.
14
43
  def data
15
44
  envelope("data")
@@ -25,6 +54,12 @@ module Clicksend
25
54
  envelope("response_msg")
26
55
  end
27
56
 
57
+ # Rate-limit headers sent with this response, if any. See Clicksend::RateLimit.
58
+ # @return [Clicksend::RateLimit, nil]
59
+ def rate_limit
60
+ RateLimit.from_headers(headers)
61
+ end
62
+
28
63
  def inspect
29
64
  "#<#{self.class.name} http_status=#{http_status} response_code=#{response_code.inspect}>"
30
65
  end
@@ -1,59 +1,76 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clicksend
4
- # Decides whether a failed attempt may be retried, and after how long.
5
- # @api private Configure it through Client.new(max_retries:).
4
+ # Decides how long to wait before retrying, and whether the retry budget
5
+ # allows another attempt. Pass one to Client.new(retry_policy:) to tune it:
6
6
  #
7
- # ClickSend's send endpoints accept no idempotency key, so retrying a send
8
- # that may already have reached ClickSend could deliver the message twice.
9
- # The rules are therefore:
7
+ # Clicksend::Client.new(retry_policy: Clicksend::RetryPolicy.new(max_retries: 4, max_retry_after: 10))
10
8
  #
11
- # * 429 Too Many Requests: ClickSend did not process the request, so it is
12
- # retried for every method, honouring Retry-After (up to +max_retry_after+).
9
+ # It does not decide *whether a failure is safe to retry*. That rule is
10
+ # fixed in the connection, so no policy can make a send repeat after a
11
+ # failure that may already have reached ClickSend:
12
+ #
13
+ # * 429 Too Many Requests: retried for every method (ClickSend documents it
14
+ # as a request that "cannot be served").
13
15
  # * Connection failures known to happen before the request was sent
14
16
  # (refused, DNS, connect timeout): retried for every method.
15
- # * Read timeouts, resets and 5xx responses: retried only when the request
16
- # is idempotent. By default only GET requests are.
17
+ # * Read timeouts, resets, TLS errors and 5xx responses: retried only when
18
+ # the request is idempotent (GET, and mark-read calls with a cutoff).
19
+ # * Anything else, including an error reported inside a 2xx body: never.
20
+ #
21
+ # Only for failures in the first three groups is #delay asked. Any object
22
+ # with +max_retries+ and +delay(error:, attempt:)+ can stand in for this
23
+ # class; it must be thread-safe, as one client is shared across threads.
17
24
  class RetryPolicy
18
- attr_reader :max_retries
25
+ attr_reader :max_retries, :base_delay, :max_delay, :max_retry_after
19
26
 
27
+ # @param max_retries [Integer] retries after the first attempt; 0 disables retrying
28
+ # @param base_delay [Numeric] seconds; backoff ceiling for the first retry
29
+ # @param max_delay [Numeric] seconds; the backoff ceiling never exceeds it
30
+ # @param max_retry_after [Numeric] longest Retry-After (seconds) worth
31
+ # waiting for; a longer one is raised as RateLimitError instead of
32
+ # blocking the caller
20
33
  def initialize(max_retries: 2, base_delay: 0.5, max_delay: 8.0, max_retry_after: 30, random: Random)
34
+ unless max_retries.is_a?(Integer) && max_retries >= 0
35
+ raise ConfigurationError, "max_retries must be a non-negative Integer"
36
+ end
37
+ {base_delay: base_delay, max_delay: max_delay, max_retry_after: max_retry_after}.each do |name, value|
38
+ raise ConfigurationError, "#{name} must be a non-negative number of seconds" unless value.is_a?(Numeric) && value >= 0
39
+ end
40
+
21
41
  @max_retries = max_retries
22
42
  @base_delay = base_delay
23
43
  @max_delay = max_delay
24
44
  @max_retry_after = max_retry_after
25
45
  @random = random
46
+ freeze
26
47
  end
27
48
 
28
- # @param error [Clicksend::Error] the failure of attempt number +attempt+ (0-based)
49
+ # @param error [Clicksend::Error] the failure of attempt number +attempt+
50
+ # (0-based); already known to be safe to retry
29
51
  # @return [Numeric, nil] seconds to wait before retrying, or nil to give up
30
- def delay(error:, attempt:, idempotent:)
52
+ def delay(error:, attempt:, **)
31
53
  return if attempt >= max_retries
54
+ return backoff(attempt) unless error.is_a?(RateLimitError)
32
55
 
33
- case error
34
- when RateLimitError
35
- rate_limit_delay(error, attempt)
36
- when ServerError
37
- backoff(attempt) if idempotent
38
- when ConnectionError
39
- backoff(attempt) if idempotent || !error.request_may_have_been_sent?
40
- end
41
- end
42
-
43
- private
44
-
45
- def rate_limit_delay(error, attempt)
46
56
  wait = error.retry_after
47
57
  return backoff(attempt) if wait.nil?
48
58
 
49
59
  # Rather than block a web request or job for minutes, surface the
50
60
  # RateLimitError and let the caller decide.
51
- wait if wait <= @max_retry_after
61
+ wait if wait <= max_retry_after
52
62
  end
53
63
 
64
+ def inspect
65
+ "#<#{self.class.name} max_retries=#{max_retries} base_delay=#{base_delay} max_delay=#{max_delay} " \
66
+ "max_retry_after=#{max_retry_after}>"
67
+ end
68
+
69
+ private
70
+
54
71
  # Exponential backoff with "equal jitter": half fixed, half random.
55
72
  def backoff(attempt)
56
- ceiling = [@base_delay * (2**attempt), @max_delay].min
73
+ ceiling = [base_delay * (2**attempt), max_delay].min
57
74
  (ceiling / 2.0) + (@random.rand * ceiling / 2.0)
58
75
  end
59
76
  end