Class: KnoxCall::InterceptManifestStore

Inherits:
Object
  • Object
show all
Defined in:
lib/knoxcall/intercept_store.rb

Overview

The SDK-side copy of the intercept manifest — fetched, held, refreshed (route-aware-interception-plan.md §2.5; PARITY §21.1). One per transport / interceptor; process memory only; dropped on stop.

Ruby idiom (as Python): the manifest is refreshed LAZILY at the TTL — the first request after ttl_seconds pays one management call — rather than by a background thread. Behaviourally the same contract ("hold the manifest for ttl_seconds, then refresh"), and it works identically under Puma threads, a forked Unicorn/Sidekiq worker and a plain script, with no thread to own.

  • single-flight: concurrent refreshes share one fetch (a caller that waited on the mutex while another refreshed takes that answer);
  • stale-but-valid: a failed refresh keeps the last GOOD manifest and backs off exponentially from the second consecutive failure (cap 8×TTL);
  • a 401/403/404 from the manifest endpoint — the credential lacks routes:read, or an older server — is NOT a routing failure: the store warns once, behaves as "no manifest" (every listed host stays on the ephemeral path exactly as before this feature), and re-checks at 10×TTL;
  • out-of-cycle refreshes (a route-mode refusal, a promoted-route hint, an explicit refresh) are rate-limited so a burst costs one call.

Discovery failing open is deliberate and bounded: it can only leave a host on the path it was on before the manifest existed. The DATA-PLANE hop is where fail-closed lives (D4), and that is in the intercept pipeline.

Constant Summary collapse

DEFAULT_TTL_SECONDS =
60
MAX_BACKOFF_FACTOR =
8
PERMISSION_RECHECK_FACTOR =
10
MONOTONIC =
-> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(fetch, on_refresh: nil, on_error: nil, min_refresh_gap: 5.0, now: MONOTONIC) ⇒ InterceptManifestStore

Returns a new instance of InterceptManifestStore.

Parameters:

  • fetch (#call) —

    performs GET /v1/wrap/intercept-manifest and returns the Hash. When it accepts an if_none_match: keyword the store passes the version it holds on every poll after the first (+If-None-Match: W/""+ on the wire) and reads nil as the server's 304: keep the manifest, restart the TTL clock, fire no on_refresh (PARITY §21.1 "Conditional poll"). A zero-argument callable is accepted and simply polls unconditionally.

  • on_refresh (#call, nil) (defaults to: nil) —

    receives version:, added:, removed: after a change

  • on_error (#call, nil) (defaults to: nil) —

    receives the exception of a failed refresh

  • min_refresh_gap (Numeric) (defaults to: 5.0) —

    seconds between out-of-cycle refreshes

  • now (#call) (defaults to: MONOTONIC) —

    the clock (monotonic seconds); a test seam



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/knoxcall/intercept_store.rb', line 51

def initialize(fetch, on_refresh: nil, on_error: nil, min_refresh_gap: 5.0, now: MONOTONIC)
  @fetch = fetch
  @fetch_conditional = self.class.accepts_if_none_match?(fetch)
  @on_refresh = on_refresh
  @on_error = on_error
  @min_refresh_gap = min_refresh_gap
  @now = now
  @manifest = nil
  @version = nil
  @expires_at = 0.0 # stale until the first refresh
  @last_refresh_at = -1e9
  @failures = 0
  @permission_denied = false
  @last_error = nil
  @stopped = false
  @seq = 0
  @mutex = Mutex.new
end

Instance Attribute Details

#last_error ⇒ Object (readonly)

Returns the value of attribute last_error.



36
37
38
# File 'lib/knoxcall/intercept_store.rb', line 36

def last_error
  @last_error
end

#manifest ⇒ Object (readonly)

Returns the value of attribute manifest.



36
37
38
# File 'lib/knoxcall/intercept_store.rb', line 36

def manifest
  @manifest
end

#min_refresh_gap ⇒ Object

Seconds between out-of-cycle refreshes (a refusal, a hint, an explicit refresh); a burst inside the gap costs one call.



39
40
41
# File 'lib/knoxcall/intercept_store.rb', line 39

def min_refresh_gap
  @min_refresh_gap
end

#version ⇒ Object (readonly)

Returns the value of attribute version.



36
37
38
# File 'lib/knoxcall/intercept_store.rb', line 36

def version
  @version
end

Class Method Details

.accepts_if_none_match?(fetch) ⇒ Boolean

Whether fetch can take if_none_match: (a keyword, or **rest). Decided once at construction so a zero-argument test/user seam keeps working.

Returns:

  • (Boolean)


75
76
77
78
79
80
81
# File 'lib/knoxcall/intercept_store.rb', line 75

def self.accepts_if_none_match?(fetch)
  return false unless fetch.respond_to?(:parameters)

  fetch.parameters.any? do |kind, name|
    (%i[key keyreq].include?(kind) && name == :if_none_match) || kind == :keyrest
  end
end

Instance Method Details

#ensure ⇒ Object

Refresh if stale (first load, TTL expiry, a hint), then return the manifest.



103
104
105
106
# File 'lib/knoxcall/intercept_store.rb', line 103

def ensure
  refresh("ttl", force: true) if stale?
  @manifest
end

#hint ⇒ Object

A promoted-route hint arrived: make the NEXT request refresh (rate-limited).



89
90
91
# File 'lib/knoxcall/intercept_store.rb', line 89

def hint
  @mutex.synchronize { @expires_at = @now.call if @now.call - @last_refresh_at >= @min_refresh_gap }
end

#permission_denied? ⇒ Boolean

Returns:

  • (Boolean)


70
# File 'lib/knoxcall/intercept_store.rb', line 70

def permission_denied? = @permission_denied

#refresh(reason, force: false) ⇒ Object

Refresh now. Single-flight; rate-limited unless force. Returns the manifest the store holds afterwards (nil after a permission refusal). Never raises for a fetch failure — the store has already applied stale-keep / backoff; read last_error.



112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/knoxcall/intercept_store.rb', line 112

def refresh(reason, force: false)
  return nil if @stopped

  seq_before = @seq # bumped when an attempt COMPLETES
  @mutex.synchronize do
    return nil if @stopped
    # An attempt completed while we waited on the mutex: take its answer
    # (single-flight — the refresh we queued behind is the one we wanted).
    return @manifest if @seq != seq_before
    return @manifest if !force && @now.call - @last_refresh_at < @min_refresh_gap

    do_refresh(reason)
  end
end

#stale? ⇒ Boolean

Whether the next request should refresh before deciding.

Returns:

  • (Boolean)


84
85
86
# File 'lib/knoxcall/intercept_store.rb', line 84

def stale?
  !@stopped && @now.call >= @expires_at
end

#stop ⇒ Object

Drop the manifest and refuse further refreshes.



94
95
96
97
98
99
100
# File 'lib/knoxcall/intercept_store.rb', line 94

def stop
  @mutex.synchronize do
    @stopped = true
    @manifest = nil
    @version = nil
  end
end

#stopped? ⇒ Boolean

Returns:

  • (Boolean)


71
# File 'lib/knoxcall/intercept_store.rb', line 71

def stopped? = @stopped