GitHub
ESC

API Client

The FIRST EPSS REST API lives at https://api.first.org/data/v1/epss. EPSS::Client is the typed wrapper.

Single CVE

client = EPSS::Client.new
score = client.score("CVE-2022-27225")

Multiple CVEs

#scores chunks the request list (default 100 per batch) so the URL stays under typical server limits:

cves = csv_file.each_line.map(&.strip).to_a
all_scores = client.scores(cves, batch_size: 100)

Threshold filtering with EPSS::Query

query = EPSS::Query.new(
  epss_gt:        0.95,
  percentile_gt:  0.99,
  order:          "!epss",  # sort by EPSS descending
)
client.each_score(query, page_size: 1000) do |score|
  publish(score)
end

each_score follows the server's offset / total cursor automatically. It uses the server-reported row count (not the flattened score count) to advance, so it stays correct even with scope=time-series.

Historical lookup

Query#date queries a specific publication day (April 14, 2021 onwards):

yesterday = Time.utc - 1.day
EPSS.scores(["CVE-2022-27225"], date: yesterday)

Retries

By default Client retries up to 3 times on 429, 500, 502, 503, and 504 responses with exponential backoff (500 ms × 2ⁿ). It also retries the underlying transport on IO::Error, Socket::Error, and OpenSSL::SSL::Error.

If the response includes a Retry-After header (in seconds or HTTP-date form), the client honors it instead — capped at 60 seconds to avoid stranding a fiber on a misbehaving server.

EPSS::Client.new(
  max_retries: 5,
  retry_backoff: 1.second,
)

Non-retryable status codes (4xx other than 429) raise EPSS::APIError immediately, with the original status and body attached:

begin
  client.fetch
rescue ex : EPSS::APIError
  puts ex.status  # => 403
  puts ex.body
end

Custom transports

For tests, audits, or offline tooling, swap in a custom EPSS::Transport:

class CacheTransport < EPSS::Transport
  def initialize(@cache : Hash(String, HTTP::Client::Response))
  end

  def get(uri : URI, headers : HTTP::Headers) : HTTP::Client::Response
    @cache[uri.to_s]? || HTTP::Client::Response.new(404)
  end
end

client = EPSS::Client.new(transport: CacheTransport.new(load_fixtures))

The specs in this repo drive every HTTP path through a StubTransport defined in spec/spec_helper.cr — no test reaches the network.

Configuring the default client

EPSS.score, EPSS.scores, and friends use a lazily-constructed default EPSS::Client. Replace it for the whole process:

EPSS.client = EPSS::Client.new(user_agent: "myapp/1.0")

EPSS.reset_client clears the cached default — useful between tests that need a fresh stub transport.