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.
@@ -1,14 +1,19 @@
1
1
  # ClickSend API notes
2
2
 
3
3
  How this gem interprets ClickSend's REST v3 documentation, where that documentation is
4
- ambiguous, and what the live API actually did. Last reviewed 2026-10-05, including live runs
5
- that covered sending but did not observe a delivery receipt (see "Live verification").
4
+ ambiguous, and what the live API actually did. Last reviewed 2026-10-06: the documentation
5
+ review covered all 34 OpenAPI sections, and the live runs on 2026-10-05 and 2026-10-06 (see
6
+ "Live verification") covered sending and read-only history and rate-limit checks, but no
7
+ delivery receipt.
6
8
 
7
9
  Sources:
8
10
  - [API reference](https://developers.clicksend.com/docs/), with its OpenAPI files at
9
11
  `https://developers.clicksend.com/docs/_spec/<section>.yaml`
10
12
  - [Testing](https://developers.clicksend.com/docs/testing)
11
13
  - [SMS error codes](https://help.clicksend.com/en/articles/42318-sms-error-codes)
14
+ - Superseded but still informative: the [legacy HTTP v2 docs](https://developers.clicksend.com/docs/http/v2/)
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
12
17
 
13
18
  ## Behaviour taken from the documentation
14
19
 
@@ -20,13 +25,19 @@ Sources:
20
25
  | Per-message status | `/sms/send` `http_code` "doesn't reflect the status of each message" | `MessageRejected` (single) / `Batch#rejected` |
21
26
  | Pagination | `page` (default 1) and `limit` (default 15, min 15, max 100); `total`, `per_page`, `current_page`, `last_page` | `Page`; `limit` checked against 15..100 |
22
27
  | Receipt status codes | 200 sent/queued, 201 delivered, 300 temporary failure (ClickSend retries), 301 failed | `pending?` / `delivered?` / `failed?` |
23
- | Unread-only lists | Receipts and inbound marked read "won't be shown" in the list endpoints | Documented in the README (paging pitfall) |
28
+ | Unread-only lists | Receipts and inbound marked read "won't be shown" in the list endpoints. Listing does not mark anything read (archived docs) | Documented in the README (paging pitfall) |
24
29
  | POLL rules | Polling receipts and inbound requires a rule with the POLL action | Documented |
25
- | Mark-read cutoff | `date_before`, Unix timestamp, optional | `before:` (Time or Integer); `{}` when omitted, which matches the request schema |
26
- | Unicode | Detected automatically; no `messagetype` in v3 | Not exposed |
30
+ | Mark-read cutoff | `date_before`, Unix timestamp, optional. Without it, **everything** is marked read ("mark all", and the archived "If not given, all messages will be marked as read") | `before:` (Time or Integer); `{}` when omitted. Since 1.1 retried only with a cutoff (see "Retry safety") |
31
+ | Per-receipt mark-read | None: receipts can only be marked read by cutoff. Inbound messages have `PUT /v3/sms/inbound-read/{message_id}` | `mark_inbound_message_read` only |
32
+ | Unicode | Detected automatically **if** the account's dashboard setting is "Autodetect" (help 42194); no `messagetype` in v3 | Not exposed |
27
33
  | URLs in SMS | Paused for new customers pending approval | Documented |
28
- | Test numbers | e.g. `+61411111111`: "No messages will be sent, and your account won't be charged" | Used by the live specs. **Live:** still subject to the account's enabled countries (see below) |
29
- | Idempotency | No idempotency key on any send operation | Sends are never retried after timeouts or 5xx |
34
+ | Test numbers | e.g. `+61411111111`: "No messages will be sent, and your account won't be charged". The legacy v2 docs add: "A delivery report won't be generated when using a test number" | Used by the live specs. **Live:** still subject to the account's enabled countries, and no receipt (see below) |
35
+ | Idempotency | No idempotency key on any operation (all 34 sections searched) | Sends are never retried after timeouts or 5xx |
36
+ | `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
+ | 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
+ | Request ID | None documented; no spec declares any response header | `Error#request` describes the call instead |
30
41
 
31
42
  ## Ambiguities and inconsistencies
32
43
 
@@ -51,14 +62,15 @@ These were found by the contract specs (`bundle exec rake contract`), which pin
51
62
  until this is verified; use `client.request`.
52
63
  8. **Inbound mark-read example.** The request example sends `date_before` as a string
53
64
  (`"1961900166"`); the schema says integer. The gem sends an integer.
54
- 9. **Batch size.** "Up to 1000 messages" appears only in a code-sample comment, not in the schema.
55
- Not enforced.
65
+ 9. **Batch size.** "Up to 1000 messages" appears in a code-sample comment and in the archived 2022
66
+ docs, not in the current schema. Not enforced.
56
67
  10. **Blocked messages.** `blocked_count` is documented, but not whether blocked messages also
57
68
  appear in `messages[]`. **Live: they do** (a `COUNTRY_NOT_ENABLED` message was listed and
58
69
  counted in `blocked_count`). `Batch#all_queued?` checks both.
59
- 11. **Rate limits.** 429 is documented, but the referenced "Rate Limiting" section doesn't exist.
60
- See the live results below for what the API actually sends. The gem honours `Retry-After`
61
- and doesn't depend on the other headers.
70
+ 11. **Rate limits.** 429 is documented, but the referenced "Rate Limiting" section doesn't exist
71
+ (nor in the 2022 archive). See the live results below for what the API actually sends. The
72
+ gem honours `Retry-After`, and since 1.1 exposes the other observed headers as
73
+ `Response#rate_limit` / `APIError#rate_limit`, without depending on them.
62
74
  12. **Error HTTP statuses.** The docs list application codes such as `INVALID_RECIPIENT` and
63
75
  `INSUFFICIENT_CREDIT` but not which HTTP status accompanies them. **Live:** on `/sms/send`,
64
76
  `INVALID_RECIPIENT` and `COUNTRY_NOT_ENABLED` are per-message statuses inside an HTTP 200, not
@@ -67,6 +79,12 @@ These were found by the contract specs (`bundle exec rake contract`), which pin
67
79
  live response, include `_subaccount.api_key`. `Account#raw` replaces that value with
68
80
  `"[REDACTED]"`. The escape hatch returns bodies verbatim, so `client.request(:get,
69
81
  "/v3/account").body` contains the key and must not be logged.
82
+ 14. **History example.** The `view-sms-history` example uses `status: 200` instead of `http_code` and
83
+ omits the pagination wrapper the schema declares. **Live: the schema is right** (paginated
84
+ envelope). History `status_code` is documented as a string; live it was `null` for a
85
+ test-number message.
86
+ 15. **Help links.** The receipt and inbound-URL help links inside `sms.yaml` now redirect to
87
+ unrelated articles.
70
88
 
71
89
  ## Defensive behaviour (not documented for v3)
72
90
 
@@ -120,6 +138,13 @@ account. This was the only send in the run, and retries were off.
120
138
  Receipt retrieval and parsing are therefore verified against ClickSend's published examples and
121
139
  the contract specs only, **not** against a live receipt.
122
140
 
141
+ ### Read-only checks (2026-10-06)
142
+
143
+ | Behaviour | Live result |
144
+ |---|---|
145
+ | `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
+ | `GET /v3/account` rate-limit headers through `Response#rate_limit` | `limit: 20`, `remaining` and `reset_in` Integers |
147
+
123
148
  ### Still unverified
124
149
 
125
150
  These need a receipt for a real (non-test) message, existing inbound messages, or the public
@@ -129,8 +154,12 @@ test accounts:
129
154
  - [ ] Inbound timestamp field (`timestamp` or `timestamp_send`); no inbound messages existed
130
155
  - [ ] HTTP status and `response_code` for the `nocredit`, `notactive` and `banned` test accounts
131
156
  - [ ] Whether mark-read accepts an empty `{}` body. Deliberately not tested, because it would
132
- mark every unread item read. The gem sends `{}` when `before:` is omitted; the request
133
- schema allows it.
157
+ mark every unread item read (documented). The gem sends `{}` when `before:` is omitted; the
158
+ 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
161
+ - [ ] How soon a sent message appears in history, and whether `date_from`/`date_to` are inclusive
162
+ - [ ] Where and when `THROTTLED` is returned
134
163
  - [ ] Rate limits for endpoints other than `GET /v3/account`
135
164
 
136
165
  ## Retry safety: the rules and why
@@ -143,12 +172,19 @@ knows the first attempt did not reach ClickSend, or that ClickSend did not act o
143
172
  | 429 | ClickSend documents it as "a request cannot be served due to the application's rate limit". Inferred, not documented: the observed body (`"Too many attempts."`) looks like a throttling layer's response, produced before the request is handled | every method, honouring `Retry-After` up to 30s |
144
173
  | Connection refused, DNS failure, connect timeout (`Net::OpenTimeout`) | These can only happen before the request is written | every method |
145
174
  | Read timeout, connection reset, broken pipe, unreachable host mid-request | The request may have been written and processed | idempotent requests only |
175
+ | Mark-read **without** a cutoff | "Mark everything read" is evaluated when ClickSend processes it, so a repeat can hide items that arrived in between | never (since 1.1); with a cutoff it is idempotent |
146
176
  | TLS errors | Usually a handshake failure (not sent), but `OpenSSL::SSL::SSLError` also covers failures after the request was written | idempotent requests only |
147
177
  | 5xx | ClickSend may have acted before failing | idempotent requests only |
148
178
  | Error reported only inside a 2xx body | Undocumented | never |
149
179
 
150
- "Idempotent" means `GET`, plus the gem's mark-read calls. `client.request` assumes only `GET`,
151
- because ClickSend uses `POST` and `PUT` for sends, purchases and credit transfers.
180
+ "Idempotent" means `GET`, the mark-read calls that have a cutoff, and marking one inbound message
181
+ read. `client.request` assumes only `GET`, because ClickSend uses `POST` and `PUT` for sends,
182
+ purchases and credit transfers.
183
+
184
+ Since 1.1 this rule lives in the connection and cannot be changed by configuration: a custom
185
+ `retry_policy` only chooses delays and the retry budget. When a non-idempotent request fails in
186
+ a way that may have been processed (a row above marked "idempotent requests only" or "never", or
187
+ a 2xx answer the gem cannot read), the error is extended with `Clicksend::AmbiguousRequestError`.
152
188
 
153
189
  Underneath the gem there are no hidden retries. `Net::HTTP` retries `GET`/`PUT`/`DELETE`
154
190
  itself unless `max_retries` is 0, and faraday-net_http sets it to 0.
@@ -18,7 +18,13 @@ module Clicksend
18
18
  DEFAULT_MAX_RETRIES = 2
19
19
  LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
20
20
 
21
- attr_reader :username, :base_url, :timeout, :open_timeout, :max_retries
21
+ attr_reader :username, :base_url, :timeout, :open_timeout
22
+
23
+ # @return [#delay, #max_retries] see Clicksend::RetryPolicy
24
+ attr_reader :retry_policy
25
+
26
+ # @return [#instrument] see Clicksend::Instrumentation
27
+ attr_reader :instrumenter
22
28
 
23
29
  # @return [Clicksend::Resources::Account]
24
30
  attr_reader :account
@@ -33,21 +39,29 @@ module Clicksend
33
39
  # @param timeout [Numeric] seconds to wait for a response (read timeout)
34
40
  # @param open_timeout [Numeric] seconds to wait for the TCP/TLS connection
35
41
  # @param max_retries [Integer] retries for failures that are safe to retry
36
- # (see Clicksend::RetryPolicy); 0 disables retries
42
+ # (see Clicksend::RetryPolicy); 0 disables retries. Default 2. A shortcut
43
+ # for +retry_policy: RetryPolicy.new(max_retries: n)+; pass one or the other.
44
+ # @param retry_policy [#delay, #max_retries] backoff timing and retry budget
45
+ # (see Clicksend::RetryPolicy). Which failures are retried at all is not
46
+ # configurable.
37
47
  # @param logger [#info, #warn, nil] receives one line per HTTP attempt;
38
48
  # never request/response bodies, query strings or credentials
49
+ # @param instrumenter [#instrument, nil] e.g. ActiveSupport::Notifications;
50
+ # see Clicksend::Instrumentation for the events and their payloads
39
51
  # @param adapter [Symbol, Array, nil] Faraday adapter (default Net::HTTP)
40
52
  # @param transport [#call, nil] replaces the HTTP layer entirely (see
41
- # Clicksend::Transport); +timeout+, +open_timeout+ and +adapter+ are then
42
- # the transport's responsibility
53
+ # Clicksend::Transport and Clicksend::Testing::FakeAPI); +timeout+,
54
+ # +open_timeout+ and +adapter+ are then the transport's responsibility
43
55
  def initialize(
44
56
  username: ENV.fetch("CLICKSEND_USERNAME", nil),
45
57
  api_key: ENV.fetch("CLICKSEND_API_KEY", nil),
46
58
  base_url: DEFAULT_BASE_URL,
47
59
  timeout: DEFAULT_TIMEOUT,
48
60
  open_timeout: DEFAULT_OPEN_TIMEOUT,
49
- max_retries: DEFAULT_MAX_RETRIES,
61
+ max_retries: nil,
62
+ retry_policy: nil,
50
63
  logger: nil,
64
+ instrumenter: nil,
51
65
  adapter: nil,
52
66
  transport: nil
53
67
  )
@@ -56,32 +70,39 @@ module Clicksend
56
70
  @base_url = normalize_base_url!(base_url)
57
71
  @timeout = positive_number!(timeout, "timeout")
58
72
  @open_timeout = positive_number!(open_timeout, "open_timeout")
59
- unless max_retries.is_a?(Integer) && max_retries >= 0
60
- raise ConfigurationError, "max_retries must be a non-negative Integer"
73
+ @retry_policy = build_retry_policy(max_retries, retry_policy)
74
+ @instrumenter = instrumenter || Instrumentation::Null
75
+ unless @instrumenter.respond_to?(:instrument)
76
+ raise ConfigurationError, "instrumenter must respond to #instrument(name, payload) { ... }"
61
77
  end
62
- @max_retries = max_retries
63
78
 
64
79
  @settings = {
65
80
  username: @username, api_key: api_key, base_url: @base_url, timeout: @timeout,
66
- open_timeout: @open_timeout, max_retries: @max_retries, logger: logger,
67
- adapter: adapter, transport: transport
81
+ open_timeout: @open_timeout, max_retries: max_retries, retry_policy: retry_policy, logger: logger,
82
+ instrumenter: instrumenter, adapter: adapter, transport: transport
68
83
  }.freeze
69
84
 
70
85
  @connection = Connection.new(
71
86
  transport: transport || Transport::Faraday.new(base_url: @base_url, timeout: @timeout, open_timeout: @open_timeout, adapter: adapter),
72
- retry_policy: RetryPolicy.new(max_retries: @max_retries),
87
+ retry_policy: @retry_policy,
73
88
  headers: {
74
89
  "Authorization" => "Basic #{["#{@username}:#{api_key}"].pack("m0")}",
75
90
  "Accept" => "application/json",
76
91
  "User-Agent" => "clicksend-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
77
92
  },
78
- logger: logger
93
+ logger: logger,
94
+ instrumenter: @instrumenter
79
95
  )
80
96
  @account = Resources::Account.new(self)
81
97
  @sms = Resources::SMS.new(self)
82
98
  freeze
83
99
  end
84
100
 
101
+ # Retries allowed after a failed attempt (from the retry policy).
102
+ def max_retries
103
+ retry_policy.max_retries
104
+ end
105
+
85
106
  # Calls any ClickSend v3 endpoint, wrapped by this gem or not.
86
107
  #
87
108
  # client.request(:get, "/v3/sms/history", query: {date_from: (Time.now - 86_400).to_i})
@@ -98,21 +119,27 @@ module Clicksend
98
119
  # it may already have reached ClickSend (timeouts, 5xx). Defaults to true
99
120
  # for GET only: ClickSend uses POST/PUT for operations such as sending
100
121
  # messages and buying credit, so they are not assumed to be repeatable.
122
+ # A failure of a non-idempotent request that may have been processed is
123
+ # a Clicksend::AmbiguousRequestError.
124
+ # @param operation [String, nil] a label for logs and instrumentation,
125
+ # e.g. "templates.create"; wrapped methods use names like "sms.deliver"
101
126
  # @return [Clicksend::Response]
102
127
  # @raise [Clicksend::Error] see the error hierarchy in errors.rb
103
- def request(method, path, query: nil, body: nil, idempotent: nil)
128
+ def request(method, path, query: nil, body: nil, idempotent: nil, operation: nil)
104
129
  method = method.to_s.downcase.to_sym
105
130
  unless Connection::HTTP_METHODS.include?(method)
106
131
  raise ArgumentError, "unsupported HTTP method #{method.inspect}; use one of #{Connection::HTTP_METHODS.join(", ")}"
107
132
  end
108
133
  validate_path!(path)
109
134
  raise ArgumentError, "query must be a Hash" unless query.nil? || query.is_a?(Hash)
135
+ raise ArgumentError, "operation must be a String" unless operation.nil? || operation.is_a?(String)
110
136
 
111
137
  @connection.request(
112
138
  method, path,
113
139
  query: query&.compact,
114
140
  body: body,
115
- idempotent: idempotent.nil? ? method == :get : idempotent
141
+ idempotent: idempotent.nil? ? method == :get : idempotent == true,
142
+ operation: operation
116
143
  )
117
144
  end
118
145
 
@@ -122,17 +149,21 @@ module Clicksend
122
149
  # page.auto_paging_each { |message| ... }
123
150
  #
124
151
  # @return [Clicksend::Page]
125
- def paginate(path, query: {}, page: nil, limit: nil)
126
- Page.fetch(self, path, query: query, page: page, limit: limit)
152
+ def paginate(path, query: {}, page: nil, limit: nil, operation: nil)
153
+ Page.fetch(self, path, query: query, page: page, limit: limit, operation: operation)
127
154
  end
128
155
 
129
156
  # Returns a new client with some settings changed, e.g. a subaccount's
130
157
  # credentials or a shorter timeout for a latency-sensitive code path.
158
+ # Overriding +max_retries+ replaces the retry policy, and vice versa.
131
159
  def with(**overrides)
132
160
  unknown = overrides.keys - @settings.keys
133
161
  raise ArgumentError, "unknown setting(s): #{unknown.join(", ")}" unless unknown.empty?
134
162
 
135
- self.class.new(**@settings, **overrides)
163
+ settings = @settings
164
+ settings = settings.merge(retry_policy: nil) if overrides.key?(:max_retries)
165
+ settings = settings.merge(max_retries: nil) if overrides.key?(:retry_policy)
166
+ self.class.new(**settings, **overrides)
136
167
  end
137
168
 
138
169
  def inspect
@@ -150,6 +181,16 @@ module Clicksend
150
181
  raise ConfigurationError, "Missing ClickSend #{name}: pass #{name}: or set #{env_name}"
151
182
  end
152
183
 
184
+ def build_retry_policy(max_retries, retry_policy)
185
+ unless max_retries.nil? || retry_policy.nil?
186
+ raise ConfigurationError, "pass max_retries: or retry_policy:, not both"
187
+ end
188
+ return RetryPolicy.new(max_retries: max_retries.nil? ? DEFAULT_MAX_RETRIES : max_retries) if retry_policy.nil?
189
+ return retry_policy if retry_policy.respond_to?(:delay) && retry_policy.respond_to?(:max_retries)
190
+
191
+ raise ConfigurationError, "retry_policy must respond to #delay(error:, attempt:) and #max_retries"
192
+ end
193
+
153
194
  def positive_number!(value, name)
154
195
  return value if value.is_a?(Numeric) && value.positive?
155
196
 
@@ -4,8 +4,12 @@ require "json"
4
4
 
5
5
  module Clicksend
6
6
  # Runs one logical API call over a transport: encodes the JSON body, parses
7
- # the response envelope, maps failures to Clicksend errors, retries when the
8
- # retry policy says it is safe, and logs a one-line summary per attempt.
7
+ # the response envelope, maps failures to Clicksend errors, retries when it
8
+ # is safe, and reports each call to the logger and instrumenter.
9
+ #
10
+ # Whether a failure may be retried at all is decided here, from what is
11
+ # known about the failure, and cannot be changed by configuration. The retry
12
+ # policy only chooses the delay and enforces the retry budget.
9
13
  #
10
14
  # @api private Use Client#request instead.
11
15
  class Connection
@@ -19,70 +23,211 @@ module Clicksend
19
23
  429 => RateLimitError
20
24
  }.freeze
21
25
 
26
+ # How much is known about a failed attempt, which decides retries and
27
+ # ambiguity:
28
+ #
29
+ # [:rate_limited] HTTP 429: not processed (ClickSend's documentation)
30
+ # [:not_sent] failed before the request was written
31
+ # [:unknown] may have been processed: read timeout, reset, TLS, 5xx
32
+ # [:undocumented] an error inside a 2xx body, or an unreadable 2xx body
33
+ # [:rejected] any other 4xx: ClickSend refused the request
34
+ RETRY_ALWAYS = %i[rate_limited not_sent].freeze
35
+ MAY_HAVE_BEEN_PROCESSED = %i[unknown undocumented].freeze
36
+
22
37
  # @param headers [Hash] sent with every request (authentication, User-Agent)
23
- def initialize(transport:, retry_policy:, headers: {}, logger: nil)
38
+ # @param instrumenter [#instrument] see Clicksend::Instrumentation
39
+ def initialize(transport:, retry_policy:, headers: {}, logger: nil, instrumenter: Instrumentation::Null)
24
40
  @transport = transport
25
41
  @retry_policy = retry_policy
26
42
  @headers = headers.dup.freeze
27
43
  @logger = logger
44
+ @instrumenter = instrumenter
28
45
  end
29
46
 
30
47
  # @return [Clicksend::Response]
31
48
  # @raise [Clicksend::Error]
32
- def request(method, path, query: nil, body: nil, idempotent: false)
49
+ def request(method, path, query: nil, body: nil, idempotent: false, operation: nil)
33
50
  headers = @headers
34
51
  unless body.nil?
35
52
  headers = headers.merge("Content-Type" => "application/json")
36
53
  body = JSON.generate(body)
37
54
  end
38
55
 
56
+ call = {method: method, path: path, operation: operation, idempotent: idempotent}
57
+ instrumented(call) { |payload| run(call, query, body, headers, payload) }
58
+ end
59
+
60
+ # Never show the Authorization header.
61
+ def inspect
62
+ "#<#{self.class.name}>"
63
+ end
64
+
65
+ private
66
+
67
+ # Runs the call inside the instrumenter's request.clicksend block, but
68
+ # never lets the instrumenter change the outcome: once the block has run,
69
+ # whatever the instrumenter raises (a failing subscriber, a frozen
70
+ # payload, ...) is logged and the call's own result or error wins. Without
71
+ # this, a metrics outage after an accepted send would surface as a
72
+ # non-Clicksend error that a job runner retries: a duplicate SMS.
73
+ #
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.
81
+ def instrumented(call)
82
+ payload = {http_method: call[:method], path: reported_path(call[:path]), operation: call[:operation], idempotent: call[:idempotent]}
83
+ state = :pending
84
+ outcome = nil
85
+ begin
86
+ @instrumenter.instrument("request.clicksend", payload) do
87
+ raise ConfigurationError, "the instrumenter ran the request block twice; #instrument must yield once" unless state == :pending
88
+
89
+ state = :running
90
+ outcome = begin
91
+ yield payload
92
+ rescue Error => e
93
+ e
94
+ end
95
+ state = :done
96
+ # Raise inside the block so ActiveSupport records the exception.
97
+ outcome.is_a?(Error) ? raise(outcome) : outcome
98
+ end
99
+ rescue Exception => e # rubocop:disable Lint/RescueException
100
+ raise unless state == :done && e.is_a?(StandardError) && !e.equal?(outcome)
101
+
102
+ log(:warn) { "instrumenter raised #{e.class.name} for #{call[:method].upcase} #{reported_path(call[:path])}; ignored" }
103
+ end
104
+ raise ConfigurationError, "the instrumenter did not run the request: #instrument must yield" if state == :pending
105
+ raise outcome if outcome.is_a?(Error)
106
+
107
+ outcome
108
+ end
109
+
110
+ def run(call, query, body, headers, payload)
39
111
  attempt = 0
40
112
  loop do
41
- outcome, retry_allowed = attempt_request(method, path, query, body, headers)
42
- return outcome if outcome.is_a?(Response)
113
+ info = RequestInfo.new(http_method: call[:method], path: reported_path(call[:path]), operation: call[:operation],
114
+ idempotent: call[:idempotent], attempts: attempt + 1)
115
+ outcome, kind = attempt_request(call, query, body, headers)
116
+ if outcome.is_a?(Response)
117
+ record(payload, attempts: info.attempts, http_status: outcome.http_status, response_code: outcome.response_code, ambiguous: false)
118
+ return outcome.with(request: info)
119
+ end
43
120
 
44
121
  error = outcome
45
- delay = retry_allowed && @retry_policy.delay(error: error, attempt: attempt, idempotent: idempotent)
46
- raise error unless delay
122
+ error.request = info
123
+ delay = retry_delay(error, kind, attempt, call[:idempotent])
124
+ unless delay
125
+ error.mark_ambiguous! if !call[:idempotent] && MAY_HAVE_BEEN_PROCESSED.include?(kind)
126
+ record(payload, attempts: info.attempts, http_status: error_status(error), response_code: error_code(error), ambiguous: error.ambiguous?)
127
+ raise error
128
+ end
47
129
 
48
130
  attempt += 1
49
- log(:warn) do
50
- "#{method.upcase} #{path} failed (#{error.class.name}), retrying in #{format("%.2f", delay)}s " \
51
- "(retry #{attempt} of #{@retry_policy.max_retries})"
52
- end
131
+ announce_retry(call, error, attempt, delay)
53
132
  Kernel.sleep(delay)
54
133
  end
55
134
  end
56
135
 
57
- # Never show the Authorization header.
58
- def inspect
59
- "#<#{self.class.name}>"
136
+ # One HTTP attempt. Returns a Response, or [error, kind]. Errors are
137
+ # returned rather than raised so the retry decision can use facts about
138
+ # this attempt without storing state on the (shared) Connection.
139
+ #
140
+ # Errors raised by the transport are copied before the connection adds
141
+ # context, so a frozen or reused exception instance is never modified.
142
+ # Anything a custom transport raises that is not a Clicksend::Error is
143
+ # treated as a connection failure that may have been sent.
144
+ def attempt_request(call, query, body, headers)
145
+ started = monotonic_now
146
+ raw = begin
147
+ @transport.call(call[:method], call[:path], query: query, body: body, headers: headers)
148
+ rescue ConnectionError => e
149
+ return [e.dup, e.request_may_have_been_sent? ? :unknown : :not_sent]
150
+ rescue MalformedResponseError => e
151
+ return [e.dup, :undocumented]
152
+ rescue Error => e
153
+ return [e.dup, :unknown] # any other Clicksend error from a custom transport: outcome unknown
154
+ rescue => e
155
+ return [wrap_failure(ConnectionError, "The transport failed", e), :unknown]
156
+ end
157
+ log(:info) { "#{call[:method].upcase} #{reported_path(call[:path])} -> #{raw.status} (#{elapsed_ms(started)}ms)" }
158
+ begin
159
+ interpret(raw)
160
+ rescue MalformedResponseError => e
161
+ [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.
165
+ [wrap_failure(MalformedResponseError, "Could not read ClickSend's response", e), :undocumented]
166
+ end
60
167
  end
61
168
 
62
- private
63
-
64
- # One HTTP attempt. Returns a Response, or [error, retry_allowed]. Errors
65
- # are returned rather than raised so the retry decision can use facts
66
- # about this attempt without storing state on the (shared) Connection.
67
- def attempt_request(method, path, query, body, headers)
68
- started = monotonic_now
69
- raw = @transport.call(method, path, query: query, body: body, headers: headers)
70
- log(:info) { "#{method.upcase} #{path} -> #{raw.status} (#{elapsed_ms(started)}ms)" }
71
- interpret(raw)
72
- rescue ConnectionError => e
73
- [e, true]
169
+ # Raised (and rescued) inside the caller's rescue, so #cause is the
170
+ # original exception. Only its class is copied into the message: a
171
+ # foreign exception's message may hold a URL, a query string or a body.
172
+ def wrap_failure(error_class, text, error)
173
+ raise error_class, "#{text}: #{error.class.name}"
174
+ rescue error_class => e
175
+ e
74
176
  end
75
177
 
76
- # @return [Response, Array(APIError, Boolean)]
178
+ # @return [Response, Array(Error, Symbol)]
77
179
  def interpret(raw)
180
+ unless raw.respond_to?(:status) && raw.status.is_a?(Integer) && raw.status.between?(100, 599)
181
+ raise MalformedResponseError, "The transport returned no valid HTTP status"
182
+ end
183
+
78
184
  body = parse_body(raw)
79
185
  status = effective_status(raw.status, body)
80
186
  return Response.new(http_status: raw.status, headers: raw.headers, body: body) if success?(status)
81
187
 
188
+ error = api_error(status, raw, body)
82
189
  # An error reported only inside a 2xx body is undocumented for v3, so
83
- # nothing is known about whether ClickSend acted on the request: never
84
- # retry it, whatever the reported code.
85
- [api_error(status, raw, body), status == raw.status]
190
+ # nothing is known about whether ClickSend acted on the request.
191
+ kind = if status != raw.status then :undocumented
192
+ elsif status == 429 then :rate_limited
193
+ elsif status >= 500 then :unknown
194
+ elsif status >= 400 then :rejected
195
+ else :undocumented # 1xx/3xx: not expected from ClickSend (redirects are not followed)
196
+ end
197
+ [error, kind]
198
+ end
199
+
200
+ # The safety rule, then the policy's timing and budget.
201
+ #
202
+ # The connection also enforces the policy's own max_retries, so a policy
203
+ # whose #delay never says no still cannot loop forever.
204
+ def retry_delay(error, kind, attempt, idempotent)
205
+ eligible = RETRY_ALWAYS.include?(kind) || (kind == :unknown && idempotent)
206
+ return unless eligible
207
+
208
+ budget = @retry_policy.max_retries
209
+ return unless budget.is_a?(Integer) && attempt < budget
210
+
211
+ delay = @retry_policy.delay(error: error, attempt: attempt)
212
+ delay = Float(delay) if delay.is_a?(Numeric)
213
+ delay if delay.is_a?(Float) && delay.finite? && delay >= 0
214
+ rescue => e
215
+ # A broken policy stops retrying (the safe direction) and keeps the
216
+ # request's own error.
217
+ log(:warn) { "retry policy raised #{e.class.name}; not retrying" }
218
+ nil
219
+ end
220
+
221
+ def announce_retry(call, error, attempt, delay)
222
+ log(:warn) do
223
+ "#{call[:method].upcase} #{reported_path(call[:path])} failed (#{error.class.name}), retrying in #{format("%.2f", delay)}s " \
224
+ "(retry #{attempt} of #{@retry_policy.max_retries})"
225
+ end
226
+ payload = {http_method: call[:method], path: reported_path(call[:path]), operation: call[:operation], attempt: attempt,
227
+ delay: delay, error_class: error.class.name, http_status: error_status(error)}
228
+ @instrumenter.instrument("retry.clicksend", payload) {}
229
+ rescue => e
230
+ log(:warn) { "instrumenter raised #{e.class.name} for retry.clicksend; ignored" }
86
231
  end
87
232
 
88
233
  def api_error(status, raw, body)
@@ -105,7 +250,7 @@ module Clicksend
105
250
  # Returns the parsed JSON, nil for an empty body, or the raw String when an
106
251
  # error response isn't JSON (e.g. an HTML page from a proxy).
107
252
  def parse_body(raw)
108
- return nil if raw.body.strip.empty?
253
+ return nil if raw.body.b.strip.empty?
109
254
 
110
255
  JSON.parse(raw.body, freeze: true)
111
256
  rescue JSON::ParserError
@@ -134,12 +279,38 @@ module Clicksend
134
279
  (200..299).cover?(status)
135
280
  end
136
281
 
282
+ def error_status(error)
283
+ error.http_status if error.respond_to?(:http_status)
284
+ end
285
+
286
+ def error_code(error)
287
+ error.response_code if error.respond_to?(:response_code)
288
+ end
289
+
137
290
  def string_or_nil(value)
138
291
  value.is_a?(String) ? value : nil
139
292
  end
140
293
 
294
+ # Adds the outcome to the request.clicksend payload. The payload belongs
295
+ # to the instrumenter's subscribers too; if one froze or replaced it,
296
+ # the outcome is simply not recorded.
297
+ def record(payload, **outcome)
298
+ payload.update(outcome)
299
+ rescue
300
+ nil
301
+ end
302
+
303
+ # A failing logger must not turn a completed request into an error.
141
304
  def log(level)
142
305
  @logger&.public_send(level, "[clicksend] #{yield}")
306
+ rescue
307
+ nil
308
+ end
309
+
310
+ # The path as reported in errors, logs and instrumentation: never with a
311
+ # query string or fragment, even if one was written into the path.
312
+ def reported_path(path)
313
+ path.split(/[?#]/, 2).first
143
314
  end
144
315
 
145
316
  def monotonic_now