Skip to content

RemoteLabClient Reference

The lower-level HTTP client the pytest fixture wraps. Use it directly from scripts, notebooks, or any non-pytest context.

RemoteLabClient is the Python interface to the Remote Lab Manager HTTP API. Use it directly when you need to drive a lab from a script, a notebook, or any non-pytest context.

Inside tests, use remote_lab_fixture instead — the fixture wraps this client, adds lifecycle hooks, and is the stable contract consumed by the Worker SDK.


Import

from neops_remote_lab.client import RemoteLabClient

Constructor

RemoteLabClient(
    base_url: str,
    request_timeout: int = 30,
    session_timeout: int = 600,
    lab_acquisition_timeout: int = 600,
)

Arguments

Name Type Default Description
base_url str Base URL of the Remote Lab Manager (e.g. http://lab.internal:8000). If falsy, falls back to the REMOTE_LAB_URL environment variable.
request_timeout int 30 Per-HTTP-request timeout in seconds for short operations (create session, release, heartbeat).
session_timeout int 600 Max seconds the constructor will wait for the session to reach ACTIVE state in the FIFO queue.
lab_acquisition_timeout int 600 Max seconds for a single acquire() POST. Netlab up can take minutes on cold caches, so this is deliberately long.

Raises

  • ValueError when base_url is falsy AND REMOTE_LAB_URL is unset.
  • TimeoutError when the session does not become ACTIVE within session_timeout seconds.

Side effects at construction time

The constructor is not lazy. In order:

  1. An HTTP session is created with a retry adapter for 429/5xx responses.
  2. A new server session is created via POST /session and the returned session_id is stored on the X-Session-ID header of all subsequent requests.
  3. The constructor blocks, polling GET /session/{id} every 5 seconds until the session reaches ACTIVE (or session_timeout expires).

If the server is unreachable, the constructor will retry (see Retry behavior) and ultimately raise the final requests exception.


Session lifecycle

The client follows a strict lifecycle. All /lab/* calls are rejected by the server with 423 Locked unless the session is ACTIVE, which the constructor guarantees on return.

%%{init: {'theme': 'neutral'}}%%
stateDiagram-v2
    [*] --> WAITING: __init__
    WAITING --> ACTIVE: promoted
    ACTIVE --> HOLDING_LAB: acquire()
    HOLDING_LAB --> ACTIVE: release()
    ACTIVE --> [*]: close()
    HOLDING_LAB --> [*]: close() (forces teardown)
    ACTIVE --> ACTIVE: heartbeat (implicit)

Client states; the constructor blocks until ACTIVE; acquire/release flip into HOLDING_LAB.

The session is kept alive by server-side timers: if no request touches the session for 300 seconds the server marks it stale, tears down any held lab, and promotes the next waiter. Call acquire/release/destroy to reset the heartbeat clock; there is no dedicated heartbeat method on the client — neops-remote-lab server supports POST /session/heartbeat, but the Python client does not expose it, and the pytest fixture does not need it because the test’s own acquire/release traffic keeps the session warm.


Public methods

acquire(topology, reuse) -> list[DeviceInfoDto]

Upload a topology and block until the server has a running lab for you.

def acquire(
    topology: pathlib.Path,
    reuse: bool,
) -> list[DeviceInfoDto]
Argument Type Description
topology pathlib.Path Path to a Netlab .yml file. The client opens the file and posts it as multipart form data.
reuse bool When True, increments the reference count on an already-running lab with the same SHA-256 content identity; when False, refuses to start if another lab is already up.

Returns a list of DeviceInfoDto describing the running devices. Each DTO carries .name (from Netlab) and .raw — the full netlab inspect dictionary for that node.

Raises the underlying requests.exceptions.RequestException on transport errors and the standard HTTPError subclasses on non-retriable 4xx responses.

423 polling. If the server returns 423 Locked (another session holds the host), acquire() sleeps 5 seconds and retries without user intervention. There is no retry cap — the loop is bounded only by lab_acquisition_timeout applied to each individual POST.

reuse and SHA identity

Topologies are identified by the SHA-256 of their file content, not by filename. Two files with different names but identical bytes share one lab when reuse=True; two files with the same name but different content are separate labs. If you edit a topology file between tests, you are booting a new lab.

release() -> None

Decrement the server-side reference count on the held lab. When the count reaches zero the server tears the lab down.

def release() -> None

release() is tolerant of races — a 404 Not Found response (meaning the lab was already torn down by another path) is treated as success and logged at INFO without re-raising.

release() is best-effort — it never raises

The request is wrapped in a try: ... except Exception as e: _log.error(...) block, so any HTTPError that raise_for_status() would have raised (5xx, unexpected 4xx, transport failure) is swallowed and logged at ERROR, not propagated. If you need to detect a failed release, grep the logs for Failed to release lab: — there is no exception to catch.

destroy() follows the same swallow-and-log pattern, so do not design retry or alerting logic around exceptions from it either.

destroy(force=True) -> None

Force teardown of the held lab regardless of reference count.

def destroy(force: bool = True) -> None

The server returns 202 Accepted when it starts an async teardown or 204 No Content when there was nothing to do; both are treated as success.

destroy bypasses reuse semantics

If another active session is also using a reuse=True lab, destroy() will still tear it down and those other sessions will start seeing 423 Locked or acquire errors. Use release() unless you have a specific reason — a stuck lab, a test runner wind-down — to force teardown.

close() -> None

End the session. This is the last call you make on a client instance.

def close() -> None

close() is idempotent: after the first call the internal session_id is cleared, so repeated calls log a debug line and return without contacting the server.

Network errors during close are logged as warnings but not raised — the working assumption is that if the server is unreachable, your session will be reaped by the server’s stale-session sweeper anyway.

session_id: str

Read-only attribute. Populated by the constructor to the server-assigned UUID; cleared to "" by close(). Send it as the X-Session-ID header on any manual requests calls you make outside the client (for example, if you want to hit an endpoint the client does not wrap).


Context manager support

RemoteLabClient does not implement __enter__ / __exit__. Wrap it in your own try/finally or a small contextmanager:

A context manager wrapper you can copy
"""A context-manager wrapper around RemoteLabClient.

`RemoteLabClient` does not implement `__enter__` / `__exit__`; this wrapper
ensures `close()` runs on the way out, even when the lab body raises.
"""

import contextlib
from collections.abc import Iterator

from neops_remote_lab.client import RemoteLabClient


@contextlib.contextmanager
def remote_lab_client(**kwargs) -> Iterator[RemoteLabClient]:
    client = RemoteLabClient(**kwargs)
    try:
        yield client
    finally:
        client.close()

Using this wrapper makes a script resilient to exceptions between acquire and release:

scripts/collect_config.py
import os
import pathlib

with remote_lab_client(base_url=os.environ["REMOTE_LAB_URL"]) as client:
    devices = client.acquire(
        pathlib.Path("topologies/demo.yml"),
        reuse=False,
    )
    for d in devices:
        print(d.name)
    client.release()

Retry behavior

The underlying requests.Session is configured with a urllib3.util.Retry adapter. The policy is: three retries total, exponential backoff with backoff_factor=1, and the retry list covers 429, 500, 502, 503, 504. The retry applies to the HEAD, GET, OPTIONS, DELETE methods only — POST is not retried because it is not idempotent on this API.

Implication: acquire() (which POSTs) has its own explicit polling loop for 423 Locked — the Retry adapter does not help there. Transient 5xx on acquire will surface as an exception on the first attempt.

Timeouts vs retries

Each HTTP call still respects request_timeout (or lab_acquisition_timeout for /lab). The Retry adapter does not extend those — it issues a fresh request per retry, each subject to the normal timeout.


End-to-end example

examples/scripts/smoke.py
"""Smoke test for `RemoteLabClient` — acquire a lab, list devices, release.

Usage:
    export REMOTE_LAB_URL=http://lab.example.com:8000
    python examples/scripts/smoke.py path/to/topology.yml
"""

import os
import pathlib
import sys

from neops_remote_lab.client import RemoteLabClient


def main(topology_path: str) -> None:
    client = RemoteLabClient(
        base_url=os.environ["REMOTE_LAB_URL"],
        session_timeout=120,  # fail fast if queue is deep
    )
    try:
        devices = client.acquire(
            topology=pathlib.Path(topology_path),
            reuse=False,
        )
        print(f"Acquired lab with {len(devices)} devices:")
        for d in devices:
            print(f"  {d.name}")
        client.release()
    finally:
        client.close()


if __name__ == "__main__":
    if len(sys.argv) != 2:
        sys.exit("Usage: smoke.py <topology.yml>")
    main(sys.argv[1])

Run with:

export REMOTE_LAB_URL="http://$LAB_HOST:8000"
python scripts/smoke.py

Expected output

Acquired lab with 2 devices:
  r1  10.x.y.z
  r2  10.x.y.z

Configuration

Each constructor argument falls back to a corresponding environment variable:

Constructor arg Falls back to
base_url REMOTE_LAB_URL
request_timeout REMOTE_LAB_REQUEST_TIMEOUT
session_timeout REMOTE_LAB_SESSION_TIMEOUT
lab_acquisition_timeout REMOTE_LAB_ACQUISITION_TIMEOUT

Constructor kwargs win when set

Instantiating RemoteLabClient directly? The kwargs you pass override the environment variables. The env vars only take precedence through the pytest fixture path. See Client config for the full reference.

See also

  • Pytest fixtures — the preferred interface for test code.
  • Client config — environment variables that drive the constructor’s defaults via the fixture.
  • Session queue — the FIFO model that _wait_for_active_session polls.
  • Lab lifecycle — reference counting, SHA identity, reuse semantics.
  • REST API — every endpoint the client wraps, plus a few it doesn’t.