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
-
.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.
- .handle_response(resp, raw: false) ⇒ Object
-
.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.
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, , 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(, :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, , 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 |