Solari

Browsers

solari-browser (library solari_browser) is the control plane: Client, the sessions / profiles / proxy namespaces, and the optional connect() helper. See the Rust SDK hub for install and configuration.

solari-browser = "0.1"                          # control plane
solari-browser = { version = "0.1", features = ["connect"] }  # + connect()
Published on crates.io
solari-browser is published to crates.io (source in the solari-sdk GitHub org).

Contents

Client

The browser client. Creates sessions and manages profiles.

Constructors

Client::new()

fn new(opts: ClientOptions) -> Result<Client, SolariError>

Creates a client. Does not open any connection.

Parameters:

Returns: Result<Client, SolariError>

Errors: SolariError::Config if api_key or base_url is empty, or the HTTP client fails to build.

Example:

use solari_browser::{Client, ClientOptions};

let client = Client::new(ClientOptions::default_region(&api_key))?;

// Or point at a staging / self-hosted gateway:
let client = Client::new(ClientOptions::new(&api_key, "https://gw.example.com"))?;
Debug never prints the API key
Debug is hand-written on ClientOptions and on the internal HttpTransport, so the key renders as "<redacted>". Client derives Debug, but its only field is that transport, so logging a client is safe too; the secret never reaches your logs.

Methods

base_url()

fn base_url(&self) -> &str

The resolved API base URL, with any trailing slash stripped.

Returns: &str

Example:

assert_eq!(client.base_url(), "https://api.getsolari.com");

Sessions

The session namespace. Obtain with client.sessions().

create()

async fn create(&self, options: CreateSessionOptions) -> Result<Session, SolariError>

Acquires a browser session (POST /sessions). Unset options are omitted from the body entirely.

Parameters:

Returns: Result<Session, SolariError>

Errors: SolariError::Api carrying status and the server code (e.g. FeatureRequiresPlan, ConcurrencyLimitExceeded); SolariError::Protocol if the response has no sessionId or wsEndpoint.

Example:

use solari_browser::{CreateSessionOptions, ProxySpec};

let session = client
    .sessions()
    .create(CreateSessionOptions::new().stealth(true).proxy(ProxySpec::country("us")))
    .await?;

println!("{} expires {}", session.id, session.expires_at);
captcha and proxy require stealth
.captcha(true) and any .proxy(…) request are rejected by the API unless .stealth(true) is also set.

get()

async fn get(&self, id: &str) -> Result<SessionView, SolariError>

Fetches a session’s current view (GET /sessions/:id). Every field is optional; unknown keys land in SessionView::extra.

Parameters:

  • id &str: session id.

Returns: Result<SessionView, SolariError>

Errors: SolariError::Api on a non-2xx status; SolariError::Protocol if the body is not JSON.

Example:

let view = client.sessions().get(&session.id).await?;
println!("{:?} {:?}", view.status, view.expires_at);
This route is dead upstream
GET /sessions/:id currently 404s on the production gateway. The pool does not serve it. The method and SessionView exist so the crate is ready if the route returns; that is also why every field is Option and unknown keys are preserved. Track a session’s deadline with Session::expires_at instead.

release()

async fn release(&self, id: &str) -> Result<(), SolariError>

Releases a session and waits for confirmation (DELETE /sessions/:id). A 404 is treated as success.

Parameters:

  • id &str: session id.

Returns: Result<(), SolariError>

Errors: SolariError::Api on any non-404 error status.

Example:

client.sessions().release(&session.id).await?;
// the replay is available ~1-3s later
No fire-and-forget variant
Unlike the TypeScript SDK there is no release() / releaseAndWait() split. This method is the equivalent of releaseAndWait. tokio::spawn it yourself if you do not want to await the round-trip.

replay_url()

async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError>

Presigned URL for a recorded session’s replay. Requires .recording(true) at create time; available ~1 to 3s after release().

Parameters:

  • id &str: session id.

Returns: Result<ReplayUrl, SolariError> with url, expires_in_seconds (0 when omitted), content_encoding ("gzip" when omitted).

Errors: SolariError::Api if the replay is not ready; SolariError::Protocol if the body carries no url.

Example:

let replay = client.sessions().replay_url(&session.id).await?;
println!("{} ({}s)", replay.url, replay.expires_in_seconds);

download_replay()

async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError>

Resolves the replay URL and downloads the bytes in one call. The bytes are still content_encoding-encoded (gzipped NDJSON by default).

Parameters:

  • id &str: session id.

Returns: Result<Vec<u8>, SolariError>

