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.
@@ -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), the only sources that list the fields ClickSend pushes to webhook URLs
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
- | Webhooks (push) | Automation rules with the `URL` action. Inbound rules: `webhook_type` `post` (form, default), `get` or `json` (format unspecified). Receipts: form-encoded POST according to the archived v3 docs (help 42270 covers inbound rules only). **No payload schema** in the current docs; the archived docs list the fields, matching the poll schemas plus `user_id` and (receipts) `status`. Archived: a non-200 is retried every 10 minutes, 10 times | `Clicksend::Webhook` parses into `SMS::Receipt` / `SMS::InboundMessage` |
39
- | Webhook authentication | **None documented.** No signature, HMAC or shared secret in any current, archived or help source. Current docs list no source IP addresses; archived help pages (around 2019–2021, no longer published) listed six and said pushes come from a fixed pool | No verification method and no IP allowlisting; README prescribes a secret URL and confirming via the API |
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. Field names come from the
160
- poll schemas and the archived docs
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
@@ -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
@@ -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. Only an exception that
75
- # arrives once the request is :done can be the instrumenter's own, and
76
- # only then is it ignored. Anything raised while the request is :running
77
- # propagates: #run turns every failure it can foresee into a
78
- # Clicksend::Error, so that is a genuine bug, never something to hide
79
- # (hiding it once made #request return nil after an accepted send). A
80
- # second call of the block raises instead of sending again.
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
- raise ConfigurationError, "the instrumenter ran the request block twice; #instrument must yield once" unless state == :pending
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
- state = :running
90
- outcome = begin
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
- outcome.is_a?(Error) ? raise(outcome) : outcome
130
+ result.is_a?(Error) ? raise(result) : result
98
131
  end
99
132
  rescue Exception => e # rubocop:disable Lint/RescueException
100
- raise unless state == :done && e.is_a?(StandardError) && !e.equal?(outcome)
133
+ failure = e
134
+ end
135
+ final, escaped = lock.synchronize { [(state == :pending) ? (state = :abandoned) : state, escaped] }
101
136
 
102
- log(:warn) { "instrumenter raised #{e.class.name} for #{call[:method].upcase} #{reported_path(call[:path])}; ignored" }
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 body that is not valid
164
- # in its declared charset) is undocumented: ambiguous for a send.
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.finite? && delay >= 0
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
 
@@ -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, if any.
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
- return if value.nil?
175
-
176
- Integer(value, exception: false)&.then { |seconds| [seconds, 0].max } ||
177
- begin
178
- [Time.httpdate(value) - Time.now, 0].max
179
- rescue ArgumentError
180
- nil
181
- end
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
@@ -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 (#{CONNECTION.keys.map(&:inspect).join(", ")}) or status:, not both"
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 #{CONNECTION.keys.map(&:inspect).join(", ")} or status:"
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 error response.
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