fly_io

fly_io is a contract-driven Ruby client for Fly.io’s documented platform APIs. It covers every operation in the Machines REST OpenAPI contract, the documentation-only Network Policies endpoints, Fly’s Prometheus compatibility endpoint, and Fly’s explicitly unstable control-plane GraphQL boundary.

The Ruby namespace is FlyIO. The gem name was unclaimed on RubyGems when checked on 2026-08-26; see contracts/research_metadata.json. This repository is not published by or affiliated with Fly.io.

Installation

Add the gem to your bundle:

gem "fly_io", "~> 0.1"

Then run bundle install. Ruby 3.2 or newer is supported. Development and the primary CI job use Ruby 4.0.6, the latest stable release verified from ruby-lang.org on the contract retrieval date.

The sole runtime dependency is Faraday 2.x. It provides a maintained, adapter-injectable HTTP foundation; retries, redaction, contract mapping, and model coercion are implemented in this gem so their behavior remains explicit.

Quickstart

require "fly_io"

client = FlyIO::Client.new(token: ENV.fetch("FLY_API_TOKEN"))

apps = client.apps.list(org_slug: "personal")
machine = client.machines.create(
  app_name: "my-app",
  body: {
    region: "iad",
    config: {
      image: "registry.example.com/my-image:latest",
      guest: {cpu_kind: "shared", cpus: 1, memory_mb: 512}
    }
  }
)

client.machines.start(app_name: "my-app", machine_id: machine.id)
client.machines.wait(app_name: "my-app", machine_id: machine.id, state: "started", timeout: 60)

When destroying an app, pass its selected custom private network to delete the attachment along with the app. Omit network: for apps on the default network:

client.apps.destroy(app_name: "my-app", network: "customer-network")

Resource methods return FlyIO::Response. response.body is coerced to the documented FlyIO::Models::* class (or an array/primitive where the contract says so), while response methods delegate to a model for concise code such as machine.id. Models keep unknown fields and preserve exact wire keys for forward compatibility.

All named endpoint inputs are keyword arguments. JSON request bodies use body: because this gives every generated operation one consistent signature. The generated complete API reference maps each Ruby method to the official operation ID, method, and path.

Authentication

The official Machines guide and current examples use Authorization: Bearer <token>. The generated reference also contains contradictory FlyV1 prose. Consequently, a raw token uses Bearer by default and preformatted values are explicit:

FlyIO::Client.new(token: ENV.fetch("FLY_API_TOKEN"))
FlyIO::Client.new(authorization: "FlyV1 #{ENV.fetch("FLY_MACAROON")}")
FlyIO::Client.new(token: ENV.fetch("FLY_MACAROON"), authorization_mode: :fly_v1)

A token passed to token: must be raw, so Bearer Bearer ... cannot be produced accidentally. Authorization values, tokens, secrets, passwords, credentials, private keys, and certificates are filtered from logs, request metadata, and exceptions.

Configuration and transport behavior

configuration = FlyIO::Configuration.new(
  token: ENV.fetch("FLY_API_TOKEN"),
  base_url: "https://api.machines.dev",       # or http://_api.internal:4280 on the Fly private network
  graphql_url: "https://api.fly.io/graphql",
  metrics_url: "https://api.fly.io",
  open_timeout: 10,
  read_timeout: 30,
  write_timeout: 30,
  request_timeout: 45,
  proxy: ENV["HTTPS_PROXY"],
  user_agent: "my-service/1.0 fly_io/#{FlyIO::VERSION}",
  max_retries: 2,
  base_retry_interval: 0.25,
  max_retry_interval: 5,
  retry_jitter: 0.25,
  logger: Logger.new($stderr)
)
client = FlyIO::Client.new(configuration)

TLS verification is always enabled by the HTTP adapter. The client parses JSON only when the response content type is JSON, represents 204 as a nil body, preserves 202 responses, and safely returns text, binary, empty, or malformed JSON bodies. FlyIO::Response includes status, headers, parsed body, raw body, safe request metadata, and Fly’s request ID.

GET/HEAD/OPTIONS requests retry bounded transient connection failures and 408, 429, 500, 502, 503, and 504 responses. Retry-After is honored; otherwise delay uses capped exponential backoff with jitter. Unsafe methods are never retried unless a caller passes retry_unsafe: true to that request or deliberately enables it globally. Fly’s limits are per-action and per-resource, so this gem does not pretend a single client-wide throttle is correct.

Configuration and resource objects are immutable. All request state is local, making a client safe to share between threads. An object responding to call(method:, url:, headers:, body:, timeout:) can be supplied as adapter: for dependency injection; a Faraday adapter symbol is also accepted.

Errors

All failures inherit FlyIO::Error. HTTP failures use FlyIO::APIError and its typed subclasses:

  • AuthenticationError / AuthorizationError
  • NotFoundError
  • ValidationError
  • RateLimitError
  • RequestTimeoutError
  • TransportError
  • ServerError
  • GraphQLError