Errors: everything replay_url() raises, plus SolariError::Api if the download itself is non-2xx.

Example:

let bytes = client.sessions().download_replay(&session.id).await?;
std::fs::write("replay.ndjson.gz", bytes)?;

Profiles

Stored browser profiles (cookies + localStorage). Obtain with client.profiles().

list()

async fn list(&self) -> Result<Vec<Profile>, SolariError>

Lists every profile on the account.

Returns: Result<Vec<Profile>, SolariError>

Errors: SolariError::Api on a non-2xx status; SolariError::Protocol on a malformed body.

Example:

for p in client.profiles().list().await? {
    println!("{} {}", p.id, p.name);
}

create()

async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError>

Creates an empty profile. Attach it to a session with .profile_id(…) to persist cookies and localStorage.

Parameters:

  • name impl Into<String>: human-facing name.

Returns: Result<Profile, SolariError>

Errors: SolariError::Api with the server code on failure.

Example:

let profile = client.profiles().create("logged-in").await?;
let session = client
    .sessions()
    .create(CreateSessionOptions::new().profile_id(&profile.id))
    .await?;

save()

async fn save(
    &self,
    id: &str,
    storage_state: &StorageState,
) -> Result<ProfileSaveResult, SolariError>

Overwrites a profile with the given storage state (POST /profiles/:id/save).

Parameters:

  • id &str: profile id.
  • storage_state &StorageState: cookies + origins.

Returns: Result<ProfileSaveResult, SolariError> with version and size_bytes, each 0 when the API omits them.

Errors: SolariError::Api on a non-2xx response.

Example:

use solari_browser::{Cookie, SameSite, StorageState};

let state = StorageState {
    cookies: Some(vec![Cookie {
        name: "sid".into(),
        value: "abc".into(),
        domain: Some(".example.com".into()),
        same_site: Some(SameSite::Lax),
        ..Default::default()
    }]),
    ..Default::default()
};

let saved = client.profiles().save(&profile.id, &state).await?;
println!("v{} ({} bytes)", saved.version, saved.size_bytes);

delete()

async fn delete(&self, id: &str) -> Result<(), SolariError>

Deletes a profile. Idempotent, so a 404 resolves successfully.

Parameters:

  • id &str: profile id.

Returns: Result<(), SolariError>

Errors: SolariError::Api on any non-404 error status.

Example:

client.profiles().delete(&profile.id).await?;

Proxy

Managed proxy metadata. Obtain with client.proxy().

countries()

async fn countries(&self) -> Result<ProxyCountries, SolariError>

Supported egress countries, and whether managed proxy egress is configured on this gateway at all.

Returns: Result<ProxyCountries, SolariError> with enabled, countries.

Errors: SolariError::Api on a non-2xx status.

Example:

let res = client.proxy().countries().await?;
if !res.enabled {
    // sending a proxy spec would fail on this gateway
}
println!("{:?}", res.countries); // ["br", "de", "gb", "us"]

ConnectedBrowser

A chromiumoxide Browser attached to a session over CDP, plus the spawned task pumping its event handler. Behind the connect feature.

Fields

  • browser chromiumoxide::Browser: the attached browser. Drive it with the usual chromiumoxide API.
  • handler JoinHandle<()>: the handler pump. It must keep running for as long as the browser is used. Every browser call resolves through it.
Raw CDP skips input humanization
The pool humanizes mouse and keyboard input on the Playwright (ws_endpoint) path only. Attaching over raw CDP (which is what Rust does) bypasses that layer, so clicks are single-shot teleport-and-press. That is exactly the tell interaction-triggered anti-bots score. Expect lower pass rates on behaviourally-gated sites than the TypeScript SDK gets.

Functions

connect()

async fn connect(session: &Session) -> Result<ConnectedBrowser, SolariError>

Attaches to session.cdp_endpoint and spawns the handler pump. A free function on the crate root, not a method.

Parameters:

  • session &Session: the acquired session.

Returns: Result<ConnectedBrowser, SolariError>

Errors: SolariError::Connect if the CDP attach fails.

Example:

let session = client
    .sessions()
    .create(CreateSessionOptions::new().stealth(true))
    .await?;

let connected = solari_browser::connect(&session).await?;
let page = connected.browser.new_page("https://example.com").await?;
println!("{:?}", page.url().await?);

