Class: Ksef::HTTP::Retry

Inherits:
Faraday::Middleware
  • Object
show all
Defined in:
lib/ksef/http/retry.rb

Overview

Re-issues a failed request when RetryPolicy says it is safe to.

Until 2026-08-23 this did not exist: RetryPolicy was constructed, validated, documented as a constructor option and never consulted, so Retry-After was honoured nowhere and retry: was silently inert. The policy object was right all along; nothing called it.

What may be retried, and why so little

Every decision is delegated to RetryPolicy#retryable?, which refuses anything but GET and HEAD (DESIGN.md §6.7). That rule is a business one, not a technical one: a duplicate invoice in KSeF is a real tax problem, so a POST whose response never arrived must surface to the caller, who alone knows whether re-sending is safe. The per-invoice duplicate status 440 exists precisely because this happens, and it is not this middleware's job to make it happen more often.

There is exactly one documented exception anywhere in the gem, and it is not here: Crypto::PublicKeys#with_key_rotation's opt-in 21470 remediation, which a caller invokes deliberately (docs/REFERENCE.md §10.2).

Placement in the stack is load-bearing

Registered outside ErrorHandler, so it catches the typed exceptions the handler raises rather than inspecting raw statuses — one place decides what a status means. Registered inside request :json, so a retry re-sends the already-encoded body instead of encoding it twice.

The body is restored before each attempt because an adapter may consume it: without that, attempt two sends an empty body and the retry silently changes the request.

Constant Summary collapse

RETRYABLE =

The four classes ErrorHandler raises that can be worth another attempt: a rate limit, a server fault, and the two transport failures. Everything else — 400, 401, 403, 410 — is a definite answer that retrying cannot improve.

[
  Ksef::RateLimitedError,
  Ksef::ServerError,
  Ksef::TimeoutError,
  Ksef::ConnectionError
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(app, policy:, sleeper: nil, logger: nil) ⇒ Retry

Returns a new instance of Retry.

Parameters:

  • policy (Ksef::RetryPolicy) —

    anything answering #retryable? and #interval_for

  • sleeper (#call) (defaults to: nil) —

    injected for tests; receives seconds

  • logger (#info, nil) (defaults to: nil) —

    a retry is worth a line, since it hides latency



50
51
52
53
54
55
# File 'lib/ksef/http/retry.rb', line 50

def initialize(app, policy:, sleeper: nil, logger: nil)
  super(app)
  @policy = policy
  @sleeper = sleeper || ->(seconds) { sleep(seconds) }
  @logger = logger
end

Instance Method Details

#call(env) ⇒ Object



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/ksef/http/retry.rb', line 57

def call(env)
  body = env.body
  attempt = 1

  begin
    env.body = body
    @app.call(env)
  rescue *RETRYABLE => e
    raise unless retry?(env, e, attempt)

    wait = @policy.interval_for(attempt: attempt, retry_after: retry_after(e))
    announce(env, e, attempt, wait)
    @sleeper.call(wait)
    attempt += 1
    retry
  end
end