ADR-006: Lazy discovery — no HTTP in constructors
Status: Accepted
Context
SMART clients need the authorization server’s endpoints (authorization_endpoint, token_endpoint) to build authorization URLs and request tokens. These are obtained by fetching /.well-known/smart-configuration. There are two approaches:
Option A — eager discovery: fetch metadata in Smart#initialize.
def initialize(config, client_type: :public)
# ...
@server_metadata = fetch_metadata # HTTP call here
end
Option B — lazy discovery: defer the fetch until a method actually needs an endpoint.
def server_metadata
@server_metadata ||= fetch_metadata # HTTP call deferred
end
def authorization_endpoint
@authorization_endpoint ||= server_metadata.authorization_endpoint
end
Eager discovery has a significant problem: it makes Safire::Client.new a network operation. Construction can fail with a network error, configuration validation occurs after a potentially slow HTTP round-trip, and there is no way to instantiate a client to inspect its configuration without triggering discovery. It also makes testing harder — every Client.new call requires a stub.
A second concern is client_type= mutation. After discovery, a caller may want to switch client type based on what the server supports:
client = Safire::Client.new(config)
metadata = client.server_metadata
client.client_type = :confidential_symmetric if metadata.supports_symmetric_auth?
With eager discovery, changing client_type must not trigger re-discovery — the metadata is already fetched. This means decoupling the discovery result from construction is necessary regardless.
Decision
Discovery is lazy and memoised at the protocol instance level. Both Protocols::Smart and Protocols::Udap follow this pattern.
SMART memoises a single metadata object at the instance level:
def server_metadata
return @server_metadata if @server_metadata
response = @http_client.get(well_known_endpoint)
@server_metadata = SmartMetadata.new(parse_discovery_body(response.body))
end
authorization_endpoint and token_endpoint are also lazy — they fall back to server_metadata only when not manually configured in ClientConfig, avoiding a discovery call for clients with pre-known endpoints.
Safire::Client memoises the protocol client itself (@protocol_client ||= ...), so changing client_type= reuses the existing Protocols::Smart instance — and thus its already-fetched @server_metadata — rather than constructing a new one. This is the mechanism that prevents double-discovery on client_type= changes.
UDAP memoises a Hash keyed by community URI string or :default, plus the caller’s trust policy. The same server can host multiple communities at separate ?community=<uri> scopes, and the same community can be evaluated under different trust anchors, CRLs, or revocation checkers. Cache hits revalidate the cached signed_metadata before reuse; if validation fails, the cached entry is discarded and discovery is fetched again.
def server_metadata(community: nil, trusted_anchors: [], crls: [], revocation_checker: nil, verify_chain: true)
community = normalize_community(community)
trust_policy = {
trusted_anchors:,
crls:,
revocation_checker:,
verify_chain:
}
cache_key = build_cache_key(community, trusted_anchors, crls, revocation_checker, verify_chain)
cached_entry = @metadata_cache[cache_key]
return cached_entry.fetch(:metadata) if cached_entry && cached_entry_valid?(cached_entry, trust_policy)
@metadata_cache.delete(cache_key)
entry = fetch_metadata(
community:,
trust_policy:
)
@metadata_cache[cache_key] = entry
entry.fetch(:metadata)
end
def fetch_metadata(community:, trust_policy:)
endpoint = well_known_endpoint(community:)
response = @http_client.get(endpoint)
check_204!(response, endpoint:, community:)
raw = parse_discovery_body(response.body, endpoint)
signed_claims = validate_signed_metadata!(
raw,
endpoint:,
community:,
trust_policy:
)
{
metadata: UdapMetadata.new(raw.merge(signed_claims)),
raw:
}
end
server_metadata(community:, trusted_anchors:, crls:, revocation_checker:, verify_chain:) uses UDAP-specific parameters. Calling any of these on a SMART client raises ArgumentError from Ruby’s own keyword argument checking — this is intentional and correct, since community scoping and UDAP certificate trust policy are UDAP concepts.
A 204 response means the server has no UDAP workflows for that community. Protocols::Udap raises DiscoveryError before the body is parsed, with a descriptive message that identifies the community when one was requested.
Consequences
Benefits:
Safire::Client.newis instantaneous — no network calls, no stubs required at construction time- Configuration errors are raised before any HTTP call
- Callers control when discovery happens — supports application-level caching patterns (see Advanced Examples)
client_type=mutation preserves cached SMART metadata — no re-discovery- UDAP community-and-trust-policy cache allows a single client instance to serve multiple communities without serving stale signed metadata
Trade-offs:
- Discovery errors surface at first use (e.g.
authorization_url), not at construction — callers must handleErrors::DiscoveryErrorin their flow logic rather than at thenewcall site - In-process metadata caching is per-instance only — across requests in a web app, callers must implement application-level caching (e.g.
Rails.cache) to avoid repeated discovery HTTP calls