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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -0
- data/README.md +418 -81
- data/docs/clicksend-api-notes.md +52 -16
- data/lib/clicksend/client.rb +58 -17
- data/lib/clicksend/connection.rb +203 -32
- data/lib/clicksend/errors.rb +99 -3
- data/lib/clicksend/instrumentation.rb +42 -0
- data/lib/clicksend/model.rb +9 -0
- data/lib/clicksend/page.rb +6 -3
- data/lib/clicksend/rate_limit.rb +40 -0
- data/lib/clicksend/resources/account.rb +1 -1
- data/lib/clicksend/resources/sms.rb +98 -20
- data/lib/clicksend/response.rb +35 -0
- data/lib/clicksend/retry_policy.rb +44 -27
- data/lib/clicksend/sms/history_record.rb +85 -0
- data/lib/clicksend/sms/message.rb +1 -1
- data/lib/clicksend/testing/failure.rb +81 -0
- data/lib/clicksend/testing/fake_api.rb +422 -0
- data/lib/clicksend/testing/payloads.rb +88 -0
- data/lib/clicksend/testing/records.rb +27 -0
- data/lib/clicksend/testing.rb +88 -0
- data/lib/clicksend/version.rb +1 -1
- data/lib/clicksend/webhook.rb +182 -0
- data/lib/clicksend.rb +4 -0
- metadata +10 -1
data/lib/clicksend/errors.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
data/lib/clicksend/model.rb
CHANGED
|
@@ -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 }
|
data/lib/clicksend/page.rb
CHANGED
|
@@ -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.
|
|
30
|
-
# Clicksend::
|
|
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
|
-
|
|
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):
|
|
114
|
-
#
|
|
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:
|
|
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):
|
|
133
|
-
#
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/clicksend/response.rb
CHANGED
|
@@ -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
|
|
5
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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
|
|
16
|
-
# is idempotent
|
|
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+
|
|
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:,
|
|
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 <=
|
|
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 = [
|
|
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
|