connected.disconnect().await;
client.sessions().release(&session.id).await?;
connect() needs a TLS feature Cargo won't add for you
Every Solari CDP endpoint is wss://, but chromiumoxide 0.7’s tokio-runtime feature only turns on its WebSocket dependency’s tokio-runtime feature — no TLS backend. With just features = ["connect"] and nothing else, connect() fails with "TLS support not compiled in" (confirmed against a live session). Add a direct dependency on the same async-tungstenite version with a TLS feature (e.g. async-tungstenite = { version = "0.27", features = ["tokio-native-tls"] }) so Cargo unifies it into the shared build.

connect_endpoint()

async fn connect_endpoint(cdp_endpoint: &str) -> Result<ConnectedBrowser, SolariError>

Attaches to an explicit CDP endpoint. Use it when you persisted a session’s cdp_endpoint and no longer hold the Session.

Parameters:

  • cdp_endpoint &str: the upstream CDP URL.

Returns: Result<ConnectedBrowser, SolariError>

Errors: SolariError::Connect if the attach fails.

Example:

let connected = solari_browser::connect_endpoint(&saved_cdp_url).await?;

into_parts()

fn into_parts(self) -> (Browser, JoinHandle<()>)

Splits into the browser and its handler task, when you want to own the two separately.

Returns: (chromiumoxide::Browser, JoinHandle<()>)

Example:

let (browser, handler) = connected.into_parts();
// … drive the browser; the handler ends on its own once it is dropped
drop(browser);
let _ = handler.await;

disconnect()

async fn disconnect(self)

Drops the browser (ending the CDP connection) and awaits the handler. Does not release the Solari session.

Returns: ()

Example:

connected.disconnect().await;                  // closes the connection
client.sessions().release(&session.id).await?; // frees the slot

Types

ClientOptions

Three constructors, then chainable builders. Reads no environment variables.

ClientOptions::new(api_key, base_url)        // explicit gateway
ClientOptions::for_region(api_key, region)   // a region's public API
ClientOptions::default_region(api_key)       // SolariRegion::UsWest

ClientOptions::default_region(&api_key)
    .max_attempts(3)
    .backoff_ms(250)
    .timeout_ms(30_000)
  • api_key String: required; empty is rejected.
  • base_url String: required; trailing slash stripped.
  • max_attempts u32: default 2 (one retry). Clamped to a minimum of 1.
  • backoff_ms u64: default 500, fixed.
  • timeout_ms u64: default 90_000, per attempt.
429 needs caller-side backoff
The transport retries only 502, 503, 504 and transport errors, with a fixed pause, not exponential backoff. Every other non-2xx is returned immediately. ConcurrencyLimitExceeded (429) is not retried. Match on SolariError::Api and back off yourself.

CreateSessionOptions

Builder-style. Falsy fields are omitted from the request body; if every field is falsy, no body is sent at all.

CreateSessionOptions::new()
    .stealth(true)
    .captcha(true)
    .recording(true)
    .web_bot_auth(true)
    .profile_id("p_123")
    .proxy(ProxySpec::country("us"))
  • profile_id Option<String>: attach a stored profile. Empty strings are omitted.
  • recording bool: record the session. Off by default.
  • stealth bool: enable the runtime stealth shim. Off by default.
  • captcha bool: managed captcha solving. Requires stealth.
  • web_bot_auth bool: sign outbound requests for Cloudflare Web Bot Auth. Independent of stealth; inert unless Web Bot Auth is enabled for your account.
  • proxy Option<ProxySpec>: managed egress. Requires stealth.

