Set ENVLESS_TOKEN and the shared client works anywhere, with no setup code:
require "envless/sdk"
workspace = Envless.workspace.get
puts workspace[:slug]Envless.workspace, Envless.variables and every other namespace on the module reach one shared client. Requiring the gem reads nothing: the client is built from ENVLESS_TOKEN and ENVLESS_API_ENDPOINT the first time you touch a namespace, so load order between your entrypoint and the rest of your code does not matter.
To configure it in code instead, call Envless.init once at startup, in a Rails initializer for example. Every caller of Envless.variables and the rest then uses that client.
require "envless/sdk"
Envless.init(ENV.fetch("ENVLESS_TOKEN"), timeout: 10,
max_retries: 5)Two ways to build one
They differ only in who holds the instance. Mix them freely; they do not interfere.
| Use when | |
|---|---|
Envless.init(...) | One workspace per process. The usual case. Installs the client the module-level namespaces resolve to, and returns it. |
Envless::Client.new(...) | You want an explicit instance to pass around, or several clients at once. Envless.new and Envless.create_client build the same thing, the second under the TypeScript name. |
require "envless/sdk"
client = Envless::Client.new
client.products.listBoth fall back to ENVLESS_TOKEN and ENVLESS_API_ENDPOINT whenever you leave token or base_url out, and raise ArgumentError with An Envless API key is required. Pass token: or set ENVLESS_TOKEN. when no token can be found. The token goes in as the first argument or as token:, not both.
Working with more than one workspace
A key is bound to one workspace, so reaching two means two clients. Envless.init holds exactly one, so build the others with Envless::Client.new.
require "envless/sdk"
staging_token = ENV.fetch("ENVLESS_STAGING_TOKEN")
staging = Envless::Client.new(token: staging_token)
live = Envless.variables.list_all("api", "production")
preview = staging.variables.list_all("api", "production")
puts "#{live.size} live, #{preview.size} in staging"The shared client is shared by everything in the process. In a multi-tenant server that handles requests for different workspaces, do not call Envless.init per request: build one Envless::Client per tenant and pass it down.
The shared client
| Method | Returns | Purpose |
|---|---|---|
Envless.init(token = nil, **options) | Envless::Client | Builds a client from the same options as Envless::Client.new, installs it as the shared one, and returns it. |
Envless.client | Envless::Client | The shared instance, built from the environment if init was never called. Pass it wherever a method wants a client:. |
Envless.reset_client | nil | Drops the shared client. The next access builds it afresh. Intended for tests. |
Envless.variables and the rest | the namespace | The same namespaces as on a client, reached through the shared one. |
Replacing the shared client with a second init does not close the first one, the same as in TypeScript. Close it yourself if you hold a reference to it.
Options
Envless::Client.new and Envless.init take the same arguments. The token is the optional first argument, or token:, and the rest are keywords.
| Option | Default | Purpose |
|---|---|---|
token: | ENVLESS_TOKEN | Your API key. Required. |
base_url: | ENVLESS_API_ENDPOINT, then https://api.envless.cloud | Point at a self-hosted or local instance, as a String or a URI. Trailing slashes are trimmed, and a bare localhost:2222 gets http:// while any other bare host gets https://. A user name, a password, a query or a fragment is refused. |
max_retries: | 3 | Retries idempotent requests on a transient status. It counts retries and not attempts, so the default is four requests at most. |
timeout: | 30 | Seconds each attempt may take, redirects and the response body included. An Integer, a Float or a Rational, and 0 turns the limit off. Every method also takes timeout: for one call. |
adapter: | a pooled Net::HTTP adapter | Anything that responds to call(request), for a different HTTP library, instrumentation or tests. See below. |
user_agent: | envless-sdk-ruby/<version> | Sent as User-Agent. Set something identifiable for your own tooling. |
disable_update_notice: | false | Silences the one-time check for a newer version on RubyGems. ENVLESS_DISABLE_UPDATE_NOTICE does the same. |
require "envless/sdk"
client = Envless::Client.new(
token: ENV.fetch("ENVLESS_TOKEN"),
base_url: "http://localhost:2222",
max_retries: 5,
timeout: 60,
user_agent: "acme-provisioner/2.1"
)
puts client.inspectA client is frozen once it is built, and inspect shows the base URL but never the token.
Retries
The client retries only when retrying is safe. Two conditions must both hold:
The method is idempotent
GET, HEAD, PUT and DELETE are retried. POST and PATCH never are, because a create or an update that timed out may well have landed.
The status is transient
408, 429, 500, 502, 503 and 504. Everything else, including every 4xx you can fix, fails on the first attempt.
Backoff doubles off a 250ms base, so the first retry waits 500ms, then 1s, then 2s, and every delay is capped at 30 seconds. A Retry-After header is read as a number of seconds and replaces the computed delay whenever it parses above zero, under that same 30 second cap. A date-form Retry-After does not parse and is ignored.
A request that produced no response at all, a refused connection or a DNS failure, raises Envless::NetworkError on the spot. There is no status to classify, so it is never retried.
Timeouts
timeout is a hard deadline for each attempt, covering the connection, the headers, any redirects and the whole response body, so a server that trickles a body cannot hold a call open past it. When it passes, the call raises Envless::NetworkError with timeout? true. Retries each get a fresh deadline.
require "envless/sdk"
begin
products = Envless.products.list_all(timeout: 5)
puts products.size
rescue Envless::NetworkError => error
raise unless error.timeout?
warn "The API did not answer within 5 seconds"
endOn Ruby 3.2 a slow DNS lookup can outlast the deadline, because name resolution there cannot be interrupted. Ruby 3.3 and newer bound it like everything else.
Threads and forks
One client is meant to be shared. It holds no per-request state, so threads in Puma, Sidekiq or your own pool can call it at the same time: each request checks a kept-alive connection out of a small pool, up to eight idle per host, and returns it afterwards. A connection idle for more than two seconds is not reused, and one the server has already dropped is noticed before reuse and replaced.
After a fork, in Puma's cluster mode, Unicorn or Resque, the child never touches the connections it inherited, because the parent may still be using them. It notices the new process and opens its own, so a client built before the fork keeps working in every worker without a reconnect hook. close closes the idle connections of the process that calls it.
Bring your own adapter
adapter: takes anything that responds to call(request). It receives an Envless::HttpRequest with method, url, headers, body and timeout, and returns an object with status, headers and body, such as an Envless::HttpResponse. Retries, redirects, the deadline and the error mapping stay in the client, so an adapter only moves bytes. That makes the whole SDK testable with no network and no mocking library:
require "envless/sdk"
fixture = File.read("spec/fixtures/envless/products.json")
adapter = lambda do |request|
puts "#{request.method} #{request.url}"
Envless::HttpResponse.new(
status: 200,
headers: {"content-type" => "application/json"},
body: fixture,
reason: "OK"
)
end
client = Envless::Client.new(
token: "ev_sk_test",
base_url: "https://api.test",
adapter:
)
client.products.listA bare list sends no query string at all: limit, offset, search and order are only appended when you pass them, and the API applies its own default of 25. Ask for list(limit: 25) and the call becomes GET https://api.test/products?limit=25.
The default adapter honours http_proxy, https_proxy and no_proxy, verifies certificates against the system store that OpenSSL uses, and requires TLS 1.2 or newer. The client never closes an adapter you pass in, because it is yours.
The raw escape hatch
client.raw is the underlying transport. It unwraps the response envelope, applies the same retry policy, and raises the same typed errors, so you keep every guarantee while reaching something the namespaces do not cover yet.
require "envless/sdk"
payload = Envless.client.raw.request("/products",
query: {limit: 5, order: "desc"})
puts payload| Argument | Type | Notes |
|---|---|---|
path | String | Appended to base_url. Encode your own path segments. |
method: | String or Symbol | Defaults to GET. |
query: | Hash | nil and "" are dropped rather than sent empty, and true and false go out as text. Values can be Strings, Symbols, Integers, Floats and booleans. |
body: | Hash, Array or anything JSON can represent | Serialised as JSON, with Time and Date as ISO 8601, and sets Content-Type. |
timeout: | Integer, Float or Rational | Overrides the client's timeout for this call. |
raw.request returns the data field of the envelope, parsed with Symbol keys, not the envelope. There is no way to read success, message or _req.reqId on a successful response, by design: a non-success envelope becomes an Envless::ApiError that carries the request id for you.
Reuse one client
Build one client per process and share it, or let the shared client do that for you. close closes its idle connections when you are done, and the shared client lives for the whole process.