Module: Mailblastr::Client

Defined in:
lib/mailblastr/client.rb

Overview

Internal HTTP layer. Every resource funnels through Client.request; tests stub Client.deliver to avoid the network.

Constant Summary collapse

VERBS =
{
  get: Net::HTTP::Get,
  post: Net::HTTP::Post,
  patch: Net::HTTP::Patch,
  delete: Net::HTTP::Delete
}.freeze
RETRYABLE_STATUSES =

Only 429 and 503 are safe to retry automatically: the server guarantees it did NOT apply the request, so a retry cannot duplicate a non-idempotent side effect (e.g. sending an email twice). Everything else propagates.

[429, 503].freeze
MAX_BACKOFF_SECONDS =

Upper bound (seconds) on any single backoff wait.

30.0

Class Method Summary collapse

Class Method Details

.backoff_sleep(seconds) ⇒ Object

Wraps Kernel#sleep so tests can stub out the wait. Extracted so the retry loop stays deterministic under test.



72
73
74
# File 'lib/mailblastr/client.rb', line 72

def backoff_sleep(seconds)
  sleep(seconds)
end

.build_request(verb, uri, body, options, key) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/mailblastr/client.rb', line 111

def build_request(verb, uri, body, options, key)
  klass = VERBS.fetch(verb) { raise ArgumentError, "unsupported HTTP verb: #{verb.inspect}" }
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{key}"
  req["User-Agent"] = "mailblastr-ruby/#{Mailblastr::VERSION}"
  req["Accept"] = "application/json"
  idem = opt(options, :idempotency_key)
  req["Idempotency-Key"] = idem if idem
  unless body.nil?
    req["Content-Type"] = "application/json"
    req.body = JSON.generate(body)
  end
  req
end

.deliver(req, uri) ⇒ Object

The single seam that touches the network (stub me in tests). Applies the configured open/read timeout; 0 or nil means "no timeout".



128
129
130
131
132
133
134
135
136
137
138
# File 'lib/mailblastr/client.rb', line 128

def deliver(req, uri)
  opts = { use_ssl: uri.scheme == "https" }
  t = Mailblastr.timeout
  if !t.nil? && t.to_f > 0
    opts[:open_timeout] = t
    opts[:read_timeout] = t
  end
  Net::HTTP.start(uri.host, uri.port, **opts) do |http|
    http.request(req)
  end
end

.deliver_with_retries(req, uri) ⇒ Object

The single request chokepoint: both the JSON and raw/binary paths funnel through here, so both get the timeout (applied inside deliver) and the bounded 429/503 retry loop. Non-retryable responses return immediately; network/timeout errors are NOT caught here (they propagate as before).



55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/mailblastr/client.rb', line 55

def deliver_with_retries(req, uri)
  max = Mailblastr.max_retries.to_i
  attempt = 0
  loop do
    resp = deliver(req, uri)
    code = resp.code.to_i
    return resp unless RETRYABLE_STATUSES.include?(code) && attempt < max

    wait = retry_after_seconds(response_header(resp, "Retry-After"))
    wait ||= [MAX_BACKOFF_SECONDS, 0.5 * (2**attempt)].min
    backoff_sleep(wait) if wait.positive?
    attempt += 1
  end
end

.handle_response(resp, raw: false) ⇒ Object



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/mailblastr/client.rb', line 140

def handle_response(resp, raw: false)
  code = resp.code.to_i
  body = resp.body

  if code >= 200 && code < 300
    return body if raw
    return nil if body.nil? || body.empty?

    begin
      JSON.parse(body)
    rescue JSON::ParserError
      body
    end
  else
    parsed = begin
      JSON.parse(body.to_s)
    rescue JSON::ParserError, TypeError
      nil
    end
    parsed = {} unless parsed.is_a?(Hash)
    raise Mailblastr::Error.new(
      parsed["message"] || "Request failed with status #{code}",
      status_code: parsed["statusCode"] || code,
      error_name: parsed["name"] || "application_error"
    )
  end
