Skip to content

Releases: prayantr/clicksend

clicksend 1.2.0

Choose a tag to compare

@amitkssolanki amitkssolanki released this 06 Oct 16:19
9ce4956

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-all is still deliberately not wrapped.
    The successful 200 SUCCESS answer is covered by contract specs but was not observed live:
    ClickSend's free test number doesn't hold scheduled messages (they show as Completed within
    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 404 NOT_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 exact custom_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: FakeAPI cancels messages it holds as scheduled for the future
    (fake.cancelled_messages) and raises StubError for 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:, ...) (any SentMessage attribute,
    matched with ===) with .once, .twice, .times(n) and .exactly(n).times;
    not_to have_sent_sms(...); and have_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) and assert_no_sms_sent(fake, **attributes), with the same
    matching and failure output. Neither framework is a dependency or loaded by
    require "clicksend" or require "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 raises Clicksend::Testing::SimulatedInterrupt, an Exception that is neither
    a StandardError nor an Interrupt, 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.
    A request.clicksend block kept by the instrumenter and called after #instrument returned
    now raises ConfigurationError without sending (before, the call raised ConfigurationError
    and the SMS was sent later anyway). If #instrument returns while the block is still running on
    another thread, or after swallowing an exception that escaped the request, the call raises a
    ConfigurationError that is also an AmbiguousRequestError unless the request is idempotent
    (before, Client#request could return nil while the send went ahead).
  • A ScriptError from code outside the gem is handled like any other failure. A logger or
    instrumenter raising NotImplementedError or LoadError after a send was accepted escaped as a
    non-Clicksend error (also in 1.1); job runners such as Sidekiq rescue Exception and would run
    the job, and the send, again. Loggers, instrumenters, retry policies and custom transports are
    now treated the same for StandardError and ScriptError: the request's own result or error
    wins, and a custom transport's ScriptError is an ambiguous ConnectionError for a send.
    Interrupt, SystemExit and NoMemoryError still propagate.
  • Clicksend::Client refuses Marshal.dump and YAML.dump (TypeError), including inside
    another object such as client.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), a Retry-After: 99999999999999999999 made
    Kernel.sleep raise RangeError instead of the RateLimitError. 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_page below 1,
    or a negative last_page, total or per_page, is a MalformedResponseError with the request
    attached (before, current_page: -1 made next_page raise ArgumentError).
    client.paginate(path, query: nil) now means no query, like Client#request; any other
    non-Hash query: raises ArgumentError (before, both raised NoMethodError).
  • 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_jis or utf-16le that holds bytes invalid in that
    charset made JSON raise an EncodingError, 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 labelled utf-8 were already handled.
  • RateLimitError#retry_after accepts only what RFC 9110 allows: plain non-negative decimal
    seconds or an HTTP-date. It used Ruby's Integer(), 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 for nil headers 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 ignored max_retries:. It now
    raises ConfigurationError, exactly as Client.new does for both, and max_retries: nil means
    the default, as in Client.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 by Errno::ECONNREFUSED), a connect
    or TLS-handshake timeout (Net::OpenTimeout, wrapped in Faraday::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 an AmbiguousRequestError and not
    retried. They are now not sent, so they are retried like the default adapter's (and a pool wait is
    a TimeoutError). A downed host and TLS errors still count as possibly sent with either adapter.

