Class: Quicopt::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/quicopt/client.rb

Overview

A connection to a Quicopt service at base_url — the public free tier (+DEFAULT_BASE_URL+) unless another URL is given.

The client holds the API key: pass a known one, or let the first keyless call mint one — which is then cached on disk and reused by later runs, so one caller keeps one key.

client = Quicopt::Client.new
result = client.solve(model)
puts result.display
client.api_key   # => the key the first call minted (and cached)

Constant Summary collapse

REQUESTS =

The HTTP methods this client issues, and the request class for each.

{
  "GET" => Net::HTTP::Get,
  "POST" => Net::HTTP::Post,
  "DELETE" => Net::HTTP::Delete
}.freeze
OCTET_STREAM =

The content type of a request body: the wire bytes are opaque binary.

"application/octet-stream"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(base_url = DEFAULT_BASE_URL, api_key = nil, timeout: 60.0, key_path: nil, cache: true) ⇒ Client

Bind a client to a service endpoint.

Parameters:

  • base_url (String) (defaults to: DEFAULT_BASE_URL)

    the service base URL; a trailing slash is stripped

  • api_key (String, nil) (defaults to: nil)

    a known API key, or nil to reuse the cached free key (minting one on the first keyless call). A key passed here is used as-is and never written to the cache, so authenticating with a key you already hold cannot clobber the free key of whoever runs the code.

  • timeout (Numeric) (defaults to: 60.0)

    the per-request socket timeout, in seconds

  • key_path (String, Pathname, nil) (defaults to: nil)

    where to cache the free key; defaults to Client.default_key_path

  • cache (Boolean) (defaults to: true)

    false keeps the key in memory only, neither reading nor writing the cache file



152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/quicopt/client.rb', line 152

def initialize(base_url = DEFAULT_BASE_URL, api_key = nil, timeout: 60.0,
               key_path: nil, cache: true)
  @base_url = base_url.to_s.sub(%r{/+\z}, "")
  @timeout = timeout
  @key_path = Pathname(key_path || Client.default_key_path).expand_path
  @cache = cache
  @explicit = !api_key.nil?
  @api_key = @explicit ? api_key : (cache ? Client.read_key(@key_path) : nil)
  # Only a key that came off disk may be discarded and re-minted on a 401 —
  # see +request_raw+, which relies on this to bound re-minting to one per run.
  @from_cache = !@api_key.nil? && !@explicit
end

Instance Attribute Details

#api_keyString?

Returns the API key in use; set on the first mint.

Returns:

  • (String, nil)

    the API key in use; set on the first mint



134
135
136
# File 'lib/quicopt/client.rb', line 134

def api_key
  @api_key
end

#base_urlString (readonly)

Returns the service base URL, without a trailing slash.

Returns:

  • (String)

    the service base URL, without a trailing slash



132
133
134
# File 'lib/quicopt/client.rb', line 132

def base_url
  @base_url
end

#key_pathPathname (readonly)

Returns where the free key is cached.

Returns:

  • (Pathname)

    where the free key is cached



138
139
140
# File 'lib/quicopt/client.rb', line 138

def key_path
  @key_path
end

#timeoutFloat

Returns the per-request socket timeout, in seconds.

Returns:

  • (Float)

    the per-request socket timeout, in seconds



136
137
138
# File 'lib/quicopt/client.rb', line 136

def timeout
  @timeout
end

Class Method Details

.decode(raw) ⇒ Hash

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Best-effort decode of a response body into a Hash.

Returns:

  • (Hash)

    the parsed JSON if it parses (or {} when empty), else the body wrapped as {"error" => text} so a non-JSON error still surfaces



306
307
308
309
310
311
312
313
314
# File 'lib/quicopt/client.rb', line 306

def self.decode(raw)
  text = raw.to_s
  return {} if text.empty?

  parsed = JSON.parse(text)
  parsed.is_a?(Hash) ? parsed : { "error" => text }
