Class: Quicopt::Client
- Inherits:
-
Object
- Object
- Quicopt::Client
- 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
-
#api_key ⇒ String?
The API key in use; set on the first mint.
-
#base_url ⇒ String
readonly
The service base URL, without a trailing slash.
-
#key_path ⇒ Pathname
readonly
Where the free key is cached.
-
#timeout ⇒ Float
The per-request socket timeout, in seconds.
Class Method Summary collapse
-
.check_time_limit(time_limit) ⇒ Float?
private
Check a time limit before it is sent.
-
.decode(raw) ⇒ Hash
private
Best-effort decode of a response body into a Hash.
-
.default_key_path ⇒ Pathname
The default location of the cached free key:
$XDG_CACHE_HOME/quicopt/free_key, falling back to~/.cachewhen the variable is unset, unlessKEY_PATH_ENVoverrides the whole path. -
.escape(value) ⇒ Object
private
Percent-encode one query component per RFC 3986 (unreserved characters pass through; everything else is escaped byte-wise, UTF-8 included).
-
.gzip(data) ⇒ Object
private
Gzip-compress a request body.
-
.query(config) ⇒ String
private
Render a config mapping as a URL query-string suffix.
-
.read_key(path) ⇒ String?
Read the cached key.
-
.write_key(path, key) ⇒ Object
Persist
keyatpath, atomically and readable only by its owner.
Instance Method Summary collapse
-
#attempt(method, path, data = nil, config: nil, gzip: false, timeout: nil) ⇒ String
private
Perform one HTTP request and return the raw response body.
-
#initialize(base_url = DEFAULT_BASE_URL, api_key = nil, timeout: 60.0, key_path: nil, cache: true) ⇒ Client
constructor
Bind a client to a service endpoint.
-
#request(method, path, data = nil, **options) ⇒ Hash
private
Perform a request via
request_rawand parse its JSON body. -
#request_raw(method, path, data = nil, config: nil, gzip: false, timeout: nil) ⇒ String
private
Perform one HTTP request, retrying once if a cached key is rejected.
-
#solve(model, project: nil, time_limit: nil, config: nil, gzip: false) ⇒ Result
Solve
modelsynchronously, blocking until the result returns. -
#submit(model, project: nil, time_limit: nil, config: nil, gzip: false) ⇒ Job
Submit
modelfor asynchronous solving and return a handle to poll.
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.
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). @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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
359 360 361 362 |
# File 'lib/quicopt/client.rb', line 359 def request(method, path, data = nil, **) raw = request_raw(method, path, data, **) 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.
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.
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: (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.
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: (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 |