Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions lib/octaspace/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,14 @@ def shutdown
# Transport diagnostics (pool stats when in keep_alive mode)
# @return [Hash]
def transport_stats
if @transport.respond_to?(:transport_stats)
@transport.transport_stats
elsif @transport.respond_to?(:pool_stats)
{mode: :persistent, pools: @transport.pool_stats}
elsif @config.urls.size > 1
{mode: :standard, rotator: @transport.instance_variable_get(:@rotator)&.stats}
else
{mode: :standard, url: @config.urls.first}
end
@transport.respond_to?(:transport_stats) ? @transport.transport_stats : {mode: :custom}
end

# Rate limit state (RateLimit-* headers) of the most recent response that
# carried them, or nil before the first such response.
# @return [OctaSpace::RateLimit, nil]
def rate_limit
@transport.respond_to?(:rate_limit) ? @transport.rate_limit : nil
end

private
Expand Down
71 changes: 62 additions & 9 deletions lib/octaspace/configuration.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# frozen_string_literal: true

module OctaSpace
# Test stand of the next-generation OctaSpace API. It is the default base URL
# until the production host of the new API is announced: the production hosts
# (+api.octa.computer+, +api.octa.space+) still serve the legacy API.
BASE_URL_DEV = "https://api-dev.octa.space"

# Default API base URL
DEFAULT_BASE_URL = BASE_URL_DEV

# Prefix of the account API keys issued in Cube (Profile → Credentials)
API_KEY_PREFIX = "octa_ak_"

# Global and per-client configuration for the OctaSpace SDK
#
# @example Global configuration (Rails initializer)
Expand All @@ -13,15 +24,22 @@ module OctaSpace
class Configuration
# --- Authentication ---

# @return [String, nil] API key for Authorization header
attr_accessor :api_key
# Account API key (+octa_ak_…+), sent as +Authorization: Bearer <key>+.
# Optional: public endpoints (network, GPU statistics, machine listings)
# work without it. A pasted value that already starts with "Bearer " is
# accepted.
# @return [String, nil]
attr_reader :api_key

# --- Connection ---

# @return [String] Base URL for the API (single endpoint)
attr_accessor :base_url

# @return [Array<String>, nil] Multiple API endpoints — enables URL rotation/failover
# Ordered list of API endpoints. GET requests fail over to the next URL
# on network errors; requests that change state are sent to one URL only.
# Takes precedence over {#base_url}.
# @return [Array<String>, nil]
attr_accessor :base_urls

# @return [Integer] Seconds to wait for connection to open
Expand Down Expand Up @@ -49,6 +67,9 @@ class Configuration
attr_accessor :idle_timeout

# --- Retry ---
# Only GET requests and the idempotent session stop are retried, on network
# errors and on 429/502/503/504. Starting or restarting a service is never
# retried: it costs money and is rate limited.

# @return [Integer] Maximum number of retries on transient failures
attr_accessor :max_retries
Expand All @@ -59,17 +80,30 @@ class Configuration
# @return [Float] Exponential backoff multiplier
attr_accessor :backoff_factor

# @return [Float] Longest backoff delay in seconds (without Retry-After)
attr_accessor :max_retry_interval

# A server-supplied Retry-After longer than this (e.g. the 5-minute
# per-session restart cooldown) is raised to the caller instead of waited for.
# @return [Integer] seconds
attr_accessor :max_retry_after

# --- Hooks ---

# @return [#call, nil] Callable invoked before each request; receives request context hash
# @return [#call, nil] Callable invoked before each request attempt; receives
# a Hash with :method, :url, :path, :params and :attempt (never the headers,
# so the API key does not leak into logs)
attr_accessor :on_request

# @return [#call, nil] Callable invoked after each response; receives OctaSpace::Response
attr_accessor :on_response

# --- Logging ---

# @return [Logger, nil] Ruby Logger instance (or any object responding to #debug/#info/#warn/#error)
# Logger for one line per request (method, path, status, duration).
# Headers and bodies are never logged: they carry the API key and,
# for sessions, URLs with credentials.
# @return [Logger, nil]
attr_accessor :logger

# @return [Symbol] Log level (:debug, :info, :warn, :error)
Expand All @@ -86,7 +120,7 @@ class Configuration
attr_accessor :user_agent

