clicksend 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -0
- data/README.md +418 -81
- data/docs/clicksend-api-notes.md +52 -16
- data/lib/clicksend/client.rb +58 -17
- data/lib/clicksend/connection.rb +203 -32
- data/lib/clicksend/errors.rb +99 -3
- data/lib/clicksend/instrumentation.rb +42 -0
- data/lib/clicksend/model.rb +9 -0
- data/lib/clicksend/page.rb +6 -3
- data/lib/clicksend/rate_limit.rb +40 -0
- data/lib/clicksend/resources/account.rb +1 -1
- data/lib/clicksend/resources/sms.rb +98 -20
- data/lib/clicksend/response.rb +35 -0
- data/lib/clicksend/retry_policy.rb +44 -27
- data/lib/clicksend/sms/history_record.rb +85 -0
- data/lib/clicksend/sms/message.rb +1 -1
- data/lib/clicksend/testing/failure.rb +81 -0
- data/lib/clicksend/testing/fake_api.rb +422 -0
- data/lib/clicksend/testing/payloads.rb +88 -0
- data/lib/clicksend/testing/records.rb +27 -0
- data/lib/clicksend/testing.rb +88 -0
- data/lib/clicksend/version.rb +1 -1
- data/lib/clicksend/webhook.rb +182 -0
- data/lib/clicksend.rb +4 -0
- metadata +10 -1
data/docs/clicksend-api-notes.md
CHANGED
|
@@ -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-
|
|
5
|
-
|
|
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
|
|
26
|
-
|
|
|
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
|
|
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
|
|
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
|
|
61
|
-
and
|
|
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
|
|
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`,
|
|
151
|
-
because ClickSend uses `POST` and `PUT` for sends,
|
|
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.
|
data/lib/clicksend/client.rb
CHANGED
|
@@ -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
|
|
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+,
|
|
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:
|
|
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
|
-
|
|
60
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
data/lib/clicksend/connection.rb
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|