Repository navigation
Cancelling and reconciling messages, test-framework helpers, opt-in persistent connections, and
hardening. Mostly additive. "Fixed" lists behaviour changes an existing application may notice,
such as Marshal.dump(client) and YAML.dump(client) now raising and stricter Retry-After
parsing. sms.cancel is experimental and may change in a minor release: its successful answer
has not been observed live (see below). The OpenTelemetry instrumenter is a separate gem,
clicksend-opentelemetry (companions/), released on its own schedule.
Added
sms.cancel(message_id)(experimental) cancels one scheduled SMS
(PUT /v3/sms/{message_id}/cancel) and returns nil. ClickSend documents only the successful
answer, and no idempotency, so it is not retried after a timeout or 5xx; such a failure is an
AmbiguousRequestError(the message may or may not have been cancelled). Check
sms.history(message_id:)for the status"Cancelled"when it matters.
PUT /v3/sms/cancel-allis still deliberately not wrapped.
The successful 200SUCCESSanswer is covered by contract specs but was not observed live:
ClickSend's free test number doesn't hold scheduled messages (they show asCompletedwithin
seconds), so it can't exercise a successful cancel. Live cancels of those test-number messages,
a repeated cancel and a random ID all answered HTTP 404NOT_FOUND, raised as
Clicksend::NotFoundError. A 404 doesn't say which case applies; it does not mean "already
sent". The API may change in a minor release once a real cancellation has been observed.sms.search_history(to:, custom_string:, sent_after:, sent_before: nil)returns the outbound
history rows ClickSend shows now for that recipient and exactcustom_string, possibly none. It
widens the date window by five minutes on each side, reads every page (100 rows each), and
requires an E.164 recipient. An empty result is not proof that nothing was sent.- Testing:
FakeAPIcancels messages it holds as scheduled for the future
(fake.cancelled_messages) and raisesStubErrorfor every cancel ClickSend doesn't document,
so tests must stub that answer.fake.stub_history(*messages, status: "Sent")states what
history shows; the fake still serves no history by itself. - Testing: opt-in RSpec matchers,
require "clicksend/testing/rspec":
expect(fake).to have_sent_sms(to:, body:, custom_string:, ...)(anySentMessageattribute,
matched with===) with.once,.twice,.times(n)and.exactly(n).times;
not_to have_sent_sms(...); andhave_sent_no_sms. Without a count exactly one message must
match, so a duplicate fails, and the negated form means "none matching". Failures list what was
sent, one line per message (at most ten, long values shortened). The require includes the
matchers in every example group (Clicksend::Testing::RSpecMatchers). - Testing: opt-in Minitest assertions,
require "clicksend/testing/minitest"and
include Clicksend::Testing::MinitestAssertions:assert_sms_sent(fake, count: 1, **attributes)
(returns the matching messages) andassert_no_sms_sent(fake, **attributes), with the same
matching and failure output. Neither framework is a dependency or loaded by
require "clicksend"orrequire "clicksend/testing". - Testing:
fake.fail_next(:interrupted, processed: true|false)simulates the job runner stopping
the worker mid-send (Sidekiq's shutdown, a deploy's SIGTERM), after or before ClickSend processed
the request. It raisesClicksend::Testing::SimulatedInterrupt, anExceptionthat is neither
aStandardErrornor anInterrupt, so it passes through the client untouched and is never
retried. Use it to test that the job's re-run doesn't send again. ClickSend itself never does
this.
Fixed
- Instrumenters that don't run the block synchronously can no longer send late or return nil.
Arequest.clicksendblock kept by the instrumenter and called after#instrumentreturned
now raisesConfigurationErrorwithout sending (before, the call raisedConfigurationError
and the SMS was sent later anyway). If#instrumentreturns while the block is still running on
another thread, or after swallowing an exception that escaped the request, the call raises a
ConfigurationErrorthat is also anAmbiguousRequestErrorunless the request is idempotent
(before,Client#requestcould return nil while the send went ahead). - A
ScriptErrorfrom code outside the gem is handled like any other failure. A logger or
instrumenter raisingNotImplementedErrororLoadErrorafter a send was accepted escaped as a
non-Clicksend error (also in 1.1); job runners such as Sidekiq rescueExceptionand would run
the job, and the send, again. Loggers, instrumenters, retry policies and custom transports are
now treated the same forStandardErrorandScriptError: the request's own result or error
wins, and a custom transport'sScriptErroris an ambiguousConnectionErrorfor a send.
Interrupt,SystemExitandNoMemoryErrorstill propagate. Clicksend::ClientrefusesMarshal.dumpandYAML.dump(TypeError), including inside
another object such asclient.sms. A client holds the API key, which both used to write out in
clear (e.g. into a cache or a job payload). Build a new client instead. Responses and errors can
still be serialized. Other serializers that walk instance variables (e.g. ActiveSupport's
Object#as_json) are not covered: pass job arguments, not clients.- A retry delay too long to sleep no longer raises
RangeError. With
RetryPolicy.new(max_retry_after: Float::INFINITY), aRetry-After: 99999999999999999999made
Kernel.sleepraiseRangeErrorinstead of theRateLimitError. A delay over 2**31 - 1 seconds
(from any policy) now means "don't retry": the request's own error is raised. - A retry policy answering with an Integer or Rational too large for a Float no longer makes
Ruby print "Integer out of Float range" (with-W); it means "don't retry", as before. - Pagination never raises a non-Clicksend error for a nonsensical page. A
current_pagebelow 1,
or a negativelast_page,totalorper_page, is aMalformedResponseErrorwith the request
attached (before,current_page: -1madenext_pageraiseArgumentError).
client.paginate(path, query: nil)now means no query, likeClient#request; any other
non-Hashquery:raisesArgumentError(before, both raisedNoMethodError). - A response body that isn't valid in its declared charset is classified by its status. A
body labelled e.g.charset=us-ascii,shift_jisorutf-16lethat holds bytes invalid in that
charset made JSON raise anEncodingError, so every such response became an unreadable
(MalformedResponseError) one: a GET's 503 was not retried, and a send's 429 was reported as
ambiguous instead of being retried. Such a body is now treated like any other non-JSON body: an
error status keeps the raw body and its usual error class and retry rule; a 2xx is still a
MalformedResponseError(ambiguous for a send). Bodies labelledutf-8were already handled. RateLimitError#retry_afteraccepts only what RFC 9110 allows: plain non-negative decimal
seconds or an HTTP-date. It used Ruby'sInteger(), so"0x10"meant 16 seconds,"1_0"10 and
"+5"5; those,"-5"(before: 0) and non-String values are now nil, and the retry policy backs
off as for a missing header. It no longer raises fornilheaders or an Array value from a
custom transport, so such a 429 is retried with backoff instead of being raised at once.- Testing:
FakeAPI#client(max_retries:, retry_policy:)silently ignoredmax_retries:. It now
raisesConfigurationError, exactly asClient.newdoes for both, andmax_retries: nilmeans
the default, as inClient.new. - With
adapter: :net_http_persistent, failures before the request was written are no longer
ambiguous. That adapter reports them differently from the default one, so a refused connection
(Net::HTTP::Persistent::Error"connection refused", caused byErrno::ECONNREFUSED), a connect
or TLS-handshake timeout (Net::OpenTimeout, wrapped inFaraday::TimeoutError) and a wait for a
pooled connection longer than connection_pool's 0.5 s (ConnectionPool::TimeoutError) were
classified as possibly sent: a send that never left was anAmbiguousRequestErrorand not
retried. They are now not sent, so they are retried like the default adapter's (and a pool wait is
aTimeoutError). A downed host and TLS errors still count as possibly sent with either adapter.
Changed
- Webhook documentation corrected from new evidence (
Clicksend::Webhookis still
experimental; no behaviour changed). Archived ClickSend help articles and ClickSend's own n8n
and Power Automate integrations list the pushed fields, including legacy duplicates (message,
sms,originalsenderid,messageid,customstring, ...) that stay in#raw. The README and
API notes no longer say that no source ever listed IP addresses: an archived article did, but it
is stale and unpublished, so the gem still offers no allowlist. Archived sources disagree on the
retry schedule, which is now said. Receipts must be deduplicated onmessage_idand
status_code, notmessage_idalone, because a message may get more than one receipt. New
receiver advice: secret rotation,discard_on Clicksend::Webhook::InvalidPayload, Rails'
log filtering, voice/email/fax receipts sharing the format, inbound MMS links, and the
dashboard's "Add Test Reply".
Documentation
- Background jobs rewritten from new measurements (ActiveJob 8.1.4, Sidekiq 8.1.7 and
ActiveRecord 8.1.4 against local stand-ins for ClickSend). The 1.1 recipe stops framework
retries from repeating an ambiguous send, but a real Sidekiq process stopped mid-send re-ran the
job and sent the message twice with the default 30s read timeout (once with a 1s timeout). The
README now covers: a timeout budget for job clients (one attempt must end inside the runner's
shutdown timeout; Sidekiq's default is 25s); theretry_on/discard_ondeclaration order (the
same two lines in the wrong order sent twice); stacked retry layers (retry_onre-raises when
exhausted and the backend retries again); an in-flight marker committed on the application's own
row beforedeliver(claiming inside the send's own transaction still sent twice); a
reconciliation job built onsms.search_historythat never resends on "not found"; and a
Sidekiq recipe withsidekiq_retry_inreturning:kill. - Observability recipes:
key=valuelogs, metrics labelled byoperation(never bypath, which
can hold message IDs), and a Rails 8.1Rails.eventbridge. The OpenTelemetry warning now names
what the stock Faraday and Net::HTTP instrumentations record (the query string, with the
recipient's number fromsms.history(to:)) and how to exclude ClickSend from them. - Persistent connections: the measured benefit (local benchmark), the pool-size rule, and which
failures are retried under that adapter (see "Fixed"). - The companion gem
clicksend-opentelemetry0.1.0, incompanions/clicksend-opentelemetry, is
versioned and released separately and is not yet on RubyGems: one OpenTelemetry span per
ClickSend call, built on theinstrumenter:hook, with no query strings, bodies or phone
numbers. It changes nothing in this gem, which still depends on Faraday only. See its own
CHANGELOG.
Development
- Webhook replay fixtures (
spec/fixtures/webhooks, one per published push shape, replayed
through Rack's request parsing) andscript/webhook_capture.rb, which captures real pushes
locally and redacts them into fixtures.rackis a new development dependency. minitestis now declared as a development dependency (it was only pulled in through
activesupport); the Minitest assertions are tested inside realMinitest::Testcases.faraday-net_http_persistentis a new development dependency:
spec/integration/persistent_connection_spec.rbruns that adapter on real sockets (plain and
TLS) and pins that a reused connection failing after the write never hides a retry of a POST or
PUT, that timeouts are honoured, and the not-sent classifications above.
SHA-256 of clicksend-1.2.0.gem on RubyGems: f8fa8f43a30b7a4b8e592eca20ea331e3758a6640b3e84f29ef160773c678652