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- IDEMPOTENCY_KEY_MAX_LENGTH =
Idempotency-Keyis stored in a VARCHAR(255) column, so the API accepts 1-255 characters measured after it trims the value — 255, not 256 — and answers anything else with 400 invalid_idempotency_key. Only POST /emails and POST /emails/batch read the header; every other endpoint ignores it, so a retry there creates a second resource.Exposed for discoverability only: the SDK sends the key as given and lets the server be the authority.
255
Class Method Summary collapse
-
.backoff_sleep(seconds) ⇒ Object
Wraps Kernel#sleep so tests can stub out the wait.
- .build_request(verb, uri, body, options, key) ⇒ Object
-
.deliver(req, uri) ⇒ Object
The single seam that touches the network (stub me in tests).
-
.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.
-
.filters(params, *keys) ⇒ Object
Copy the given keys out of
paramsinto a query hash, skipping the ones the caller left out. - .handle_response(resp, raw: false) ⇒ Object
-
.idempotency_key(value) ⇒ Object
Normalize an
idempotency_keyoption into the header value. -
.opt(params, key) ⇒ Object
Read a hash param by symbol or string key.
-
.pagination(params) ⇒ Object
Extract the cursor-pagination params ({ limit, after, before }).
-
.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).
-
.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). -
.require_domain!(params, context) ⇒ Object
Domain-first guard: several resources require the sending domain.
-
.response_header(resp, name) ⇒ Object
Read a response header without assuming the response object shape (Net::HTTPResponse supports #[]; test doubles may not).
-
.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.
-
.without(params, *keys) ⇒ Object
A copy of
paramswithout the given keys (symbol or string forms).
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.
82 83 84 |
# File 'lib/mailblastr/client.rb', line 82 def backoff_sleep(seconds) sleep(seconds) end |
.build_request(verb, uri, body, options, key) ⇒ Object
121 122 123 124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/mailblastr/client.rb', line 121 def build_request(verb, uri, body, , 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 = idempotency_key(opt(, :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".
138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/mailblastr/client.rb', line 138 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).
65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
# File 'lib/mailblastr/client.rb', line 65 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 |
.filters(params, *keys) ⇒ Object
Copy the given keys out of params into a query hash, skipping the
ones the caller left out. Used to expose an endpoint's server-side
filters without forwarding unrelated params.
228 229 230 231 232 233 |
# File 'lib/mailblastr/client.rb', line 228 def filters(params, *keys) keys.each_with_object({}) do |k, q| v = opt(params, k) q[k] = v unless v.nil? end end |
.handle_response(resp, raw: false) ⇒ Object
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/mailblastr/client.rb', line 150 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) # The whole body rides along: plan/quota errors add `limit`, reputation # gates add `reputation`, and a partial batch failure adds # `sent`/`sent_count` (see Mailblastr::Error). raise Mailblastr::Error.new( parsed["message"] || "Request failed with status #{code}", status_code: parsed["statusCode"] || code, error_name: parsed["name"] || "application_error", body: parsed ) end end |
.idempotency_key(value) ⇒ Object
Normalize an idempotency_key option into the header value. nil/absent or
an empty string means "no header"; anything else is sent VERBATIM.
The 1-255 bound (IDEMPOTENCY_KEY_MAX_LENGTH) is the server's to enforce — it trims the value and answers an out-of-range key with 400 invalid_idempotency_key. Checking here would only risk drifting from the server, and would disagree with the other MailBlastr SDKs.
195 196 197 198 199 200 |
# File 'lib/mailblastr/client.rb', line 195 def idempotency_key(value) return nil if value.nil? key = value.to_s key.empty? ? nil : key end |
.opt(params, key) ⇒ Object
Read a hash param by symbol or string key.
203 204 205 206 207 |
# File 'lib/mailblastr/client.rb', line 203 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 }).
216 217 218 219 220 221 222 223 |
# File 'lib/mailblastr/client.rb', line 216 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).
184 185 186 |
# File 'lib/mailblastr/client.rb', line 184 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.
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/mailblastr/client.rb', line 43 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, , 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.
236 237 238 239 240 241 242 243 244 |
# File 'lib/mailblastr/client.rb', line 236 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).
88 89 90 91 92 93 94 |
# File 'lib/mailblastr/client.rb', line 88 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.
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/mailblastr/client.rb', line 100 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).
210 211 212 213 |
# File 'lib/mailblastr/client.rb', line 210 def without(params, *keys) strs = keys.map(&:to_s) params.reject { |k, _| strs.include?(k.to_s) } end |