end

.opt(params, key) ⇒ Object

Read a hash param by symbol or string key.



175
176
177
178
179
# File 'lib/mailblastr/client.rb', line 175

def opt(params, key)
  return nil unless params.is_a?(Hash)

  params.key?(key) ? params[key] : params[key.to_s]
end

.pagination(params) ⇒ Object

Extract the cursor-pagination params ({ limit, after, before }).



188
189
190
191
192
193
194
195
# File 'lib/mailblastr/client.rb', line 188

def pagination(params)
  q = {}
  %i[limit after before].each do |k|
    v = opt(params, k)
    q[k] = v unless v.nil?
  end
  q
end

.path_escape(value) ⇒ Object

Percent-encode one path segment so an id like "../api-keys" cannot traverse the URL path (spaces become %20, "/" becomes %2F).



170
171
172
# File 'lib/mailblastr/client.rb', line 170

def path_escape(value)
  CGI.escape(value.to_s).gsub("+", "%20")
end

.request(verb, path, body: nil, query: nil, options: {}, raw: false) ⇒ Object

Perform an API request and return the parsed JSON body (a Hash/Array), or the raw body String when raw: true (binary download endpoints). Raises Mailblastr::Error on any non-2xx response.



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/mailblastr/client.rb', line 33

def request(verb, path, body: nil, query: nil, options: {}, raw: false)
  key = Mailblastr.api_key
  if key.nil? || key.to_s.strip.empty?
    raise Mailblastr::Error.new(
      'Mailblastr.api_key is not set. Configure it first: Mailblastr.api_key = "mb_xxxxxxxxx"',
      error_name: "missing_api_key"
    )
  end

  uri = URI.parse("#{Mailblastr.base_url.to_s.sub(%r{/+\z}, '')}#{path}")
  if query && !query.empty?
    uri.query = [uri.query, URI.encode_www_form(query)].compact.reject(&:empty?).join("&")
  end

  req = build_request(verb, uri, body, options, key)
  handle_response(deliver_with_retries(req, uri), raw: raw)
end

.require_domain!(params, context) ⇒ Object

Domain-first guard: several resources require the sending domain.



198
199
200
201
202
203
204
205
206
# File 'lib/mailblastr/client.rb', line 198

def require_domain!(params, context)
  v = opt(params, :domain)
  if v.nil? || v.to_s.strip.empty?
    raise ArgumentError,
          "#{context} requires `domain` — the sending domain whose contact pool it targets, " \
          'e.g. { domain: "yourdomain.com", ... }'
  end
  v
end

.response_header(resp, name) ⇒ Object

Read a response header without assuming the response object shape (Net::HTTPResponse supports #[]; test doubles may not).



78
79
80
81
82
83
84
# File 'lib/mailblastr/client.rb', line 78

def response_header(resp, name)
  return nil unless resp.respond_to?(:[])

  resp[name]
rescue StandardError
  nil
end

.retry_after_seconds(value) ⇒ Object

Parse a Retry-After header into a number of seconds to wait: a numeric delta-seconds value, or an HTTP-date to wait until. Negative is treated as 0, and the wait is capped at 30 seconds. Returns nil when the header is absent or unparseable.



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/mailblastr/client.rb', line 90

def retry_after_seconds(value)
  return nil if value.nil?

  str = value.to_s.strip
  return nil if str.empty?

  seconds =
    begin
      Float(str)
    rescue ArgumentError, TypeError
      begin
        Time.httpdate(str) - Time.now
      rescue ArgumentError
        return nil
      end
    end

  seconds = 0.0 if seconds.negative?
  [seconds.to_f, MAX_BACKOFF_SECONDS].min
end

.without(params, *keys) ⇒ Object

A copy of params without the given keys (symbol or string forms).



182
183
184
185
# File 'lib/mailblastr/client.rb', line 182

def without(params, *keys)
  strs = keys.map(&:to_s)
  params.reject { |k, _| strs.include?(k.to_s) }
end