Class: Nfe::Http::RetryingTransport
- Inherits:
-
Object
- Object
- Nfe::Http::RetryingTransport
- Defined in:
- lib/nfe/http/retrying_transport.rb,
sig/nfe/http/retrying_transport.rbs
Overview
Transport decorator that retries transient failures on idempotent
requests. Wraps any inner transport (any object responding to
call(request)) and applies a RetryPolicy.
Retries happen on HTTP 429, HTTP 500-599, and network errors
(ApiConnectionError, including TimeoutError) -- but ONLY
when Nfe::Http::Request#idempotent? is true. A POST without an idempotency_key
is never retried, so an invoice is never re-issued automatically.
An integer Retry-After header (seconds) overrides the computed backoff,
clamped to the policy's max_delay.
When a duck-typed logger (responding to +info+/+warn+/+error+) is
supplied, the decorator logs request start (+info+), retries (+warn+), and
final failures (+error+). Sensitive headers are redacted via
Nfe::Http::Redactor.headers and request/response BODIES are never logged. Every log
call is wrapped in rescue StandardError so logging can never break the
request.
Instance Method Summary collapse
-
#call(request) ⇒ Nfe::Http::Response
Execute
requestthrough the inner transport, retrying transient failures on idempotent requests. -
#delay_for_response(attempt, response) ⇒ Float
Honor an integer
Retry-After(seconds), clamped tomax_delay; otherwise fall back to the policy backoff. -
#initialize(inner:, policy: RetryPolicy.default, sleep_fn: ->(seconds) { Kernel.sleep(seconds) }, logger: nil) ⇒ RetryingTransport
constructor
A new instance of RetryingTransport.
- #log_final_error(request, response: nil, error: nil) ⇒ void
- #log_retry(request, attempt:, delay:, status: nil, error: nil) ⇒ void
- #log_start(request) ⇒ void
- #parse_retry_after(response) ⇒ Integer?
-
#retry_again?(attempt, request) ⇒ Boolean
An
attempt(1-based retry index) is permitted when it does not exceed the configured maximum and the request is idempotent. -
#retryable_status?(status) ⇒ Boolean
True for statuses that warrant a retry: 429 and 5xx.
-
#safe_log { ... } ⇒ Object
Logging must never break the request.
- #truncate(body, limit = 256) ⇒ String
Constructor Details
#initialize(inner:, policy: RetryPolicy.default, sleep_fn: ->(seconds) { Kernel.sleep(seconds) }, logger: nil) ⇒ RetryingTransport
Returns a new instance of RetryingTransport.
28 29 30 31 32 33 |
# File 'lib/nfe/http/retrying_transport.rb', line 28 def initialize(inner:, policy: RetryPolicy.default, sleep_fn: ->(seconds) { Kernel.sleep(seconds) }, logger: nil) @inner = inner @policy = policy @sleep_fn = sleep_fn @logger = logger end |
Instance Method Details
#call(request) ⇒ Nfe::Http::Response
Execute request through the inner transport, retrying transient
failures on idempotent requests. Returns an Nfe::Http::Response; on a
non-retryable network failure (or after exhausting retries) re-raises
the last ApiConnectionError.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/nfe/http/retrying_transport.rb', line 39 def call(request) log_start(request) attempt = 0 loop do begin response = @inner.call(request) rescue Nfe::ApiConnectionError => e attempt += 1 unless retry_again?(attempt, request) log_final_error(request, error: e) raise end delay = @policy.delay_for(attempt) log_retry(request, attempt: attempt, delay: delay, error: e) @sleep_fn.call(delay) next end unless retryable_status?(response.status) && retry_again?(attempt + 1, request) log_final_error(request, response: response) unless response.success? return response end attempt += 1 delay = delay_for_response(attempt, response) log_retry(request, attempt: attempt, delay: delay, status: response.status) @sleep_fn.call(delay) end end |
#delay_for_response(attempt, response) ⇒ Float
Honor an integer Retry-After (seconds), clamped to max_delay;
otherwise fall back to the policy backoff.
85 86 87 88 89 90 |
# File 'lib/nfe/http/retrying_transport.rb', line 85 def delay_for_response(attempt, response) retry_after = parse_retry_after(response) return [retry_after.to_f, @policy.max_delay].min if retry_after @policy.delay_for(attempt) end |
#log_final_error(request, response: nil, error: nil) ⇒ void
This method returns an undefined value.
119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/nfe/http/retrying_transport.rb', line 119 def log_final_error(request, response: nil, error: nil) return unless @logger detail = if error "error=#{error.class}" elsif response "status=#{response.status} body=#{truncate(response.body)}" else "unknown failure" end safe_log do @logger.error("nfe-io request failed: #{request.method.to_s.upcase} #{request.url} #{detail}") end end |
#log_retry(request, attempt:, delay:, status: nil, error: nil) ⇒ void
This method returns an undefined value.
109 110 111 112 113 114 115 116 117 |
# File 'lib/nfe/http/retrying_transport.rb', line 109 def log_retry(request, attempt:, delay:, status: nil, error: nil) return unless @logger reason = error ? "error=#{error.class}" : "status=#{status}" safe_log do @logger.warn("nfe-io retry ##{attempt} in #{delay}s: " \ "#{request.method.to_s.upcase} #{request.url} #{reason}") end end |
#log_start(request) ⇒ void
This method returns an undefined value.
100 101 102 103 104 105 106 107 |
# File 'lib/nfe/http/retrying_transport.rb', line 100 def log_start(request) return unless @logger safe_log do @logger.info("nfe-io request: #{request.method.to_s.upcase} #{request.url} " \ "headers=#{Redactor.headers(request.headers).inspect}") end end |
#parse_retry_after(response) ⇒ Integer?
92 93 94 95 96 97 98 |
# File 'lib/nfe/http/retrying_transport.rb', line 92 def parse_retry_after(response) raw = response.header("retry-after") return nil if raw.nil? return nil unless raw.to_s.match?(/\A\d+\z/) raw.to_i end |
#retry_again?(attempt, request) ⇒ Boolean
An attempt (1-based retry index) is permitted when it does not exceed
the configured maximum and the request is idempotent.
79 80 81 |
# File 'lib/nfe/http/retrying_transport.rb', line 79 def retry_again?(attempt, request) attempt <= @policy.max_retries && request.idempotent? end |
#retryable_status?(status) ⇒ Boolean
True for statuses that warrant a retry: 429 and 5xx.
71 72 73 |
# File 'lib/nfe/http/retrying_transport.rb', line 71 def retryable_status?(status) status == 429 || (500..599).cover?(status) end |
#safe_log { ... } ⇒ Object
Logging must never break the request.
142 143 144 145 146 |
# File 'lib/nfe/http/retrying_transport.rb', line 142 def safe_log yield rescue StandardError nil end |
#truncate(body, limit = 256) ⇒ String
134 135 136 137 138 139 |
# File 'lib/nfe/http/retrying_transport.rb', line 134 def truncate(body, limit = 256) return "" if body.nil? string = body.to_s string.bytesize > limit ? "#{string.byteslice(0, limit)}...(truncated)" : string end |