Client
EPSS::Client is the typed wrapper around
https://api.first.org/data/v1/epss. HTTP is fully delegated to an
injectable EPSS::Transport, so tests can swap in a stub without
touching the network.
Constructor
EPSS::Client.new(
base_uri: URI = DEFAULT_BASE_URI,
user_agent: String = "epss.cr/0.1.0 (+https://github.com/hahwul/epss.cr)",
max_retries: Int32 = 3,
retry_backoff: Time::Span = 500.milliseconds,
transport: EPSS::Transport = EPSS::HTTPTransport.new,
)
| Argument | Default | Purpose |
|---|---|---|
base_uri |
FIRST endpoint | Override for proxies / mirrors |
user_agent |
epss.cr/<VERSION> |
Sent on every request |
max_retries |
3 |
0 disables retries entirely |
retry_backoff |
500ms |
Exponential base; doubled each attempt |
transport |
HTTPTransport |
Swap for tests or caching layers |
Instance methods
#fetch(query : Query = Query.new) : EPSS::Response
Issue one request. Does not iterate pages — see #each_score.
#score(cve : String, *, date : Time? = nil) : EPSS::Score?
Convenience for the single-CVE case. Returns nil when FIRST has no
published score.
#scores(cves : Enumerable(String), *, date : Time? = nil, batch_size : Int32 = 100) : Array(Score)
Batch lookup across many CVEs. The list is chunked into requests of
batch_size CVEs each.
#time_series(cve : String) : Array(Score)
Fetch the 30-day history for one CVE. Returns the flattened daily series sorted oldest-first.
#each_score(query, *, page_size : Int32 = 1000, & : Score ->) : Nil
Iterate every score matching query, fetching subsequent pages
transparently. The loop advances by the server-reported row_count
on EPSS::Response, so it remains correct when scope=time-series
expands each row.
#all_scores(query, *, page_size : Int32 = 1000) : Array(Score)
Convenience around #each_score that materializes every page into a
single Array. Be aware that an unfiltered query can be hundreds of
thousands of rows.
#build_uri(query : Query) : URI
Compose the absolute URI for a query. Useful for cache keys and logging.
Retries
Status codes 429, 500, 502, 503, 504 are retried up to
max_retries times. If the response includes a Retry-After header
(seconds or HTTP-date form), the client honors it — capped at 60 seconds
to prevent unbounded waits.
The transport rescue covers IO::Error (which includes
IO::TimeoutError), Socket::Error, and OpenSSL::SSL::Error. When
retries are exhausted on a transport failure, the original exception is
chained through EPSS::APIError#cause.
Non-retryable status codes raise EPSS::APIError immediately with the
status and body attached.
Transport seam
abstract class EPSS::Transport
abstract def get(uri : URI, headers : HTTP::Headers) : HTTP::Client::Response
end
The default HTTPTransport opens a fresh HTTP::Client per request:
EPSS::HTTPTransport.new(
connect_timeout: 10.seconds,
read_timeout: 30.seconds,
)
For tests, see the StubTransport pattern in
spec/spec_helper.cr.
Default client
EPSS.client # lazily-constructed default
EPSS.client = ... # override
EPSS.reset_client # clear the cache
Construction is mutex-protected so two fibers racing the first call
won't build two Clients.