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: an encoded model is 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



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

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_key ⇒ String?

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

Returns:

  • (String, nil) —

    the API key in use; set on the first mint



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

def api_key
  @api_key
end

#base_url ⇒ String (readonly)

Returns the service base URL, without a trailing slash.

Returns:

  • (String) —

    the service base URL, without a trailing slash



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

def base_url
  @base_url
end

#key_path ⇒ Pathname (readonly)

Returns where the free key is cached.

Returns:

  • (Pathname) —

    where the free key is cached



197
198
199
# File 'lib/quicopt/client.rb', line 197

def key_path
  @key_path
end

#timeout ⇒ Float

Returns the per-request socket timeout, in seconds.

Returns:

  • (Float) —

    the per-request socket timeout, in seconds



195
196
197
# File 'lib/quicopt/client.rb', line 195

def timeout
  @timeout
end

Class Method Details

.check_time_limit(time_limit) ⇒ Float?

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.

Check a time limit before it is sent.

Parameters:

  • time_limit (Numeric, nil) —

    seconds, or nil to leave the choice to the service

Returns:

  • (Float, nil)

Raises:

  • (TypeError) —

    if it is not a number

  • (ArgumentError) —

    if it is not positive and finite



286
287
288
289
290
291
292
293
294
295
296
# File 'lib/quicopt/client.rb', line 286

def self.check_time_limit(time_limit)
  return nil if time_limit.nil?
  raise TypeError, "time_limit is a number of seconds, got #{time_limit.inspect}" unless time_limit.is_a?(Numeric)

  seconds = time_limit.to_f
  unless seconds.finite? && seconds.positive?
    raise ArgumentError, "time_limit is a positive, finite number of seconds, got #{time_limit}"
  end

  seconds
end

.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



409
410
411
412
413
414
415
416
417
# File 'lib/quicopt/client.rb', line 409

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_path ⇒ Pathname

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)


424
425
426
427
428
429
430
431
# File 'lib/quicopt/client.rb', line 424

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).



386
387
388
# File 'lib/quicopt/client.rb', line 386

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.



393
394
395
396
397
398
399
400
401
402
# File 'lib/quicopt/client.rb', line 393

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 ""



376
377
378
379
380
# File 'lib/quicopt/client.rb', line 376

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



437
438
439
440
441
442
# File 'lib/quicopt/client.rb', line 437

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)



453
454
455
456
457
458
459
460
461
# File 'lib/quicopt/client.rb', line 453

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, timeout: nil) ⇒ 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. timeout replaces the client's socket timeout for this one request.

Returns:

  • (String) —

    the raw response body

Raises:



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
# File 'lib/quicopt/client.rb', line 333

def attempt(method, path, data = nil, config: nil, gzip: false, timeout: nil)
  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, timeout || @timeout).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:



359
360
361
362
# File 'lib/quicopt/client.rb', line 359

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, timeout: nil) ⇒ 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:



313
314
315
316
317
318
319
320
# File 'lib/quicopt/client.rb', line 313

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

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

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

Solve model synchronously, blocking until the result returns.

Examples:

Give a search half a minute

result = client.solve(m, time_limit: 30)
result.solver_data["time_budget_s"]   # => 30.0

Parameters:

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

    a model — encoded here — or a Program / already-encoded 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.

  • time_limit (Numeric, nil) (defaults to: nil) —

    how many seconds the solve may run, in place of the time the service would choose. A solver that proves its answer sooner stops sooner; a search, which cannot prove it is done, may stop sooner too, once it stops finding better answers. The service grants at most its maximum (120 s on the free tier), and a warning says so when it granted less. The client waits for the answer at least this long plus a minute.

  • 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:

  • (QuicoptError) —

    on a non-2xx response

  • (TypeError) —

    if time_limit is not a number

  • (ArgumentError) —

    if time_limit is not positive and finite



250
251
252
253
254
255
256
# File 'lib/quicopt/client.rb', line 250

def solve(model, project: nil, time_limit: nil, config: nil, gzip: false)
  time_limit = Client.check_time_limit(time_limit)
  wait = time_limit.nil? ? @timeout : [@timeout, time_limit + WAIT_MARGIN].max
  Result.from_json(request("POST", "/v1/solve", to_wire(model),
                           config: meta_config(model, project, time_limit, config),
                           gzip: gzip, timeout: wait)).warn_if_lowered
end

#submit(model, project: nil, time_limit: 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.

time_limit works as for solve; Job#result then polls at least that long plus a minute by default.

Returns:

  • (Job) —

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

Raises:

  • (QuicoptError) —

    on a non-2xx response

  • (TypeError) —

    if time_limit is not a number

  • (ArgumentError) —

    if time_limit is not positive and finite



270
271
272
273
274
275
276
# File 'lib/quicopt/client.rb', line 270

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