DEFAULTS = {
base_url: "https://api.octa.space",
base_url: DEFAULT_BASE_URL,
open_timeout: 10,
read_timeout: 30,
write_timeout: 30,
Expand All @@ -97,6 +131,8 @@ class Configuration
max_retries: 2,
retry_interval: 0.5,
backoff_factor: 2.0,
max_retry_interval: 10.0,
max_retry_after: 60,
ssl_verify: true,
log_level: :info
}.freeze
Expand All @@ -106,19 +142,30 @@ def initialize
@user_agent = "octaspace-ruby/#{OctaSpace::VERSION} Ruby/#{RUBY_VERSION}"
end

# Sets the API key; surrounding whitespace and a leading "Bearer " are
# removed, and an empty value means no key.
# @param value [String, nil]
def api_key=(value)
key = value.to_s.strip.sub(/\Abearer\s+/i, "")
@api_key = key.empty? ? nil : key
end

# @return [Boolean] whether an API key is configured
def api_key? = !api_key.nil?

# Alias: `persistent` is the Cube-internal term; `keep_alive` is the public SDK term
alias_method :persistent, :keep_alive
alias_method :persistent=, :keep_alive=

# @return [Boolean]
def keep_alive? = !!keep_alive

# Returns effective list of API URLs.
# Returns effective list of API URLs, without trailing slashes.
# base_urls takes priority over base_url; always returns an Array.
# @return [Array<String>]
def urls
candidates = Array(base_urls).map(&:to_s).reject(&:empty?)
candidates.empty? ? Array(base_url).map(&:to_s).reject(&:empty?) : candidates
candidates = clean_urls(base_urls)
candidates.empty? ? clean_urls(base_url) : candidates
end

# Deep-clone configuration for per-client overrides
Expand All @@ -132,5 +179,11 @@ def dup
end
copy
end

private

def clean_urls(value)
Array(value).map { |url| url.to_s.strip.sub(%r{/+\z}, "") }.reject(&:empty?)
end
end
end
117 changes: 77 additions & 40 deletions lib/octaspace/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
module OctaSpace
# Base error class for all OctaSpace SDK errors
class Error < StandardError
attr_reader :response, :status, :request_id
# @return [OctaSpace::Response, nil] the response that caused the error
attr_reader :response

# @return [Integer, nil] HTTP status
attr_reader :status

# @return [String, nil] X-Request-Id of the response
attr_reader :request_id

def initialize(message = nil, response: nil)
@response = response
Expand All @@ -21,82 +28,112 @@ class NetworkError < Error; end
class ConnectionError < NetworkError; end
class TimeoutError < NetworkError; end

# API-level errors (HTTP response received, but indicates failure)
class ApiError < Error; end
# An HTTP 4xx/5xx response from the API, or a 2xx response the SDK could not
# read. The next API answers errors as +{"message": "..."}+; the message is
# used as the exception message.
class ApiError < Error
# @return [Integer, nil] seconds to wait before retrying (Retry-After header)
attr_reader :retry_after

# Domain-level rejection where transport succeeded but the provision request
# was rejected by the API payload contract.
class ProvisionRejectedError < Error
attr_reader :rejections
# @return [OctaSpace::RateLimit, nil] rate limit state of this response
attr_reader :rate_limit

def initialize(message = nil, response: nil, rejections: [])
@rejections = Array(rejections)
super(message || build_message(@rejections), response: response)
def initialize(message = nil, response: nil)
@retry_after = response&.retry_after
@rate_limit = response&.rate_limit
super
end

private

def build_message(rejections)
first_reason =
rejections.filter_map do |item|
next unless item.is_a?(Hash)

item["reason"] || item[:reason]
end.first

return "Provision request rejected" if first_reason.to_s.empty?

"Provision request rejected: #{first_reason}"
end
# @return [Object, nil] parsed response body
def body = response&.data
end

# 401 Unauthorized
# 400 or 422: the request was rejected as invalid; the message says why
# (for example "tail must be 1..1000" or "Invalid node number").
class ValidationError < ApiError; end

# 401: the API key is missing or invalid. The next API expects
# +Authorization: Bearer octa_ak_…+; legacy account tokens are refused.
# Also raised before any request when an endpoint needs a key and the
# client has none.
class AuthenticationError < ApiError; end

# 403 Forbidden
class PermissionError < ApiError; end

