Class: Nfe::Http::RetryingTransport

Inherits:
Object
  • Object
show all
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

Constructor Details

#initialize(inner:, policy: RetryPolicy.default, sleep_fn: ->(seconds) { Kernel.sleep(seconds) }, logger: nil) ⇒ RetryingTransport

Returns a new instance of RetryingTransport.

Parameters:

  • inner (#call)

    the wrapped transport

  • policy (RetryPolicy) (defaults to: RetryPolicy.default)

    backoff policy

  • sleep_fn (#call) (defaults to: ->(seconds) { Kernel.sleep(seconds) })

    injectable sleep, called with seconds

  • logger (#info, #warn, #error, nil) (defaults to: nil)

    optional duck-typed logger

  • inner: (Object)
  • policy: (RetryPolicy) (defaults to: RetryPolicy.default)
  • sleep_fn: (^(Float | Integer) -> untyped) (defaults to: ->(seconds) { Kernel.sleep(seconds) })
  • logger: (Object) (defaults to: nil)


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.

Parameters:

Returns:



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.

Parameters:

Returns:

  • (Float)


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.

Parameters:



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.

Parameters:

  • request (Nfe::Http::Request)
  • attempt: (Integer)
  • delay: (Float, Integer)
  • status: (Integer, nil) (defaults to: nil)
  • error: (Object) (defaults to: nil)


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.

Parameters:



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?

Parameters:

Returns:

  • (Integer, nil)


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.

Parameters:

Returns:

  • (Boolean)


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.

Parameters:

  • status (Integer)

Returns:

  • (Boolean)


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.

Yields:

Yield Returns:

  • (Object)

Returns:

  • (Object)


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

Parameters:

  • body (Object)
  • limit (Integer) (defaults to: 256)

Returns:

  • (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