API errors retain status, safe headers, redacted parsed details, request ID, and redacted request metadata.

Raw REST and operation-ID access

Use a path template when values are dynamic so every component is escaped independently:

client.request(
  method: :get,
  path: "/v1/apps/{app_name}/machines",
  path_params: {app_name: "my-app"},
  query: {state: "started"}
)

client.operation("Machines_start", app_name: "my-app", machine_id: "machine-id")

The low-level API validates the method, rejects path traversal and URL-shaped paths, supports repeated query values, and returns the same response/errors as resource methods. The upstream duplicate Machines_update_metadata ID must be disambiguated with method: and path:.

Network Policies

Fly officially documents three Network Policies operations that are missing from its OpenAPI document:

client.network_policies.upsert(
  app_name: "my-app",
  body: {
    name: "web-egress",
    selector: {metadata: {role: "web"}},
    rules: [{action: "allow", direction: "egress", ports: [{protocol: "tcp", port: 443}]}]
  }
)
client.network_policies.list(app_name: "my-app")
client.network_policies.delete(app_name: "my-app", policy_id: "policy-id")

The guide does not document response statuses or schemas. Responses therefore remain forward-compatible raw values; the body input is checked only against fields and constraints the guide actually states.

Prometheus metrics

Fly documents seven Prometheus-compatible endpoint families at https://api.fly.io/prometheus/<org>/. They are a stable observability/data API, not control-plane operations, but are included so the platform-surface audit is honest:

client.metrics.query(org_slug: "personal", query: 'sum(rate(fly_edge_http_responses_count[5m]))')
client.metrics.query_range(org_slug: "personal", query: "up", start: "-1h", end: "now", step: "60s")
client.metrics.series(org_slug: "personal", match: ['{__name__="up"}'])
client.metrics.labels(org_slug: "personal")
client.metrics.label_values(org_slug: "personal", label_name: "app")
client.metrics.tsdb(org_slug: "personal")
client.metrics.federate(org_slug: "personal", match: ["up"])

The JSON envelope becomes FlyIO::Models::PrometheusAPIResponse; federation remains text. Endpoint semantics are delegated to the stable Prometheus HTTP API and Fly’s stated VictoriaMetrics compatibility.

GraphQL: experimental and unstable

Fly explicitly documents https://api.fly.io/graphql as an internal flyctl interface with no stability guarantees. It can change without notice. Prefer REST or flyctl where possible. Nothing in this gem presents GraphQL fields as a stable public contract.

Raw documents, variables, operation names, response parsing, errors, timeouts, and opt-in retries are first class:

graphql = client.graphql
response = graphql.query(
  document: "query App($name: String!) { app(name: $name) { id name } }",
  variables: {name: "my-app"},
  operation_name: "App"
)

Schema introspection is double opt-in: construct configuration with introspection: true, then call graphql.introspect(document: ...). The committed flyctl schema is evidence only.

The experimental adapter inventories 29 current flyctl documents, 85 fly-go v0.9.8 documents, and 3 official unstable examples. Execute one with graphql.experimental.execute("flyctl.GetApp", variables: ...). Arbitrary raw GraphQL documents remain available when Fly adds fields before this gem releases.

Contracts, development, and tests

The surface inventory records every selected/excluded surface, provenance, hashes, revisions, counts, stability, and gaps. Contract artifacts live under contracts/; generated runtime metadata is under lib/fly_io/generated/.

bundle install
bundle exec rake api:check   # offline snapshot/ref/mapping/generated-drift checks
bundle exec rspec            # offline; WebMock disables all network access
bundle exec rubocop
bundle exec bundle-audit check --update
bundle exec rake build

For an isolated --install-dir smoke under RubyGems 4, use --ignore-dependencies for the target gem and include the already audited Bundler gem path when requiring it. RubyGems otherwise attempts to rediscover Faraday in a local gem repository even when it is already installed. CI performs this install plus a packaged-gem require check.

bundle exec rake api:fetch_check is the explicit networked upstream-drift check. script/generate_api regenerates operation/model metadata and docs/API.md. Generated code is included in coverage because the small runtime generator boundary is exercised by contract-derived RSpec examples; JSON metadata itself has no executable lines to exclude.

Live tests require FLY_IO_LIVE_TESTS=1, FLY_API_TOKEN, and FLY_IO_TEST_APP. Destructive live work additionally requires FLY_IO_DESTRUCTIVE_LIVE_TESTS=1; ordinary CI never has network access or credentials.

Versioning

The gem follows Semantic Versioning for its own stable Ruby API. Machines contract changes are reviewed through the drift gate. Experimental GraphQL changes may ship in any release and are called out in the changelog because Fly gives that upstream interface no compatibility guarantee.

License

MIT. See LICENSE.