# 404 Not Found
# 404: the resource does not exist. For a session this also means it has
# stopped: the API only reports running sessions.
class NotFoundError < ApiError; end

# 422 Unprocessable Entity
class ValidationError < ApiError; end

# 429 Too Many Requests — includes Retry-After header value
class RateLimitError < ApiError
attr_reader :retry_after
# 409: the operation conflicts with the current state, e.g. restarting a
# session that is still starting or restarting.
class ConflictError < ApiError; end

def initialize(message = nil, response: nil)
@retry_after = response&.retry_after
super
end
end
# 429: too many requests. Wait {#retry_after} seconds before retrying.
class RateLimitError < ApiError; end

# 5xx Server Errors
class ServerError < ApiError; end
class BadGatewayError < ServerError; end # 502
class ServiceUnavailableError < ServerError; end # 503

# 503: a backend the API depends on (render nodes, statistics, …) is
# temporarily unavailable. The request itself was fine; retry later.
class ServiceUnavailableError < ServerError; end

class GatewayTimeoutError < ServerError; end # 504

# Legacy batch-style rejection of services.mr.create (removed with that resource)
class ProvisionRejectedError < Error
attr_reader :rejections

def initialize(message = nil, response: nil, rejections: [])
@rejections = Array(rejections)
reason = @rejections.filter_map { |item| item["reason"] if item.is_a?(Hash) }.first
super(message || (reason ? "Provision request rejected: #{reason}" : "Provision request rejected"), response: response)
end
end

# HTTP status code → exception class mapping
STATUS_ERRORS = {
400 => ValidationError,
401 => AuthenticationError,
403 => PermissionError,
404 => NotFoundError,
409 => ConflictError,
422 => ValidationError,
429 => RateLimitError,
502 => BadGatewayError,
503 => ServiceUnavailableError,
504 => GatewayTimeoutError
}.freeze

# Longest error message kept from a response body (e.g. an HTML error page)
MAX_ERROR_MESSAGE_LENGTH = 512

# Build the appropriate error for a given Response
# @param response [OctaSpace::Response]
# @param api_key [String, nil] the key the request was sent with, for a hint on 401
# @return [OctaSpace::ApiError] subclass instance
def self.error_for(response)
def self.error_for(response, api_key: nil)
klass = STATUS_ERRORS.fetch(response.status) do
response.server_error? ? ServerError : ApiError
end
klass.new(response: response)
message = error_message(response)
if response.status == 401 && api_key && !api_key.start_with?(API_KEY_PREFIX)
message += " (the next API expects a key starting with \"#{API_KEY_PREFIX}\"; " \
"legacy account tokens are not accepted)"
end
klass.new(message, response: response)
end

# @api private
def self.error_message(response)
data = response.data
if data.is_a?(Hash)
text = data.values_at("message", "error", "detail").find { |v| v.is_a?(String) && !v.empty? }
end
text ||= response.body.to_s.strip
text = "Request failed with status #{response.status}" if text.empty?
(text.length > MAX_ERROR_MESSAGE_LENGTH) ? "#{text[0, MAX_ERROR_MESSAGE_LENGTH]}…" : text
end
end
2 changes: 1 addition & 1 deletion lib/octaspace/resources/network.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class Network < Base
# GET /network
# @return [OctaSpace::Response]
def info
get("/network")
get("/network", auth: :none)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/octaspace/resources/services/machine_rental.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class MachineRental < Base
# @param params [Hash] optional filter params
# @return [OctaSpace::Response]
def list(**params)
get("/services/mr", params:)
get("/services/mr", params:, auth: :optional)
end

# Create (start) a machine rental
Expand Down
2 changes: 1 addition & 1 deletion lib/octaspace/resources/services/render.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Render < Base
# @param params [Hash] optional filter params
# @return [OctaSpace::Response]
def list(**params)
get("/services/render", params:)
get("/services/render", params:, auth: :optional)
end

# Create (start) a render job
Expand Down
2 changes: 1 addition & 1 deletion lib/octaspace/resources/services/session_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def logs(recent: nil)
# @param params [Hash] e.g. { score: 5 }
# @return [OctaSpace::Response]
def stop(**params)
@transport.post("/services/#{@uuid}/stop", body: params.empty? ? nil : params)
@transport.post("/services/#{@uuid}/stop", body: params.empty? ? nil : params, idempotent: true)
end
end
end
Expand Down
Loading
Loading