Skip to content

clicksend 1.1.0

Choose a tag to compare

@amitkssolanki amitkssolanki released this 06 Oct 11:13
· 42 commits to master since this release
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.