Session

  • id String: session id.
  • ws_endpoint String: Playwright wire-protocol endpoint.
  • cdp_endpoint String: raw CDP endpoint, the one to use from Rust. Derived from ws_endpoint (/ws//cdp/) when the API omits it.
  • expires_at String: ISO 8601 UTC deadline; the session auto-releases then. Falls back to ~1h from now when the API omits it.
  • storage_state Option<Option<StorageState>>: tri-state; see below.
  • proxy Option<ResolvedProxyConfig>: present only when a managed proxy was requested.
Endpoints are UPSTREAM URLs
The TypeScript SDK routes wsEndpoint / cdpEndpoint through a Node-side loopback proxy. This crate has no such indirection: both fields are the upstream gateway endpoints, reachable from anywhere with the session still live. You can persist them and hand them to another machine.

storage_state is a serde double-option, mirroring the TypeScript storageState?: StorageState | null. It is only populated when a profile was requested:

match &session.storage_state {
    None              => {} // no profile attached to this session
    Some(None)        => {} // profile attached, but it has no saved state yet
    Some(Some(state)) => {  // the profile's state
        println!("{} cookies", state.cookies.as_ref().map_or(0, |c| c.len()));
    }
}

SessionView

Returned by get(). Every field is optional because the API proxies the pool’s view through verbatim.

  • session_id, status, expires_at Option<String>.
  • extra Map<String, Value>: #[serde(flatten)]; every key the crate does not model survives here rather than being dropped.

StorageState

  • cookies Option<Vec<Cookie>>: each Cookie has name, value, and optional domain, path, expires, http_only, secure, same_site.
  • origins Option<Vec<StorageOrigin>>: each has origin and optional local_storage (Vec<LocalStorageEntry> of name/value).
  • extra Map<String, Value>: flattened unknown keys.
  • SameSite: Strict, Lax, None.

ProxySpec

The proxy field of a create request. Mirrors the TypeScript union string | ProxyRequest | "off" | "smart".

ProxySpec::country("us")  // proxy: "us"
ProxySpec::off()          // proxy: "off"
ProxySpec::smart()        // proxy: "smart", gateway sweeps strategies on block
ProxySpec::from(ProxyRequest::country("us").tier(ProxyTier::Static))
  • Shorthand(String): a country code, or the literals "off" / "smart".
  • Request(ProxyRequest): a full egress request. From<ProxyRequest> is implemented, so .proxy(req) takes either directly.

ProxyRequest

Builder-style; unset fields are omitted from the wire.

ProxyRequest::country("us")
    .tier(ProxyTier::Mobile)
    .asn("21928")
    .session("warm-1")
    .session_duration(15)
  • country Option<String>: ISO-3166-1 alpha-2, lowercase. Defaults to "us" server-side.
  • tier Option<ProxyTier>: Residential (default, rotating), Static (fixed ISP IP), Mobile (carrier IPs).
  • asn Option<String>: pin egress to an ASN, e.g. "20057" for AT&T Mobility.
  • session Option<String>: sticky-session id (alnum + dash, ≤32 chars). Pins the egress IP.
  • session_duration Option<u32>: sticky lifetime in minutes (1 to 30, default 10). Only with session.
  • state, city Option<String>: US-only geo narrowing, e.g. "california" / "los_angeles".

ResolvedProxyConfig

  • server, username, password String: the resolved proxy and its credentials.
  • timezone_id String: timezone matching the egress IP.
  • country String, tier Option<ProxyTier>: what the gateway actually resolved.

ProxyCountries

  • enabled bool: false when managed proxy is unavailable; sending a proxy spec will then fail.
  • countries Vec<String>: supported lowercase alpha-2 codes.

Profile / ProfileSaveResult

  • Profile: id String, name String, extra Map<String, Value> (flattened unknown keys).
  • ProfileSaveResult: version u64, size_bytes u64. Both 0 when the API omits them.

ReplayUrl

  • url String: presigned download URL.
  • expires_in_seconds u64: URL lifetime; 0 when omitted.
  • content_encoding String: "gzip" when omitted.

SolariRegion

  • UsWest (default): resolves to https://api.getsolari.com. The only region today; read it with region.base_url().

SolariError

Every failure the crate raises. Derives thiserror::Error.

VariantFieldsMeaning
Apistatus, code, messageA non-2xx response from the API.
TransportmessageNetwork failure, or retries exhausted.
ProtocolmessageA 2xx body missing required fields, or not JSON.
ConfigmessageThe client was constructed with invalid options.
ConnectmessageCDP attach failed. Only with the connect feature.

Two accessors read across variants: .status() Option<u16> and .code() Option<&SolariErrorCode>, both None for non-Api variants.

SolariErrorCode

Parsed out of the API’s { "code": … } error body. Unknown codes are preserved in Other(String) rather than dropped, so the wire can grow new codes without breaking the crate.

  • FeatureRequiresPlan: the plan does not include the feature (e.g. captcha).
  • ConcurrencyLimitExceeded: at the concurrent-session cap.
  • PlanLimitExceeded: a plan quota is exhausted.
  • BrowserUnhealthy: the acquired browser failed its health check.
  • Other(String): a code this crate does not know.
use solari_browser::{SolariError, SolariErrorCode};

match client.sessions().create(opts).await {
    Ok(session) => { /* … */ }
    Err(e) if e.code() == Some(&SolariErrorCode::FeatureRequiresPlan) => {
        eprintln!("upgrade required: {:?}", e.status());
    }
    Err(SolariError::Transport { message }) => eprintln!("retry later: {message}"),
    Err(e) => return Err(e),
}