octaspace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ # Base error class for all OctaSpace SDK errors
5
+ class Error < StandardError
6
+ attr_reader :response, :status, :request_id
7
+
8
+ def initialize(message = nil, response: nil)
9
+ @response = response
10
+ @status = response&.status
11
+ @request_id = response&.request_id
12
+ super(message || "OctaSpace API error")
13
+ end
14
+ end
15
+
16
+ # Raised when SDK is misconfigured (e.g., missing required gems for persistent mode)
17
+ class ConfigurationError < Error; end
18
+
19
+ # Network-level errors (before HTTP response is received)
20
+ class NetworkError < Error; end
21
+ class ConnectionError < NetworkError; end
22
+ class TimeoutError < NetworkError; end
23
+
24
+ # API-level errors (HTTP response received, but indicates failure)
25
+ class ApiError < Error; end
26
+
27
+ # 401 Unauthorized
28
+ class AuthenticationError < ApiError; end
29
+
30
+ # 403 Forbidden
31
+ class PermissionError < ApiError; end
32
+
33
+ # 404 Not Found
34
+ class NotFoundError < ApiError; end
35
+
36
+ # 422 Unprocessable Entity
37
+ class ValidationError < ApiError; end
38
+
39
+ # 429 Too Many Requests — includes Retry-After header value
40
+ class RateLimitError < ApiError
41
+ attr_reader :retry_after
42
+
43
+ def initialize(message = nil, response: nil)
44
+ @retry_after = response&.retry_after
45
+ super
46
+ end
47
+ end
48
+
49
+ # 5xx Server Errors
50
+ class ServerError < ApiError; end
51
+ class BadGatewayError < ServerError; end # 502
52
+ class ServiceUnavailableError < ServerError; end # 503
53
+ class GatewayTimeoutError < ServerError; end # 504
54
+
55
+ # HTTP status code → exception class mapping
56
+ STATUS_ERRORS = {
57
+ 401 => AuthenticationError,
58
+ 403 => PermissionError,
59
+ 404 => NotFoundError,
60
+ 422 => ValidationError,
61
+ 429 => RateLimitError,
62
+ 502 => BadGatewayError,
63
+ 503 => ServiceUnavailableError,
64
+ 504 => GatewayTimeoutError
65
+ }.freeze
66
+
67
+ # Build the appropriate error for a given Response
68
+ # @param response [OctaSpace::Response]
69
+ # @return [OctaSpace::ApiError] subclass instance
70
+ def self.error_for(response)
71
+ klass = STATUS_ERRORS.fetch(response.status) do
72
+ response.server_error? ? ServerError : ApiError
73
+ end
74
+ klass.new(response: response)
75
+ end
76
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Middleware
5
+ # Thread-safe round-robin URL rotator with automatic failover
6
+ #
7
+ # When multiple base_urls are configured, requests are distributed
8
+ # across all healthy URLs. Failed URLs enter a cooldown period before
9
+ # being re-admitted to the rotation.
10
+ #
11
+ # @example
12
+ # rotator = OctaSpace::Middleware::UrlRotator.new([
13
+ # "https://api.octa.space",
14
+ # "https://api2.octa.space"
15
+ # ])
16
+ #
17
+ # url = rotator.next_url
18
+ # rotator.mark_failed(url)
19
+ # rotator.stats # => { total: 2, available: 1, failed: ["https://api.octa.space"] }
20
+ class UrlRotator
21
+ # Seconds a failed URL is excluded from rotation
22
+ FAILURE_COOLDOWN = 30
23
+
24
+ # @param urls [Array<String>] ordered list of API base URLs
25
+ def initialize(urls)
26
+ @urls = urls.dup.freeze
27
+ @counter = 0
28
+ @failed = {} # url => Time failed_at
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ # @return [String] next available URL (round-robin), falls back to first if all failed
33
+ def next_url
34
+ available = available_urls
35
+ return @urls.first if available.empty?
36
+
37
+ @mutex.synchronize do
38
+ idx = @counter % available.size
39
+ @counter += 1
40
+ available[idx]
41
+ end
42
+ end
43
+
44
+ # Mark a URL as temporarily failed
45
+ # @param url [String]
46
+ def mark_failed(url)
47
+ @mutex.synchronize { @failed[url] = Time.now }
48
+ end
49
+
50
+ # Mark a URL as recovered (remove from failed list)
51
+ # @param url [String]
52
+ def mark_success(url)
53
+ @mutex.synchronize { @failed.delete(url) }
54
+ end
55
+
56
+ # @return [Array<String>] currently healthy URLs (excluding cooldown)
57
+ def available_urls
58
+ now = Time.now
59
+ @mutex.synchronize do
60
+ @urls.reject do |url|
61
+ failed_at = @failed[url]
62
+ next false unless failed_at
63
+
64
+ if now - failed_at < FAILURE_COOLDOWN
65
+ true
66
+ else
67
+ @failed.delete(url)
68
+ false
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # @return [Hash] diagnostic stats
75
+ def stats
76
+ {
77
+ total: @urls.size,
78
+ available: available_urls.size,
79
+ failed: @mutex.synchronize { @failed.keys }
80
+ }
81
+ end
82
+
83
+ # Reset state — useful in tests
84
+ def reset!
85
+ @mutex.synchronize do
86
+ @counter = 0
87
+ @failed.clear
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ # Rails integration via Railtie
5
+ #
6
+ # Automatically loaded when Rails is present (see lib/octaspace.rb).
7
+ # Allows configuration via config/initializers/octaspace.rb:
8
+ #
9
+ # OctaSpace.configure do |config|
10
+ # config.api_key = ENV["OCTA_API_KEY"]
11
+ # config.keep_alive = true
12
+ # config.pool_size = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
13
+ # config.logger = Rails.logger
14
+ # end
15
+ class Railtie < ::Rails::Railtie
16
+ initializer "octaspace.configure" do
17
+ # No-op: users call OctaSpace.configure {} directly in their initializer.
18
+ # This hook exists for future extensions (e.g., auto-configure from credentials).
19
+ end
20
+
21
+ # Gracefully shut down the shared client's persistent connection pools
22
+ # when the Rails process stops. Only applies when keep_alive: true is used
23
+ # and the shared client (OctaSpace.client) has been accessed.
24
+ config.after_initialize do
25
+ at_exit { OctaSpace.shutdown_shared_client! }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ # Account-related API endpoints
6
+ #
7
+ # @example
8
+ # client.accounts.profile
9
+ # client.accounts.balance
10
+ class Accounts < Base
11
+ # Fetch the authenticated user's profile
12
+ # GET /accounts
13
+ # @return [OctaSpace::Response]
14
+ def profile
15
+ get("/accounts")
16
+ end
17
+
18
+ # Fetch the authenticated user's balance
19
+ # GET /accounts/balance
20
+ # @return [OctaSpace::Response]
21
+ def balance
22
+ get("/accounts/balance")
23
+ end
24
+
25
+ # Generate / create a new wallet for the authenticated user
26
+ # POST /accounts
27
+ # @return [OctaSpace::Response]
28
+ def generate_wallet
29
+ post("/accounts")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ # Apps API endpoint
6
+ #
7
+ # @example
8
+ # client.apps.list
9
+ class Apps < Base
10
+ # List available apps
11
+ # GET /apps
12
+ # @param params [Hash] optional filter params
13
+ # @return [OctaSpace::Response]
14
+ def list(**params)
15
+ get("/apps", params:)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module OctaSpace
6
+ module Resources
7
+ # Base class for all API resource groups
8
+ #
9
+ # Delegates HTTP methods to the transport layer and provides
10
+ # a clean DSL for subclasses.
11
+ class Base
12
+ # @param transport [OctaSpace::Transport::Base]
13
+ def initialize(transport)
14
+ @transport = transport
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :transport
20
+
21
+ def get(path, **opts) = transport.get(path, **opts)
22
+ def post(path, **opts) = transport.post(path, **opts)
23
+ def put(path, **opts) = transport.put(path, **opts)
24
+ def patch(path, **opts) = transport.patch(path, **opts)
25
+ def delete(path, **opts) = transport.delete(path, **opts)
26
+
27
+ # Encode a single path segment to prevent path traversal
28
+ # @param segment [String, Integer]
29
+ # @return [String]
30
+ def encode(segment)
31
+ URI.encode_www_form_component(segment.to_s)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ # Idle Jobs API endpoints
6
+ #
7
+ # Each idle job is identified by both a node ID and a job ID.
8
+ #
9
+ # @example
10
+ # client.idle_jobs.find(node_id: 69, job_id: 42)
11
+ # client.idle_jobs.logs(node_id: 69, job_id: 42)
12
+ class IdleJobs < Base
13
+ # Fetch a single idle job status
14
+ # GET /idle_jobs/:node_id/:job_id
15
+ # @param node_id [Integer, String]
16
+ # @param job_id [Integer, String]
17
+ # @return [OctaSpace::Response]
18
+ def find(node_id:, job_id:)
19
+ get("/idle_jobs/#{encode(node_id)}/#{encode(job_id)}")
20
+ end
21
+
22
+ # Fetch idle job logs
23
+ # GET /idle_jobs/:node_id/:job_id/logs
24
+ # @param node_id [Integer, String]
25
+ # @param job_id [Integer, String]
26
+ # @return [OctaSpace::Response]
27
+ def logs(node_id:, job_id:)
28
+ get("/idle_jobs/#{encode(node_id)}/#{encode(job_id)}/logs")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ # Network information endpoint
6
+ #
7
+ # @example
8
+ # client.network.info
9
+ class Network < Base
10
+ # Fetch combined network information (blockchain, market, nodes, power, etc.)
11
+ # GET /network
12
+ # @return [OctaSpace::Response]
13
+ def info
14
+ get("/network")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ # Node-related API endpoints
6
+ #
7
+ # @example
8
+ # client.nodes.list
9
+ # client.nodes.find(123)
10
+ # client.nodes.reboot(123)
11
+ # client.nodes.update_prices(123, gpu_hour: 0.5, cpu_hour: 0.1)
12
+ class Nodes < Base
13
+ # List all nodes
14
+ # GET /nodes
15
+ # @param params [Hash] optional filter/pagination params
16
+ # @return [OctaSpace::Response]
17
+ def list(**params)
18
+ get("/nodes", params:)
19
+ end
20
+
21
+ # Fetch a single node by ID
22
+ # GET /nodes/:id
23
+ # @param id [Integer, String]
24
+ # @return [OctaSpace::Response]
25
+ def find(id)
26
+ get("/nodes/#{encode(id)}")
27
+ end
28
+
29
+ # Download node identity file (binary response)
30
+ # GET /nodes/:id/ident
31
+ # @param id [Integer, String]
32
+ # @return [OctaSpace::Response]
33
+ def download_ident(id)
34
+ get("/nodes/#{encode(id)}/ident")
35
+ end
36
+
37
+ # Download node logs (binary response)
38
+ # GET /nodes/:id/logs
39
+ # @param id [Integer, String]
40
+ # @return [OctaSpace::Response]
41
+ def download_logs(id)
42
+ get("/nodes/#{encode(id)}/logs")
43
+ end
44
+
45
+ # Update node pricing
46
+ # PATCH /nodes/:id/prices
47
+ # @param id [Integer, String]
48
+ # @param prices [Hash] e.g. { gpu_hour: 0.5, cpu_hour: 0.1 }
49
+ # @return [OctaSpace::Response]
50
+ def update_prices(id, **prices)
51
+ patch("/nodes/#{encode(id)}/prices", body: prices)
52
+ end
53
+
54
+ # Reboot a node
55
+ # GET /nodes/:id/reboot
56
+ # @param id [Integer, String]
57
+ # @return [OctaSpace::Response]
58
+ def reboot(id)
59
+ get("/nodes/#{encode(id)}/reboot")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ class Services
6
+ # Machine Rental (MR) service endpoints
7
+ #
8
+ # @example
9
+ # client.services.mr.list
10
+ # client.services.mr.create(
11
+ # node_id: 123,
12
+ # disk_size: 10,
13
+ # image: "ubuntu:24.04",
14
+ # app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c"
15
+ # )
16
+ class MachineRental < Base
17
+ # List available / active machine rentals
18
+ # GET /services/mr
19
+ # @param params [Hash] optional filter params
20
+ # @return [OctaSpace::Response]
21
+ def list(**params)
22
+ get("/services/mr", params:)
23
+ end
24
+
25
+ # Create (start) a machine rental
26
+ # POST /services/mr
27
+ # @param attrs [Hash] rental parameters
28
+ # @return [OctaSpace::Response]
29
+ def create(**attrs)
30
+ item = {
31
+ id: 0,
32
+ node_id: attrs.fetch(:node_id),
33
+ disk_size: attrs.fetch(:disk_size),
34
+ image: attrs.fetch(:image),
35
+ app: attrs[:app].to_s,
36
+ envs: attrs[:envs] || {},
37
+ ports: attrs[:ports] || [],
38
+ http_ports: attrs[:http_ports] || [],
39
+ start_command: attrs[:start_command].to_s,
40
+ entrypoint: attrs[:entrypoint].to_s
41
+ }
42
+
43
+ post("/services/mr", body: [item])
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ class Services
6
+ # Render service endpoints
7
+ #
8
+ # @example
9
+ # client.services.render.list
10
+ # client.services.render.create(node_id: 123, disk_size: 100)
11
+ class Render < Base
12
+ # List render jobs
13
+ # GET /services/render
14
+ # @param params [Hash] optional filter params
15
+ # @return [OctaSpace::Response]
16
+ def list(**params)
17
+ get("/services/render", params:)
18
+ end
19
+
20
+ # Create (start) a render job
21
+ # POST /services/render
22
+ # @param attrs [Hash] render job parameters
23
+ # @return [OctaSpace::Response]
24
+ def create(**attrs)
25
+ post("/services/render", body: attrs)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module OctaSpace
6
+ module Resources
7
+ class Services
8
+ # Proxy object for operations on a specific service session
9
+ #
10
+ # Obtained via: client.services.session("uuid")
11
+ #
12
+ # @example
13
+ # session = client.services.session("abc-123")
14
+ # session.info
15
+ # session.logs
16
+ # session.stop(score: 5)
17
+ class SessionProxy
18
+ # @param transport [OctaSpace::Transport::Base]
19
+ # @param uuid [String] session UUID
20
+ def initialize(transport, uuid)
21
+ @transport = transport
22
+ @uuid = URI.encode_www_form_component(uuid.to_s)
23
+ end
24
+
25
+ # Fetch session details
26
+ # GET /services/:uuid/info
27
+ # @return [OctaSpace::Response]
28
+ def info
29
+ @transport.get("/services/#{@uuid}/info")
30
+ end
31
+
32
+ # Fetch session logs
33
+ # GET /services/:uuid/logs
34
+ # @return [OctaSpace::Response]
35
+ def logs
36
+ @transport.get("/services/#{@uuid}/logs")
37
+ end
38
+
39
+ # Stop the session
40
+ # POST /services/:uuid/stop
41
+ # @param params [Hash] e.g. { score: 5 }
42
+ # @return [OctaSpace::Response]
43
+ def stop(**params)
44
+ @transport.post("/services/#{@uuid}/stop", body: params.empty? ? nil : params)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ class Services
6
+ # VPN service endpoints
7
+ #
8
+ # @example
9
+ # client.services.vpn.list
10
+ # client.services.vpn.create(node_id: 123)
11
+ class Vpn < Base
12
+ # List active VPN sessions
13
+ # GET /services/vpn
14
+ # @param params [Hash] optional filter params
15
+ # @return [OctaSpace::Response]
16
+ def list(**params)
17
+ get("/services/vpn", params:)
18
+ end
19
+
20
+ # Create (start) a VPN session
21
+ # POST /services/vpn
22
+ # @param attrs [Hash] VPN parameters
23
+ # @return [OctaSpace::Response]
24
+ def create(**attrs)
25
+ post("/services/vpn", body: attrs)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ # Services namespace — aggregates MR, VPN, Render subresources
6
+ # and provides the session proxy pattern
7
+ #
8
+ # @example
9
+ # client.services.mr.list
10
+ # client.services.vpn.create(node_id: 123)
11
+ # client.services.render.create(node_id: 456, disk_size: 100)
12
+ #
13
+ # # Session proxy pattern
14
+ # client.services.session("uuid-123").info
15
+ # client.services.session("uuid-123").stop(score: 5)
16
+ class Services < Base
17
+ attr_reader :mr, :vpn, :render
18
+
19
+ def initialize(transport)
20
+ super
21
+ @mr = Services::MachineRental.new(transport)
22
+ @vpn = Services::Vpn.new(transport)
23
+ @render = Services::Render.new(transport)
24
+ end
25
+
26
+ # Return a proxy object for operations on a specific session
27
+ # @param uuid [String] session UUID
28
+ # @return [OctaSpace::Resources::Services::SessionProxy]
29
+ def session(uuid)
30
+ Services::SessionProxy.new(transport, uuid)
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ require_relative "services/session_proxy"
37
+ require_relative "services/machine_rental"
38
+ require_relative "services/vpn"
39
+ require_relative "services/render"
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ module Resources
5
+ # Session listing endpoint
6
+ #
7
+ # For operations on a specific session (info/logs/stop),
8
+ # use the proxy pattern: client.services.session("uuid")
9
+ #
10
+ # @example
11
+ # client.sessions.list
12
+ # client.sessions.list(recent: true)
13
+ class Sessions < Base
14
+ # List all sessions
15
+ # GET /sessions
16
+ # @param params [Hash] optional filter params
17
+ # @return [OctaSpace::Response]
18
+ def list(**params)
19
+ get("/sessions", params:)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OctaSpace
4
+ # Wraps a raw Faraday response, providing a stable SDK interface
5
+ class Response
6
+ attr_reader :status, :headers, :body, :data
7
+
8
+ # @param faraday_response [Faraday::Response]
9
+ def initialize(faraday_response)
10
+ @status = faraday_response.status
11
+ @headers = faraday_response.headers
12
+ @body = faraday_response.body
13
+ # Faraday :json middleware parses JSON body into a Hash/Array automatically
14
+ @data = faraday_response.body
15
+ end
16
+
17
+ # @return [Boolean] true for 2xx status codes
18
+ def success? = (200..299).cover?(status)
19
+
20
+ # @return [Boolean] true for 4xx status codes
21
+ def client_error? = (400..499).cover?(status)
22
+
23
+ # @return [Boolean] true for 5xx status codes
24
+ def server_error? = (500..599).cover?(status)
25
+
26
+ # @return [Boolean] true for any non-2xx error
27
+ def error? = client_error? || server_error?
28
+
29
+ # @return [String, nil] X-Request-Id header value
30
+ def request_id = headers["x-request-id"]
31
+
32
+ # @return [Integer, nil] Retry-After header value in seconds
33
+ def retry_after = headers["retry-after"]&.to_i
34
+
35
+ def to_s
36
+ "#<OctaSpace::Response status=#{status}>"
37
+ end
38
+ alias_method :inspect, :to_s
39
+ end
40
+ end