rescue JSON::ParserError
  { "error" => text }
end

.default_key_pathPathname

The default location of the cached free key: $XDG_CACHE_HOME/quicopt/free_key, falling back to ~/.cache when the variable is unset, unless KEY_PATH_ENV overrides the whole path.

Returns:

  • (Pathname)


321
322
323
324
325
326
327
328
# File 'lib/quicopt/client.rb', line 321

def self.default_key_path
  override = ENV[KEY_PATH_ENV]
  return Pathname(override) unless override.nil? || override.empty?

  root = ENV["XDG_CACHE_HOME"]
  root = File.join(Dir.home, ".cache") if root.nil? || root.empty?
  Pathname(root) + "quicopt" + "free_key"
end

.escape(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Percent-encode one query component per RFC 3986 (unreserved characters pass through; everything else is escaped byte-wise, UTF-8 included).



283
284
285
# File 'lib/quicopt/client.rb', line 283

def self.escape(value)
  value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |c| format("%%%02X", c.ord) }
end

.gzip(data) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Gzip-compress a request body.



290
291
292
293
294
295
296
297
298
299
# File 'lib/quicopt/client.rb', line 290

def self.gzip(data)
  io = StringIO.new(+"".b)
  writer = Zlib::GzipWriter.new(io)
  begin
    writer.write(data)
  ensure
    writer.close
  end
  io.string
end

.query(config) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Render a config mapping as a URL query-string suffix.

Escapes per RFC 3986, so a space becomes %20 and never +'+'+ — the form the service's query parser round-trips unambiguously, and the form the other clients send. Ruby's stock helpers (+URI.encode_www_form+, CGI.escape, URI.encode_www_form_component) all emit +'+'+ here, so none of them can be used.

Parameters:

  • config (Hash, nil)

Returns:

  • (String)

    "?k=v&…" if config is non-empty, else ""



273
274
275
276
277
# File 'lib/quicopt/client.rb', line 273

def self.query(config)
  return "" if config.nil? || config.empty?

  "?" + config.map { |k, v| "#{escape(k)}=#{escape(v)}" }.join("&")
end

.read_key(path) ⇒ String?

Read the cached key. A missing or unreadable cache is not an error — it just means this caller has no key yet and the next call mints one.

Returns:

  • (String, nil)

    the cached key, or nil if absent/unreadable/empty



334
335
336
337
338
339
# File 'lib/quicopt/client.rb', line 334

def self.read_key(path)
  key = path.read.strip
  key.empty? ? nil : key
rescue SystemCallError, IOError
  nil
end

.write_key(path, key) ⇒ Object

Persist key at path, atomically and readable only by its owner.

The key is a credential, so the file is 0600; the write goes to a temp file in the same directory and is then renamed into place, so a crash or a concurrent writer can never leave a truncated key behind for the next run to send.

Raises:

  • (SystemCallError)

    if the directory or file cannot be written (callers treat caching as best-effort and degrade to a warning)



350
351
352
353
354
355
356
357
358
# File 'lib/quicopt/client.rb', line 350

def self.write_key(path, key)
  path.dirname.mkpath
  path.dirname.chmod(0o700)
  tmp = path.dirname + ".free_key.#{Process.pid}"
  tmp.open(File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |fh| fh.write(key) }
  tmp.rename(path.to_s)
ensure
  tmp&.unlink if tmp&.exist?
end

Instance Method Details

#attempt(method, path, data = nil, config: nil, gzip: false) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Perform one HTTP request and return the raw response body.

Sets the octet-stream content type (and gzip encoding, if requested) for a body, attaches the bearer key when known, and captures a freshly minted key off the response — including from an error response, since a 502/504 on a first call may still have minted one.

Returns:

  • (String)

    the raw response body

Raises:



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/quicopt/client.rb', line 231