Changed

  • Webhook documentation corrected from new evidence (Clicksend::Webhook is 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 on message_id and
    status_code, not message_id alone, 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 ...
Read more

clicksend 1.1.0

Choose a tag to compare

@amitkssolanki amitkssolanki released this 06 Oct 11:13
dc792c4

clicksend 1.1.0 adds failure semantics, observability and testing support for production messaging. It is mostly additive; read "Changed" below before upgrading.

gem install clicksend -v 1.1.0

Highlights

  • Explicit ambiguity: rescue Clicksend::AmbiguousRequestError catches every failure where ClickSend may or may not have accepted a send. The error keeps its class, so existing rescue clauses still work.
  • A retry rule no configuration can override. A send that may already have been processed is never repeated automatically. Clicksend::RetryPolicy only tunes timing and budget.
  • Request context on errors and responses, plus retryable? and ambiguous?.
  • sms.history: the closest documented way to look for an ambiguous send. A missing row is not proof that nothing was sent.
  • Clicksend::Testing::FakeAPI: an in-memory ClickSend for tests, with failure injection that asks whether ClickSend processed the request.
  • Instrumentation for ActiveSupport::Notifications, without credentials, query strings or bodies.
  • Experimental: webhook parsing and rate-limit metadata, which rest on behaviour ClickSend doesn't document.

This reduces the risk of duplicate SMS. It is not a guarantee against every possible duplicate; see the README's "Background jobs" section.

Verified: the published gem is byte-identical to a rebuild of v1.1.0 (SHA-256 12948d70ff172c439f0c15e60d09af44f89edd28c2718c2d4925b7d378472a18). It was published with RubyGems Trusted Publishing.


Failure semantics, observability and testing support for production messaging. Mostly additive;
read "Changed" before upgrading. Two additions are experimental and may change in a minor
release: Clicksend::Webhook and Clicksend::RateLimit, because both rest on behaviour ClickSend
doesn't document.

Retry safety is unchanged in principle and stricter in practice. ClickSend has no idempotency key,
so a send that may already have been processed is never repeated automatically, and such failures
are now marked as ambiguous. This reduces the risk of duplicate SMS; it is not a guarantee against
every possible duplicate (for example, a job runner re-running a job after a crash).

Added

  • Explicit ambiguity. When a request that is not safe to repeat (such as an SMS send) fails
    in a way that ClickSend may still have processed, the error is extended with
    Clicksend::AmbiguousRequestError. That covers:

    • a timeout or reset after the request may have been written;
    • a 5xx;
    • an error reported inside a 2xx body;
    • a 2xx answer the gem can't read, including a send result without a per-message status.

    The error keeps its class, so existing rescue clauses still work. Error#ambiguous? is the
    predicate. (A dup of the error loses the mark; clone and re-raising keep it.)

  • Request context on errors and responses. Error#request and Response#request return a
    Clicksend::RequestInfo with http_method, path (no query string or fragment), operation
    (e.g. "sms.deliver"), idempotent and attempts. Response#request is not a Data
    member, so Response equality, to_h and pattern matching are unchanged from 1.0.

  • Error#retryable?: whether repeating the same request later is both safe and might succeed.

  • Retry configuration. Clicksend::RetryPolicy is public:
    Client.new(retry_policy: RetryPolicy.new(max_retries:, base_delay:, max_delay:, max_retry_after:)).
    The rule deciding which failures may be retried lives in the connection and cannot be
    changed by any policy, and the connection enforces the policy's own max_retries.

  • Rate limits (experimental). Response#rate_limit and APIError#rate_limit return a
    Clicksend::RateLimit (limit, remaining, reset_in) from the rate-limit headers observed
    live on GET /v3/account. They are nil when ClickSend sends none.

  • Instrumentation. Client.new(instrumenter:) accepts ActiveSupport::Notifications or any
    object with the same instrument signature that yields once. It publishes request.clicksend
    and retry.clicksend. Payloads never include credentials, query strings or bodies, and the
    wrapped methods' paths contain no phone numbers or message text. Client#request and
    #paginate take an optional operation: label.

  • Message history. sms.history(date_from:, date_to:, to:/from:/status:/message_id:, order:)
    returns a page of Clicksend::SMS::HistoryRecord, whose delivered?, failed? and pending?
    follow ClickSend's "SMS error codes" article and are all false when a row can't be classified
    (e.g. "Completed" with no gateway code, as observed live). ClickSend documents no way to look up
    a send by your own reference; history, filtered by recipient and matched on custom_string, is
    the closest. The README explains why a missing row is not proof that nothing was sent.

  • Webhooks (experimental). Clicksend::Webhook.parse_receipt, .parse_inbound and .parse
    turn pushed receipts and replies into SMS::Receipt and SMS::InboundMessage. ClickSend
    documents no way to authenticate pushes, so there is deliberately no verification method; the
    README explains how to secure the endpoint. No real push has been captured yet.

  • Testing. require "clicksend/testing" adds Clicksend::Testing::FakeAPI, an in-memory
    ClickSend you plug in as the transport, covering sends, receipts, replies and the account. It
    records sent messages and can inject failures, including ambiguous ones with an explicit
    processed: flag. It deliberately doesn't serve history; stub it. Mistakes in stubs surface
    as Clicksend::Testing::StubError (not a StandardError), never as a simulated ClickSend
    failure.

Changed

Behaviour an existing 1.0 application may notice:

  • Mark-read without a cutoff is no longer retried. sms.mark_receipts_read and
    sms.mark_inbound_read with no before: mark everything read at the moment ClickSend
    processes the call. Retrying after an unknown outcome could hide items that arrived in between.
    With before: they are still retried.
  • Error messages end with the request they came from, e.g. HTTP 500 (POST /v3/sms/send).
    Code that matched the exact message text needs updating. MessageRejected now carries
    #request too.
  • Unreadable 2xx answers to sends are ambiguous. Before, they were plain
    MalformedResponseErrors. That covers deliver and deliver_batch, including a message
    without a status, which 1.0 reported as a rejection (MessageRejected with a nil status, or
    in Batch#rejected).
  • Custom transports:
    • An exception that is not a Clicksend::Error becomes a ConnectionError that may have been
      sent. It is ambiguous for a send, retried for idempotent requests, and keeps the original as
      #cause; rescue MyTransportError around client calls no longer matches.
    • Any other Clicksend::Error raised by a transport is treated as an unknown outcome.
    • A response without a valid HTTP status, or an unexpected 1xx/3xx, is ambiguous for a send
      instead of being a rejection.
    • Exceptions raised by transports are copied before context is added.
  • Loggers and instrumenters cannot change a result. A logger or instrumenter that raises
    after a request completes is logged and ignored. An instrumenter that never runs the block
    raises ConfigurationError, and one that runs it twice can't send twice.
  • Configuration:
    • Client.new(max_retries:) and retry_policy: are mutually exclusive. Client#with replaces
      one with the other, and max_retries: nil now means the default.
    • RetryPolicy.new validates its arguments (ConfigurationError) and returns a frozen policy.
    • Client#request(idempotent:) honours only true; other truthy values, such as 1 or
      "false", no longer make a request retryable.

Compatibility

  • Ruby 3.3 or newer, as before; tested on 3.3, 3.4 and 4.0, with a Ruby head canary in CI.
  • Faraday 2 remains the only runtime dependency. ActiveSupport is used only in this gem's own
    tests; instrumenter: duck-types it.
  • No public method or class from 1.0 was removed. require "clicksend/testing" is opt-in and
    not loaded by require "clicksend".

Verification

  • Unit, integration (local real-socket servers) and contract specs cover the new behaviour.
    Contract specs check the wrapped operations, the fields the models read, and the documented
    page-size range against ClickSend's published OpenAPI files.
  • Live checks on 2026-10-06 were read-only: sms.history filtered to ClickSend's test number,
    and the rate-limit headers on GET /v3/account. No SMS was sent.
  • Not verified live: a webhook push (none has been captured), and a delivery receipt. The
    test number produced none, as ClickSend's legacy docs say it won't. Both are parsed according
    to ClickSend's published schemas and archived documentation.

Documentation

  • README: positioning, unknown send outcomes and reconciliation, webhooks, history, background
    jobs (including at-least-once job runners), instrumentation, and rate limits. Behaviour that
    ClickSend doesn't document is labelled as inferred or observed.
  • API notes: a review of all 34 of ClickSend's OpenAPI sections plus the archived push
    documentation; read-only live checks of history and rate-limit headers on 2026-10-06.
  • design/1.1-audit-and-roadmap.md: the audit, the decisions taken, the features rejected, and
    the reviews.

clicksend-opentelemetry 0.1.0

Choose a tag to compare

@amitkssolanki amitkssolanki released this 06 Oct 16:48
b8c6bc6

The optional OpenTelemetry companion for clicksend, released separately from it (gem "clicksend-opentelemetry"). clicksend itself never requires it.

The first release.

Added

  • Clicksend::OpenTelemetry::Instrumenter, passed to Clicksend::Client.new(instrumenter:). One
    span of kind CLIENT per ClickSend API call, retries included, named clicksend <operation>
    (clicksend <METHOD> when there is no operation). Attributes: http.request.method,
    server.address, server.port, url.path (never the query string; record_path: false leaves
    it out, and the path out of the exception event's message), http.response.status_code,
    error.type, and clicksend.operation, clicksend.idempotent, clicksend.attempts,
    clicksend.ambiguous, clicksend.response_code.
    Each retry is a clicksend.retry span event. A failed call sets the span status to ERROR and
    adds an exception event whose message is built only from the class, HTTP status, ClickSend's
    response_code and the request line, never from the exception's own message.
  • Clicksend::OpenTelemetry::FanOut, which sends the client's events to several instrumenters
    (for example ActiveSupport::Notifications and the OpenTelemetry instrumenter) while the
    request still runs once.
  • The instrumenter never changes a call's outcome: its own failures go to
    OpenTelemetry.handle_error, the request runs exactly once, and errors, including ambiguous
    ones, pass through unchanged.
  • Requires Ruby 3.3 or newer, clicksend 1.x (from 1.1) and opentelemetry-api 1.x. The SDK and
    exporters are the application's choice.

SHA-256 of clicksend-opentelemetry-0.1.0.gem on RubyGems: 6593452d5f0eba037fe83d97d020044a027f47cf41185bf8da251e28d8a135de

clicksend 1.0.0

Choose a tag to compare

@amitkssolanki amitkssolanki released this 05 Oct 14:56

clicksend 1.0.0 is the stable release of the 2014 clicksend gem, rewritten for ClickSend's REST v3 API and modern Ruby. It is a focused, idiomatic SMS client; it is not a replacement for ClickSend's official full-API SDK.

There are no changes to the library's behaviour or public API since 1.0.0.rc1.

gem install clicksend

Highlights

  • Revival of the 2014 gem. It replaces 0.0.3, which used ClickSend's legacy v2 API.
  • Modern Ruby and Faraday. Requires Ruby 3.3+ and Faraday 2, which is the only runtime dependency. Tested on Ruby 3.3, 3.4 and 4.0.
  • Focused SMS API:
    • client.sms.deliver and client.sms.deliver_batch
    • delivery receipts and replies
    • client.account.fetch
    • lazy pagination
    • client.request / client.paginate for any other ClickSend endpoint, through the same authentication, timeouts, retries, errors and parsing
  • Typed errors. ConfigurationError, ConnectionError/TimeoutError, APIError and its subclasses, MalformedResponseError, and MessageRejected, which is raised when ClickSend refuses a message inside an HTTP 200.
  • Retry safety. The gem never re-sends a message that may have reached ClickSend. Requests ClickSend did not process (429s, refused connections, DNS failures, connect timeouts) are retried. Read timeouts, resets, TLS errors and 5xx responses are retried only for idempotent requests.
  • Security.
    • Clients are immutable and thread-safe, and timeouts are on by default.
    • The API key is redacted everywhere.
    • client.request cannot send credentials to another host.

Breaking changes from 0.0.x

  • The Ruby namespace is now Clicksend (it was ClickSend), so the gem can be loaded alongside ClickSend's official clicksend_client 6.x.
  • The legacy v2 client API has been removed.

See MIGRATING.md.

Verification

  • Accepted send, verified live. The accepted SMS send path (POST /v3/sms/send) was verified live against ClickSend's free test number, at no charge. The response was HTTP 200 with status SUCCESS, an upper-case UUID message ID, an integer date, message_parts: 0 and message_price: "0.0000".
  • Receipts, not live-verified. Receipt retrieval and parsing are verified against ClickSend's published examples and the contract specs. A live receipt was not observed: the test number did not generate one during the two-minute observation window.

Details are in docs/clicksend-api-notes.md.

Publishing

Published to RubyGems from this tag by the Release workflow, using RubyGems Trusted Publishing (OIDC) with no stored API key.

Full changelog: CHANGELOG.md