clicksend 1.1.0 → 1.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 +4 -4
- data/CHANGELOG.md +161 -0
- data/README.md +457 -74
- data/docs/clicksend-api-notes.md +49 -5
- data/lib/clicksend/client.rb +16 -0
- data/lib/clicksend/connection.rb +81 -26
- data/lib/clicksend/errors.rb +14 -10
- data/lib/clicksend/instrumentation.rb +6 -0
- data/lib/clicksend/page.rb +6 -1
- data/lib/clicksend/resources/sms.rb +77 -0
- data/lib/clicksend/retry_policy.rb +5 -2
- data/lib/clicksend/testing/failure.rb +15 -4
- data/lib/clicksend/testing/fake_api.rb +87 -18
- data/lib/clicksend/testing/minitest.rb +56 -0
- data/lib/clicksend/testing/payloads.rb +15 -0
- data/lib/clicksend/testing/records.rb +17 -1
- data/lib/clicksend/testing/rspec.rb +124 -0
- data/lib/clicksend/testing/sms_expectations.rb +91 -0
- data/lib/clicksend/testing.rb +6 -0
- data/lib/clicksend/transport.rb +29 -9
- data/lib/clicksend/version.rb +1 -1
- data/lib/clicksend/webhook.rb +22 -16
- metadata +4 -1
data/docs/clicksend-api-notes.md
CHANGED
|
@@ -13,7 +13,9 @@ Sources:
|
|
|
13
13
|
- [SMS error codes](https://help.clicksend.com/en/articles/42318-sms-error-codes)
|
|
14
14
|
- Superseded but still informative: the [legacy HTTP v2 docs](https://developers.clicksend.com/docs/http/v2/)
|
|
15
15
|
and the [archived REST v3 docs](https://web.archive.org/web/20220502204655/https://developers.clicksend.com/docs/rest/v3/)
|
|
16
|
-
(2022),
|
|
16
|
+
(2022), and help articles that disappeared when ClickSend moved its help centre (around mid-2025,
|
|
17
|
+
judging by the Wayback Machine, which kept them). Those, and ClickSend's own integrations, are the only sources that list
|
|
18
|
+
the fields ClickSend pushes to webhook URLs; see `research/1.2-webhooks.md` in the repository
|
|
17
19
|
|
|
18
20
|
## Behaviour taken from the documentation
|
|
19
21
|
|
|
@@ -35,8 +37,14 @@ Sources:
|
|
|
35
37
|
| Idempotency | No idempotency key on any operation (all 34 sections searched) | Sends are never retried after timeouts or 5xx |
|
|
36
38
|
| `THROTTLED` | Application code: "Identical message body recently sent to the same recipient." Window and placement undocumented | Treated as an ordinary rejection; never relied on for deduplication |
|
|
37
39
|
| History search | `GET /v3/sms/history`: `date_from`, `date_to`, `order_by` (`date:asc` default), `page`, `limit`, and `q=field:value` for `status`, `to`, `from`, `subaccount_id`, `message_id`. `custom_string` is **not** a filter | `sms.history` takes one of `to:`, `from:`, `status:`, `message_id:`; match `custom_string` yourself |
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
+
| Combining search filters | The general "Searching and Sorting" section shows `q=field:value,field2:value`, `AND` by default, and `operator=OR`; searches are "**not** case-sensitive". The history operation itself documents one `field_name:value` and no `operator`, and calls the value "the text or keyword you're searching for" (exact or partial match unstated) | `sms.history` sends one filter; `sms.search_history` re-checks `to` and `custom_string` for exact equality |
|
|
41
|
+
| History retention | Help 43125: message data is kept for four months, then archived and "no longer available to view in the Dashboard, or downloadable from the History page or API". De-identification (on request) obfuscates `to` "in any history downloads" | Another reason an empty history search proves nothing |
|
|
42
|
+
| History consistency | **None documented**: no statement anywhere (current, archived or help) on how soon an accepted send appears in `GET /v3/sms/history` | `search_history` returns rows only; never "not sent" |
|
|
43
|
+
| Cancel one scheduled SMS | `PUT /v3/sms/{message_id}/cancel`, no body; 200 example `response_msg: "Scheduled sms message has been cancelled."`, `data` "deprecated and will return null" (archived 2022: `data: []`, message naming the ID). Answers for an unknown, sent or already-cancelled ID, and idempotency, are **undocumented** | `sms.cancel`, not idempotent: never retried after a timeout or 5xx |
|
|
44
|
+
| Cancel all scheduled SMS | `PUT /v3/sms/cancel-all`; optional body `custom_string` limits it to messages with that value (match semantics undocumented); without it, every scheduled SMS is cancelled. Returns `data.count` | Deliberately not wrapped |
|
|
45
|
+
| Price quote | `POST /v3/sms/price`: "calculate the price of sending messages". The only statement about effects is in the `sms` schema's `date`: it may be empty "in price-calculation responses where no message has actually been sent yet". The example returns a `message_id` | Not wrapped; `client.request` treats it as non-idempotent |
|
|
46
|
+
| Webhooks (push) | Automation rules with the `URL` action. Inbound rules: `webhook_type` `post` (form, default), `get` or `json` (format unspecified; ClickSend's n8n trigger shows a flat JSON object with integer `timestamp`/`user_id`). Receipts: form-encoded POST according to archived help ("the only forwarding format we support is x-www-form-urlencoded"). **No payload schema** in the current docs; archived docs and help list the fields: the poll schemas' names plus `user_id`, `status` (receipts) and legacy duplicates (`message`, `sms`, `originalsenderid`, `originalmessage`, `originalmessageid`, `customstring`, `messageid`). Retries: archived sources disagree (every 10 minutes ×10 with a 30 s timeout, or backoff over hours with a 15 s timeout); nothing current | `Clicksend::Webhook` parses into `SMS::Receipt` / `SMS::InboundMessage`; the extra keys stay in `#raw` |
|
|
47
|
+
| Webhook authentication | **None in the current docs**: no signature, HMAC or shared secret. Archived help (no longer published) suggested HTTPS, a URL token, checking `user_id`, and an allowlist of six source IPs last updated around 2019 | No verification method; README prescribes a secret URL and confirming via the API, and warns against the stale IP list |
|
|
40
48
|
| Request ID | None documented; no spec declares any response header | `Error#request` describes the call instead |
|
|
41
49
|
|
|
42
50
|
## Ambiguities and inconsistencies
|
|
@@ -145,21 +153,57 @@ the contract specs only, **not** against a live receipt.
|
|
|
145
153
|
| `GET /v3/sms/history?q=to:%2B61411111111&date_from=…&order_by=date:asc&limit=100` (`sms.history(to:, date_from:)`) | HTTP 200, paginated envelope. Exactly one row, the accepted test-number message from 2026-10-05, so `q=to:` filters and the encoded `+` works. Row: `direction: "out"`, `status: "Completed"`, `status_code: null`, `status_text: null`, `message_parts: 0`, `message_price: "0.0000"`, `custom_string` a String, `date` an Integer, `schedule` a String. The documented fields, plus `contact_id`, `user_id`, `subaccount_id`, `first_name`, `last_name`, `_api_username` |
|
|
146
154
|
| `GET /v3/account` rate-limit headers through `Response#rate_limit` | `limit: 20`, `remaining` and `reset_in` Integers |
|
|
147
155
|
|
|
156
|
+
### `sms.cancel` check (2026-10-06, protocol B3.7)
|
|
157
|
+
|
|
158
|
+
Run through the gem (`client.sms.cancel`, `max_retries: 0`) with one scheduled message to the free
|
|
159
|
+
test number `+61411111111` (`schedule:` one hour ahead) and nothing else sent. Every call was
|
|
160
|
+
answered quickly (under 1.1s); the balance was the same before the send, after it, and at the end.
|
|
161
|
+
|
|
162
|
+
| Step | Live result |
|
|
163
|
+
|---|---|
|
|
164
|
+
| Scheduled send to the test number | HTTP 200, per-message `status: "SUCCESS"`, `message_price: "0.0000"`, `message_parts: 0`, `schedule` one hour ahead |
|
|
165
|
+
| History (`q=message_id:`) at 0s, 5s, 30s, 120s | No row at 0s. From 5s on, one row with status **`Completed`** (not `Scheduled`), `status_code: null` |
|
|
166
|
+
| `PUT /v3/sms/{id}/cancel` on that message, no body | HTTP **404** `{"http_code":404,"response_code":"NOT_FOUND","response_msg":"Record not found.","data":null}`. The gem raised `Clicksend::NotFoundError`, not ambiguous |
|
|
167
|
+
| History after the cancel (0s to 120s) | Still `Completed` |
|
|
168
|
+
| The same cancel again | Same HTTP 404 `NOT_FOUND` envelope |
|
|
169
|
+
| A random well-formed UUID | Same HTTP 404 `NOT_FOUND` envelope |
|
|
170
|
+
| The 2026-10-05 accepted test-number message (`Completed`) | Same HTTP 404 `NOT_FOUND` envelope; history unchanged |
|
|
171
|
+
|
|
172
|
+
What this shows:
|
|
173
|
+
- A scheduled message to the free test number is **not held**: it is `Completed` within seconds,
|
|
174
|
+
so the test number can't exercise a successful cancel. The documented `200 SUCCESS` answer is
|
|
175
|
+
still unobserved live.
|
|
176
|
+
- Cancelling a message that is no longer scheduled answers 404 `NOT_FOUND`, **not** `SUCCESS`, at
|
|
177
|
+
least for test-number messages. An unknown ID gets the same answer, so a 404 doesn't say which.
|
|
178
|
+
- The path, the bodiless `PUT`, the JSON error envelope and the gem's mapping (typed, not
|
|
179
|
+
ambiguous, not retried) match the implementation.
|
|
180
|
+
|
|
148
181
|
### Still unverified
|
|
149
182
|
|
|
150
183
|
These need a receipt for a real (non-test) message, existing inbound messages, or the public
|
|
151
184
|
test accounts:
|
|
152
185
|
|
|
153
186
|
- [ ] A live receipt: its shape and `status_code` type. The free test number produced none.
|
|
187
|
+
- [ ] A successful `sms.cancel` (HTTP 200) and how a cancelled message shows in history. A
|
|
188
|
+
scheduled message to the free test number completes at once, so this needs a message that
|
|
189
|
+
ClickSend actually holds, i.e. a paid, scheduled send that is then cancelled.
|
|
154
190
|
- [ ] Inbound timestamp field (`timestamp` or `timestamp_send`); no inbound messages existed
|
|
155
191
|
- [ ] HTTP status and `response_code` for the `nocredit`, `notactive` and `banned` test accounts
|
|
156
192
|
- [ ] Whether mark-read accepts an empty `{}` body. Deliberately not tested, because it would
|
|
157
193
|
mark every unread item read (documented). The gem sends `{}` when `before:` is omitted; the
|
|
158
194
|
request schema allows it.
|
|
159
|
-
- [ ] A real webhook push: its content type, field names and types
|
|
160
|
-
|
|
195
|
+
- [ ] A real webhook push: its content type, field names and types (inbound `post`, `get` and
|
|
196
|
+
`json`, and a receipt), its headers, and how `null` is encoded. Field names come from the poll
|
|
197
|
+
schemas, archived docs and ClickSend's integrations. `script/webhook_capture.rb` and the
|
|
198
|
+
capture protocol in `research/1.2-webhooks.md` exist for this
|
|
199
|
+
- [ ] Whether `GET /v3/sms/receipts/{message_id}` finds a receipt on an account with only URL rules
|
|
200
|
+
- [ ] ClickSend's current retry schedule and timeout for pushes
|
|
161
201
|
- [ ] How soon a sent message appears in history, and whether `date_from`/`date_to` are inclusive
|
|
162
202
|
- [ ] Where and when `THROTTLED` is returned
|
|
203
|
+
- [ ] What `PUT /v3/sms/{message_id}/cancel` answers for a second cancel, an already-sent message
|
|
204
|
+
and an unknown ID (protocol in `research/1.2-api-cancel-quote-history.md`)
|
|
205
|
+
- [ ] Whether `POST /v3/sms/price` changes anything: balance, history, `THROTTLED`, rate limits
|
|
206
|
+
- [ ] Whether history's `q=to:` matches exactly or by substring
|
|
163
207
|
- [ ] Rate limits for endpoints other than `GET /v3/account`
|
|
164
208
|
|
|
165
209
|
## Retry safety: the rules and why
|
data/lib/clicksend/client.rb
CHANGED
|
@@ -148,7 +148,11 @@ module Clicksend
|
|
|
148
148
|
# page = client.paginate("/v3/sms/history", query: {date_from: from.to_i}, limit: 100)
|
|
149
149
|
# page.auto_paging_each { |message| ... }
|
|
150
150
|
#
|
|
151
|
+
# @param query [Hash, nil] query parameters, kept for every page
|
|
152
|
+
# @param page [Integer, nil] the page to fetch (from 1)
|
|
153
|
+
# @param limit [Integer, nil] items per page, 15 to 100
|
|
151
154
|
# @return [Clicksend::Page]
|
|
155
|
+
# @raise [ArgumentError] for an invalid query, page or limit
|
|
152
156
|
def paginate(path, query: {}, page: nil, limit: nil, operation: nil)
|
|
153
157
|
Page.fetch(self, path, query: query, page: page, limit: limit, operation: operation)
|
|
154
158
|
end
|
|
@@ -171,6 +175,18 @@ module Clicksend
|
|
|
171
175
|
end
|
|
172
176
|
alias_method :to_s, :inspect
|
|
173
177
|
|
|
178
|
+
# A client holds the API key (in its settings and its Authorization
|
|
179
|
+
# header), so it must never be written to a cache, a job queue or a
|
|
180
|
+
# session by Marshal or YAML. Marshal checks #marshal_dump first, for
|
|
181
|
+
# frozen objects too; Psych checks #encode_with.
|
|
182
|
+
def marshal_dump
|
|
183
|
+
raise TypeError, "#{self.class.name} contains credentials and can't be marshaled; build a new client instead"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def encode_with(_coder)
|
|
187
|
+
raise TypeError, "#{self.class.name} contains credentials and can't be serialized to YAML; build a new client instead"
|
|
188
|
+
end
|
|
189
|
+
|
|
174
190
|
private
|
|
175
191
|
|
|
176
192
|
# Surrounding whitespace (e.g. a trailing newline from a secrets file) is
|
data/lib/clicksend/connection.rb
CHANGED
|
@@ -15,6 +15,15 @@ module Clicksend
|
|
|
15
15
|
class Connection
|
|
16
16
|
HTTP_METHODS = %i[get post put patch delete].freeze
|
|
17
17
|
|
|
18
|
+
# What code outside the gem (a logger, an instrumenter, a retry policy, a
|
|
19
|
+
# custom transport) raises when it has a bug: StandardError, and also
|
|
20
|
+
# ScriptError (NotImplementedError from an abstract method, LoadError from
|
|
21
|
+
# a lazily required exporter). Such a failure must never escape as a
|
|
22
|
+
# non-Clicksend error after a send was accepted: job runners such as
|
|
23
|
+
# Sidekiq rescue Exception and would run the job, and the send, again.
|
|
24
|
+
# Interrupt, SystemExit and NoMemoryError still propagate.
|
|
25
|
+
FOREIGN_FAILURES = [StandardError, ScriptError].freeze
|
|
26
|
+
|
|
18
27
|
ERROR_CLASSES = {
|
|
19
28
|
400 => BadRequestError,
|
|
20
29
|
401 => AuthenticationError,
|
|
@@ -34,6 +43,11 @@ module Clicksend
|
|
|
34
43
|
RETRY_ALWAYS = %i[rate_limited not_sent].freeze
|
|
35
44
|
MAY_HAVE_BEEN_PROCESSED = %i[unknown undocumented].freeze
|
|
36
45
|
|
|
46
|
+
# The longest delay (seconds, about 68 years) Kernel.sleep accepts on
|
|
47
|
+
# every platform. A policy delay beyond it can't be waited for (sleep
|
|
48
|
+
# raises RangeError), so it means "don't retry".
|
|
49
|
+
MAX_SLEEP = (2**31) - 1
|
|
50
|
+
|
|
37
51
|
# @param headers [Hash] sent with every request (authentication, User-Agent)
|
|
38
52
|
# @param instrumenter [#instrument] see Clicksend::Instrumentation
|
|
39
53
|
def initialize(transport:, retry_policy:, headers: {}, logger: nil, instrumenter: Instrumentation::Null)
|
|
@@ -71,37 +85,72 @@ module Clicksend
|
|
|
71
85
|
# this, a metrics outage after an accepted send would surface as a
|
|
72
86
|
# non-Clicksend error that a job runner retries: a duplicate SMS.
|
|
73
87
|
#
|
|
74
|
-
# +state+ moves :pending -> :running -> :done
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
88
|
+
# +state+ moves :pending -> :running -> :done, under a lock because an
|
|
89
|
+
# instrumenter may run the block on another thread. What it is when
|
|
90
|
+
# #instrument returns or raises decides the outcome:
|
|
91
|
+
#
|
|
92
|
+
# [:done] the call's result or error. Any other exception can only be
|
|
93
|
+
# the instrumenter's, and only then is it ignored.
|
|
94
|
+
# [:pending] nothing was sent. The block becomes :abandoned, so an
|
|
95
|
+
# instrumenter that kept it and calls it later gets a
|
|
96
|
+
# ConfigurationError instead of sending.
|
|
97
|
+
# [:running] the block was left unfinished. An exception that escaped the
|
|
98
|
+
# request itself is a genuine bug and propagates: #run turns
|
|
99
|
+
# every failure it can foresee into a Clicksend::Error, and
|
|
100
|
+
# hiding one once made #request return nil after an accepted
|
|
101
|
+
# send. Otherwise the block is still running on another thread
|
|
102
|
+
# (or the instrumenter swallowed what it raised), so the
|
|
103
|
+
# request may be or may yet be sent: a ConfigurationError,
|
|
104
|
+
# ambiguous unless the request is idempotent.
|
|
105
|
+
#
|
|
106
|
+
# A second call of the block raises instead of sending again.
|
|
81
107
|
def instrumented(call)
|
|
82
108
|
payload = {http_method: call[:method], path: reported_path(call[:path]), operation: call[:operation], idempotent: call[:idempotent]}
|
|
109
|
+
lock = Mutex.new
|
|
83
110
|
state = :pending
|
|
84
|
-
outcome = nil
|
|
111
|
+
outcome = escaped = failure = nil
|
|
85
112
|
begin
|
|
86
113
|
@instrumenter.instrument("request.clicksend", payload) do
|
|
87
|
-
|
|
114
|
+
lock.synchronize do
|
|
115
|
+
raise ConfigurationError, "the instrumenter ran the request block after #instrument returned; it must yield before returning" if state == :abandoned
|
|
116
|
+
raise ConfigurationError, "the instrumenter ran the request block twice; #instrument must yield once" unless state == :pending
|
|
88
117
|
|
|
89
|
-
|
|
90
|
-
|
|
118
|
+
state = :running
|
|
119
|
+
end
|
|
120
|
+
result = begin
|
|
91
121
|
yield payload
|
|
92
122
|
rescue Error => e
|
|
93
123
|
e
|
|
124
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
125
|
+
lock.synchronize { escaped = e }
|
|
126
|
+
raise
|
|
94
127
|
end
|
|
95
|
-
state = :done
|
|
128
|
+
lock.synchronize { outcome, state = result, :done }
|
|
96
129
|
# Raise inside the block so ActiveSupport records the exception.
|
|
97
|
-
|
|
130
|
+
result.is_a?(Error) ? raise(result) : result
|
|
98
131
|
end
|
|
99
132
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
100
|
-
|
|
133
|
+
failure = e
|
|
134
|
+
end
|
|
135
|
+
final, escaped = lock.synchronize { [(state == :pending) ? (state = :abandoned) : state, escaped] }
|
|
101
136
|
|
|
102
|
-
|
|
137
|
+
case final
|
|
138
|
+
when :abandoned
|
|
139
|
+
raise failure if failure # the instrumenter failed before the request: nothing was sent
|
|
140
|
+
|
|
141
|
+
raise ConfigurationError, "the instrumenter did not run the request: #instrument must yield"
|
|
142
|
+
when :running
|
|
143
|
+
raise failure if failure&.equal?(escaped)
|
|
144
|
+
|
|
145
|
+
error = ConfigurationError.new("the instrumenter returned before the request finished; #instrument must run the block to completion before returning")
|
|
146
|
+
error.mark_ambiguous! unless call[:idempotent]
|
|
147
|
+
raise error, cause: failure
|
|
148
|
+
end
|
|
149
|
+
if failure && !failure.equal?(outcome)
|
|
150
|
+
raise failure unless FOREIGN_FAILURES.any? { |kind| failure.is_a?(kind) }
|
|
151
|
+
|
|
152
|
+
log(:warn) { "instrumenter raised #{failure.class.name} for #{call[:method].upcase} #{reported_path(call[:path])}; ignored" }
|
|
103
153
|
end
|
|
104
|
-
raise ConfigurationError, "the instrumenter did not run the request: #instrument must yield" if state == :pending
|
|
105
154
|
raise outcome if outcome.is_a?(Error)
|
|
106
155
|
|
|
107
156
|
outcome
|
|
@@ -151,7 +200,7 @@ module Clicksend
|
|
|
151
200
|
return [e.dup, :undocumented]
|
|
152
201
|
rescue Error => e
|
|
153
202
|
return [e.dup, :unknown] # any other Clicksend error from a custom transport: outcome unknown
|
|
154
|
-
rescue => e
|
|
203
|
+
rescue *FOREIGN_FAILURES => e
|
|
155
204
|
return [wrap_failure(ConnectionError, "The transport failed", e), :unknown]
|
|
156
205
|
end
|
|
157
206
|
log(:info) { "#{call[:method].upcase} #{reported_path(call[:path])} -> #{raw.status} (#{elapsed_ms(started)}ms)" }
|
|
@@ -159,9 +208,9 @@ module Clicksend
|
|
|
159
208
|
interpret(raw)
|
|
160
209
|
rescue MalformedResponseError => e
|
|
161
210
|
[e, :undocumented]
|
|
162
|
-
rescue => e
|
|
163
|
-
# A response this gem cannot even read (e.g. a
|
|
164
|
-
#
|
|
211
|
+
rescue *FOREIGN_FAILURES => e
|
|
212
|
+
# A response this gem cannot even read (e.g. a custom transport's
|
|
213
|
+
# body that is not a String) is undocumented: ambiguous for a send.
|
|
165
214
|
[wrap_failure(MalformedResponseError, "Could not read ClickSend's response", e), :undocumented]
|
|
166
215
|
end
|
|
167
216
|
end
|
|
@@ -209,9 +258,11 @@ module Clicksend
|
|
|
209
258
|
return unless budget.is_a?(Integer) && attempt < budget
|
|
210
259
|
|
|
211
260
|
delay = @retry_policy.delay(error: error, attempt: attempt)
|
|
261
|
+
return if (delay.is_a?(Integer) || delay.is_a?(Rational)) && delay > MAX_SLEEP # Float() would warn
|
|
262
|
+
|
|
212
263
|
delay = Float(delay) if delay.is_a?(Numeric)
|
|
213
|
-
delay if delay.is_a?(Float) && delay.
|
|
214
|
-
rescue => e
|
|
264
|
+
delay if delay.is_a?(Float) && delay.between?(0, MAX_SLEEP)
|
|
265
|
+
rescue *FOREIGN_FAILURES => e
|
|
215
266
|
# A broken policy stops retrying (the safe direction) and keeps the
|
|
216
267
|
# request's own error.
|
|
217
268
|
log(:warn) { "retry policy raised #{e.class.name}; not retrying" }
|
|
@@ -226,7 +277,7 @@ module Clicksend
|
|
|
226
277
|
payload = {http_method: call[:method], path: reported_path(call[:path]), operation: call[:operation], attempt: attempt,
|
|
227
278
|
delay: delay, error_class: error.class.name, http_status: error_status(error)}
|
|
228
279
|
@instrumenter.instrument("retry.clicksend", payload) {}
|
|
229
|
-
rescue => e
|
|
280
|
+
rescue *FOREIGN_FAILURES => e
|
|
230
281
|
log(:warn) { "instrumenter raised #{e.class.name} for retry.clicksend; ignored" }
|
|
231
282
|
end
|
|
232
283
|
|
|
@@ -249,11 +300,15 @@ module Clicksend
|
|
|
249
300
|
|
|
250
301
|
# Returns the parsed JSON, nil for an empty body, or the raw String when an
|
|
251
302
|
# error response isn't JSON (e.g. an HTML page from a proxy).
|
|
303
|
+
#
|
|
304
|
+
# A body that isn't valid in its declared charset (JSON converts it to
|
|
305
|
+
# UTF-8 first) is unreadable in the same way, so the status still decides:
|
|
306
|
+
# a garbled 429 or 503 must stay retryable, and a 400 a rejection.
|
|
252
307
|
def parse_body(raw)
|
|
253
308
|
return nil if raw.body.b.strip.empty?
|
|
254
309
|
|
|
255
310
|
JSON.parse(raw.body, freeze: true)
|
|
256
|
-
rescue JSON::ParserError
|
|
311
|
+
rescue JSON::ParserError, EncodingError
|
|
257
312
|
return raw.body unless success?(raw.status)
|
|
258
313
|
|
|
259
314
|
raise MalformedResponseError.new(
|
|
@@ -296,14 +351,14 @@ module Clicksend
|
|
|
296
351
|
# the outcome is simply not recorded.
|
|
297
352
|
def record(payload, **outcome)
|
|
298
353
|
payload.update(outcome)
|
|
299
|
-
rescue
|
|
354
|
+
rescue *FOREIGN_FAILURES
|
|
300
355
|
nil
|
|
301
356
|
end
|
|
302
357
|
|
|
303
358
|
# A failing logger must not turn a completed request into an error.
|
|
304
359
|
def log(level)
|
|
305
360
|
@logger&.public_send(level, "[clicksend] #{yield}")
|
|
306
|
-
rescue
|
|
361
|
+
rescue *FOREIGN_FAILURES
|
|
307
362
|
nil
|
|
308
363
|
end
|
|
309
364
|
|
data/lib/clicksend/errors.rb
CHANGED
|
@@ -168,17 +168,21 @@ module Clicksend
|
|
|
168
168
|
# served", so it is treated as not processed (an inference, not a documented
|
|
169
169
|
# guarantee).
|
|
170
170
|
class RateLimitError < ClientError
|
|
171
|
-
# Seconds to wait before retrying, from the Retry-After header
|
|
171
|
+
# Seconds to wait before retrying, from the Retry-After header: a plain
|
|
172
|
+
# non-negative decimal Integer, or the time left until its HTTP-date (0
|
|
173
|
+
# if that has passed). Nil when the header is missing or is anything
|
|
174
|
+
# else ("+5", "-5", "0x10", "1_0", an Array, ...).
|
|
172
175
|
def retry_after
|
|
173
|
-
value = headers["retry-after"]
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
176
|
+
value = headers["retry-after"] if headers.is_a?(Hash)
|
|
177
|
+
value = value.to_s if value.is_a?(Integer) # from a custom transport
|
|
178
|
+
return unless value.is_a?(String)
|
|
179
|
+
|
|
180
|
+
text = value.strip
|
|
181
|
+
return Integer(text, 10) if text.match?(/\A\d+\z/)
|
|
182
|
+
|
|
183
|
+
[Time.httpdate(text) - Time.now, 0].max
|
|
184
|
+
rescue ArgumentError, RangeError # not an HTTP-date, or not valid in its encoding
|
|
185
|
+
nil
|
|
182
186
|
end
|
|
183
187
|
|
|
184
188
|
def retryable?
|
|
@@ -29,6 +29,12 @@ module Clicksend
|
|
|
29
29
|
# without its query string; for some endpoints it includes a message ID.
|
|
30
30
|
#
|
|
31
31
|
# The instrumenter is called on the caller's thread and must be thread-safe.
|
|
32
|
+
# For request.clicksend it must run the block exactly once, to completion,
|
|
33
|
+
# before it returns. If it returns without running it, the call raises
|
|
34
|
+
# ConfigurationError and the block never sends, even if called later. If
|
|
35
|
+
# it returns while the block is still running (on another thread), the
|
|
36
|
+
# call raises a ConfigurationError that, for a request that is not
|
|
37
|
+
# idempotent, is also a Clicksend::AmbiguousRequestError.
|
|
32
38
|
module Instrumentation
|
|
33
39
|
# The default: runs the block and publishes nothing.
|
|
34
40
|
module Null
|
data/lib/clicksend/page.rb
CHANGED
|
@@ -30,8 +30,9 @@ module Clicksend
|
|
|
30
30
|
if limit && !(limit.is_a?(Integer) && LIMITS.cover?(limit))
|
|
31
31
|
raise ArgumentError, "limit must be an Integer between #{LIMITS.min} and #{LIMITS.max} (ClickSend's documented range)"
|
|
32
32
|
end
|
|
33
|
+
raise ArgumentError, "query must be a Hash" unless query.nil? || query.is_a?(Hash)
|
|
33
34
|
|
|
34
|
-
query = query.transform_keys(&:to_s)
|
|
35
|
+
query = (query || {}).transform_keys(&:to_s)
|
|
35
36
|
response = client.request(:get, path, query: query.merge("page" => page, "limit" => limit).compact, operation: operation)
|
|
36
37
|
fetch_page = ->(number) { fetch(client, path, query: query, page: number, limit: limit, operation: operation, &build_item) }
|
|
37
38
|
from_response(response, fetch_page, &build_item)
|
|
@@ -49,6 +50,10 @@ module Clicksend
|
|
|
49
50
|
numbers = %w[total per_page current_page last_page].to_h do |key|
|
|
50
51
|
value = Integer(data[key], exception: false) if data[key].is_a?(Integer) || data[key].is_a?(String)
|
|
51
52
|
raise MalformedResponseError.new("Paginated response is missing #{key}", http_status: response.http_status, body: response.body) if value.nil?
|
|
53
|
+
# Pages are numbered from 1 (the page parameter defaults to 1); counts can be 0.
|
|
54
|
+
if value < ((key == "current_page") ? 1 : 0)
|
|
55
|
+
raise MalformedResponseError.new("Paginated response has an invalid #{key}: #{value}", http_status: response.http_status, body: response.body)
|
|
56
|
+
end
|
|
52
57
|
|
|
53
58
|
[key.to_sym, value]
|
|
54
59
|
end
|
|
@@ -12,6 +12,13 @@ module Clicksend
|
|
|
12
12
|
DEFAULT_FIELDS = (MESSAGE_FIELDS - %i[to list_id body]).freeze
|
|
13
13
|
MESSAGE_ID = /\A[A-Za-z0-9-]+\z/
|
|
14
14
|
HISTORY_ORDERS = %i[asc desc].freeze
|
|
15
|
+
# History stores recipients in E.164 ("+61411111111"); a local or
|
|
16
|
+
# unprefixed number would not match the rows it is compared with.
|
|
17
|
+
E164 = /\A\+[1-9]\d{5,14}\z/
|
|
18
|
+
# Seconds #search_history widens its window by, on each side. ClickSend
|
|
19
|
+
# doesn't document whether date_from/date_to are inclusive or which clock
|
|
20
|
+
# history dates come from; a wider window only finds more rows.
|
|
21
|
+
HISTORY_SEARCH_MARGIN = 300
|
|
15
22
|
|
|
16
23
|
def initialize(client)
|
|
17
24
|
@client = client
|
|
@@ -202,6 +209,76 @@ module Clicksend
|
|
|
202
209
|
end
|
|
203
210
|
end
|
|
204
211
|
|
|
212
|
+
# The history rows ClickSend shows *now* for messages you sent to +to+
|
|
213
|
+
# with exactly this +custom_string+ since +sent_after+, oldest first.
|
|
214
|
+
# Meant for the question an AmbiguousRequestError from #deliver leaves
|
|
215
|
+
# open: did ClickSend accept that message after all?
|
|
216
|
+
#
|
|
217
|
+
# records = client.sms.search_history(to: user.phone, custom_string: "otp:#{attempt.id}", sent_after: started_at)
|
|
218
|
+
# records.any? # => true: ClickSend accepted it (see record.status)
|
|
219
|
+
# # false: nothing is known yet; this is NOT proof it wasn't sent
|
|
220
|
+
#
|
|
221
|
+
# ClickSend doesn't say how soon an accepted message appears in history,
|
|
222
|
+
# keeps history for about four months, and can de-identify recipients, so
|
|
223
|
+
# an empty result never means "not sent". Never resend automatically
|
|
224
|
+
# because of it.
|
|
225
|
+
#
|
|
226
|
+
# Queries GET /v3/sms/history with +q=to:+ and a date window widened by
|
|
227
|
+
# HISTORY_SEARCH_MARGIN seconds on each side, 100 rows per page (each
|
|
228
|
+
# page is one request against ClickSend's undocumented rate limits),
|
|
229
|
+
# then keeps outbound rows whose +to+ and +custom_string+ equal yours:
|
|
230
|
+
# +custom_string+ is not a documented filter, and ClickSend calls a +q+
|
|
231
|
+
# value a "text or keyword", not an exact match.
|
|
232
|
+
#
|
|
233
|
+
# @param to [String] the recipient, in E.164 ("+61411111111")
|
|
234
|
+
# @param custom_string [String] the reference you passed to #deliver
|
|
235
|
+
# @param sent_after [Time, Integer] a time before the send started
|
|
236
|
+
# @param sent_before [Time, Integer, nil] a time after it ended (default: no upper bound)
|
|
237
|
+
# @return [Array<Clicksend::SMS::HistoryRecord>] possibly several (a
|
|
238
|
+
# reference reused across sends), possibly none
|
|
239
|
+
def search_history(to:, custom_string:, sent_after:, sent_before: nil)
|
|
240
|
+
unless to.is_a?(String) && to.match?(E164)
|
|
241
|
+
raise ArgumentError, "to must be an E.164 number such as \"+61411111111\" (history stores recipients that way), got #{to.inspect}"
|
|
242
|
+
end
|
|
243
|
+
raise ArgumentError, "custom_string must be a non-empty String" unless custom_string.is_a?(String) && !custom_string.empty?
|
|
244
|
+
|
|
245
|
+
from = unix_time(sent_after, "sent_after")
|
|
246
|
+
to_time = unix_time(sent_before, "sent_before") unless sent_before.nil?
|
|
247
|
+
raise ArgumentError, "sent_before must not be earlier than sent_after" if to_time && to_time < from
|
|
248
|
+
|
|
249
|
+
history(to: to, date_from: from - HISTORY_SEARCH_MARGIN, date_to: (to_time + HISTORY_SEARCH_MARGIN if to_time), limit: Page::LIMITS.max)
|
|
250
|
+
.auto_paging_each
|
|
251
|
+
.select { |record| record.outbound? && record.to == to && record.custom_string == custom_string }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Cancels one scheduled SMS (PUT /v3/sms/{message_id}/cancel). Returns
|
|
255
|
+
# nil when ClickSend answers SUCCESS; ClickSend's response carries no
|
|
256
|
+
# data (+data+ is deprecated and always null).
|
|
257
|
+
#
|
|
258
|
+
# *Experimental*: the SUCCESS answer is covered by contract specs but
|
|
259
|
+
# has not been observed live. ClickSend's free test number completes
|
|
260
|
+
# scheduled messages at once, and cancelling them answered 404
|
|
261
|
+
# NOT_FOUND (raised as NotFoundError), as did a repeated cancel and an
|
|
262
|
+
# unknown ID. A NotFoundError therefore doesn't say why, and doesn't
|
|
263
|
+
# mean the message was sent.
|
|
264
|
+
#
|
|
265
|
+
# message = client.sms.deliver(to: user.phone, body: "Reminder", schedule: Time.now + 3600)
|
|
266
|
+
# client.sms.cancel(message.message_id)
|
|
267
|
+
#
|
|
268
|
+
# Don't treat "no exception" as "the message will not go out": check
|
|
269
|
+
# #history(message_id:) for status "Cancelled" when it matters.
|
|
270
|
+
#
|
|
271
|
+
# Not retried after a timeout or 5xx (ClickSend documents no idempotency
|
|
272
|
+
# for it). Such a failure is a Clicksend::AmbiguousRequestError: the
|
|
273
|
+
# message may or may not have been cancelled. Calling #cancel again
|
|
274
|
+
# cannot send anything, but may raise even if the first call worked;
|
|
275
|
+
# #history(message_id:) tells you which.
|
|
276
|
+
# @return [nil]
|
|
277
|
+
def cancel(message_id)
|
|
278
|
+
@client.request(:put, "/v3/sms/#{message_id!(message_id)}/cancel", operation: "sms.cancel")
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
|
|
205
282
|
def inspect
|
|
206
283
|
"#<#{self.class.name}>"
|
|
207
284
|
end
|
|
@@ -29,7 +29,8 @@ module Clicksend
|
|
|
29
29
|
# @param max_delay [Numeric] seconds; the backoff ceiling never exceeds it
|
|
30
30
|
# @param max_retry_after [Numeric] longest Retry-After (seconds) worth
|
|
31
31
|
# waiting for; a longer one is raised as RateLimitError instead of
|
|
32
|
-
# blocking the caller
|
|
32
|
+
# blocking the caller. Even with Float::INFINITY, a wait longer than
|
|
33
|
+
# Kernel.sleep accepts (2**31 - 1 seconds) is raised, not retried.
|
|
33
34
|
def initialize(max_retries: 2, base_delay: 0.5, max_delay: 8.0, max_retry_after: 30, random: Random)
|
|
34
35
|
unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
35
36
|
raise ConfigurationError, "max_retries must be a non-negative Integer"
|
|
@@ -48,7 +49,9 @@ module Clicksend
|
|
|
48
49
|
|
|
49
50
|
# @param error [Clicksend::Error] the failure of attempt number +attempt+
|
|
50
51
|
# (0-based); already known to be safe to retry
|
|
51
|
-
# @return [Numeric, nil] seconds to wait before retrying, or nil to give up
|
|
52
|
+
# @return [Numeric, nil] seconds to wait before retrying, or nil to give up.
|
|
53
|
+
# The connection also gives up on anything that is not a number of
|
|
54
|
+
# seconds between 0 and 2**31 - 1 (Kernel.sleep's limit).
|
|
52
55
|
def delay(error:, attempt:, **)
|
|
53
56
|
return if attempt >= max_retries
|
|
54
57
|
return backoff(attempt) unless error.is_a?(RateLimitError)
|
|
@@ -13,19 +13,25 @@ module Clicksend
|
|
|
13
13
|
timeout: [TimeoutError, nil, "Timed out waiting for the response"],
|
|
14
14
|
connection_reset: [ConnectionError, nil, "Connection reset by peer"]
|
|
15
15
|
}.freeze
|
|
16
|
+
OUTCOMES = (CONNECTION.keys + [:interrupted]).freeze
|
|
16
17
|
|
|
17
18
|
attr_reader :times
|
|
18
19
|
|
|
19
20
|
def initialize(outcome, status:, processed:, retry_after:, path:, method:, times:)
|
|
20
21
|
label = outcome ? outcome.inspect : "status: #{status.inspect}"
|
|
21
22
|
if outcome.nil? == status.nil?
|
|
22
|
-
raise ArgumentError, "fail_next needs an outcome (#{
|
|
23
|
+
raise ArgumentError, "fail_next needs an outcome (#{OUTCOMES.map(&:inspect).join(", ")}) or status:, not both"
|
|
23
24
|
end
|
|
24
25
|
raise ArgumentError, "retry_after: only applies to status: 429" if retry_after && status != 429
|
|
25
26
|
|
|
26
|
-
if outcome
|
|
27
|
+
if outcome == :interrupted
|
|
28
|
+
# The worker is stopped mid-send: whether ClickSend got the request
|
|
29
|
+
# first is exactly what the application can't know.
|
|
30
|
+
@interrupted = true
|
|
31
|
+
ambiguous = true
|
|
32
|
+
elsif outcome
|
|
27
33
|
@error_class, @request_sent, @message = CONNECTION.fetch(outcome) do
|
|
28
|
-
raise ArgumentError, "unknown fail_next outcome #{outcome.inspect}; use one of #{
|
|
34
|
+
raise ArgumentError, "unknown fail_next outcome #{outcome.inspect}; use one of #{OUTCOMES.map(&:inspect).join(", ")} or status:"
|
|
29
35
|
end
|
|
30
36
|
ambiguous = @request_sent.nil?
|
|
31
37
|
else
|
|
@@ -65,9 +71,14 @@ module Clicksend
|
|
|
65
71
|
(@path.nil? || @path == request.path) && (@method.nil? || @method == request.http_method)
|
|
66
72
|
end
|
|
67
73
|
|
|
68
|
-
# Raises the connection error, or returns the
|
|
74
|
+
# Raises the connection error or the SimulatedInterrupt, or returns the
|
|
75
|
+
# error response.
|
|
69
76
|
# @return [Clicksend::Transport::Response]
|
|
70
77
|
def trigger
|
|
78
|
+
if @interrupted
|
|
79
|
+
raise SimulatedInterrupt, "Worker interrupted mid-request, #{@processed ? "after" : "before"} ClickSend processed it " \
|
|
80
|
+
"(simulated by Clicksend::Testing::FakeAPI#fail_next(:interrupted); this models the job runner, not ClickSend)"
|
|
81
|
+
end
|
|
71
82
|
raise @error_class.new("#{@message} (simulated by Clicksend::Testing::FakeAPI)", request_sent: @request_sent) if @error_class
|
|
72
83
|
return Payloads.error(@status) unless @status == 429
|
|
73
84
|
|