def attempt(method, path, data = nil, config: nil, gzip: false)
  uri = URI.parse(@base_url + path + Client.query(config))
  request_class = REQUESTS.fetch(method) { raise ArgumentError, "unsupported method #{method}" }
  req = request_class.new(uri)
  if data
    req["Content-Type"] = OCTET_STREAM
    if gzip
      data = Client.gzip(data)
      req["Content-Encoding"] = "gzip"
    end
    req.body = data
  end
  req["Authorization"] = "Bearer #{@api_key}" if @api_key

  resp = http(uri).start { |conn| conn.request(req) }
  capture_key(resp) # a 502/504 on a first call still minted a key
  raise QuicoptError.new(resp.code.to_i, Client.decode(resp.body)) unless resp.is_a?(Net::HTTPSuccess)

  resp.body.to_s
end

#request(method, path, data = nil, **options) ⇒ Hash

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Perform a request via request_raw and parse its JSON body.

Returns:

  • (Hash)

    the parsed JSON object, or {} for an empty body

Raises:



257
258
259
260
# File 'lib/quicopt/client.rb', line 257

def request(method, path, data = nil, **options)
  raw = request_raw(method, path, data, **options)
  raw.empty? ? {} : JSON.parse(raw)
end

#request_raw(method, path, data = nil, config: nil, gzip: false) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Perform one HTTP request, retrying once if a cached key is rejected.

A cache file can outlive the key it holds (the server was reset, the key was revoked, the file was copied from another machine), and a stale key 401s every call forever. So a 401 discards the cached key and retries once, keyless — which mints a fresh key and caches it.

The retry is deliberately narrow: only a key read from disk is discarded, never one minted in this process and never a caller-supplied api_key. A run can therefore mint at most one key beyond the stale one, so a server that rejected a key it just issued can never turn into a mint loop.

Returns:

  • (String)

    the raw response body

Raises:



212
213
214
215
216
217
218
219
# File 'lib/quicopt/client.rb', line 212

def request_raw(method, path, data = nil, config: nil, gzip: false)
  attempt(method, path, data, config: config, gzip: gzip)
rescue QuicoptError => e
  raise unless e.status_code == 401 && @from_cache

  discard_key
  attempt(method, path, data, config: config, gzip: gzip)
end

#solve(model, project: nil, config: nil, gzip: false) ⇒ Result

Solve model synchronously, blocking until the result returns.

Parameters:

  • model (Quicopt::Model, Quicopt::Wire::PB::Program, String)

    a model — lowered and encoded here — or a Program / pre-encoded wire bytes if you built them yourself

  • project (String, nil) (defaults to: nil)

    an optional project tag, so calls on one key can be invoiced per project. Sent as a query param, never baked into the model.

  • config (Hash, nil) (defaults to: nil)

    optional service parameters, sent as the query string

  • gzip (Boolean) (defaults to: false)

    gzip-compress the request body

Returns:

Raises:



178
179
180
181
# File 'lib/quicopt/client.rb', line 178

def solve(model, project: nil, config: nil, gzip: false)
  Result.from_json(request("POST", "/v1/solve", to_wire(model),
                           config: meta_config(model, project, config), gzip: gzip))
end

#submit(model, project: nil, config: nil, gzip: false) ⇒ Job

Submit model for asynchronous solving and return a handle to poll.

Use this for the first call against a freshly-booted server: the worker warms up before claiming, which can time out a cold synchronous call.

Returns:

  • (Job)

    a handle to the queued job; call Job#result to await it

Raises:



190
191
192
193
194
195
# File 'lib/quicopt/client.rb', line 190

def submit(model, project: nil, config: nil, gzip: false)
  body = request("POST", "/v1/jobs", to_wire(model),
                 config: meta_config(model, project, config), gzip: gzip)
  remember(body["api_key"]) # /v1/jobs echoes a minted key in the 202 body too
  Job.new(self, body["job_id"])
end