Skip to content

PHP SDK

fractal-tess/faber-sdk is the PHP client for the Faber API. It runs on any PSR-18 HTTP client, models every schema of the API contract as readonly classes and backed enums, has a fluent builder for steps, and retries transient failures. It needs PHP 8.2 or newer.

The source lives in sdks/php.

Terminal window
composer require fractal-tess/faber-sdk guzzlehttp/guzzle

Guzzle is the suggested HTTP client: when it is installed and you do not pass a client, the SDK creates one with the configured timeout. Any other PSR-18 client with PSR-17 factories works too (for example symfony/http-client with nyholm/psr7); php-http/discovery finds it.

use Faber\Client;
$faber = new Client('http://localhost:3000', apiKey: 'your-api-key');

With no arguments, new Client() reads FABER_URL and FABER_API_KEY from the environment and falls back to http://localhost:3000. The base URL may be the service root (http://host:3000) or the API root (http://host:3000/api/v1); both work, and so does a path prefix such as https://example.com/faber.

Every constructor argument is optional; pass them by name.

ArgumentDefaultMeaning
baseUrlFABER_URL, else http://localhost:3000Service root or API root
apiKeyFABER_API_KEYSent as Authorization: Bearer <key>
timeout60.0Seconds; applied when the SDK creates the Guzzle client
retrynew RetryPolicy()When and how long to wait before retrying (see below)
headers[]Extra headers sent with every request
httpClientGuzzle if installed, else discoveredAny Psr\Http\Client\ClientInterface
requestFactory, streamFactorydiscoveredPSR-17 factories

An injected or discovered client keeps its own settings, so configure its timeout, proxy and TLS options when you create it:

use Faber\Client;
use GuzzleHttp\Client as Guzzle;
$faber = new Client(
'https://faber.internal',
apiKey: getenv('FABER_API_KEY') ?: null,
httpClient: new Guzzle(['timeout' => 120, 'proxy' => 'http://proxy.internal:8080']),
);

The client retries a request when the server answers 503 (no free execution slots, or the service is draining) or 429 (the API key is rate limited), and when the connection fails before a response arrives (Psr\Http\Client\NetworkExceptionInterface). On a 503 or 429 it waits for the Retry-After header if the server sends one (capped at maxDelay), otherwise it backs off exponentially with jitter. It never retries other 4xx responses or other 5xx responses, and with Guzzle it does not retry a request that timed out while waiting for the response, because the server may still be running it.

use Faber\RetryPolicy;
new Client(retry: new RetryPolicy(maxRetries: 5, backoff: 1.0, maxDelay: 30.0));
new Client(retry: RetryPolicy::none());

RetryPolicy is not final: extend it to change how the client sleeps (for example in tests) or computes delays.

A request is a list of steps that run in order in one sandbox workspace (/faber). A step is a single task, or a list of tasks that run in parallel. Build steps with Task::command() and StepBuilder, then pass them to execute:

use Faber\Builder\StepBuilder;
use Faber\Client;
use Faber\Enum\SandboxProfile;
use Faber\Model\Task;
$source = "#include <stdio.h>\nint main(void){int a,b;scanf(\"%d %d\",&a,&b);printf(\"%d\\n\",a+b);}\n";
$steps = StepBuilder::create()
->step(Task::command('gcc', '-O2', '-o', 'main', 'main.c')->file('main.c', $source)->cache())
->parallel(
Task::command('./main')->stdin("1 2\n")->profile(SandboxProfile::NativeV2),
Task::command('./main')->stdin("40 2\n")->profile(SandboxProfile::NativeV2),
);
$response = (new Client())->execute($steps, stopOnFailure: true);
var_dump($response->isSuccessful(), $response->cache->hits);

execute accepts:

  • a StepBuilder,
  • a list of steps, where each step is a Task, a TaskBuilder, or a list of them for a parallel step: $faber->execute([Task::command('make'), [Task::command('./a'), Task::command('./b')]]),
  • an ExecuteRequest model.

The named arguments set the request envelope and override the values of an ExecuteRequest you pass:

ArgumentMeaning
cacheDefault for tasks that do not set cache themselves. Only a leading run of cacheable steps is ever cached. Ignored when the server cache is disabled.
stopOnFailureAfter a step in which any task did not exit with status 0, report the remaining steps as skipped (NotStarted) instead of running them.

To run a single task and get its result directly, use run:

use Faber\Model\CompletedTaskResult;
$result = $faber->run(Task::command('echo', 'hello'));
if ($result instanceof CompletedTaskResult) {
echo $result->stdout;
}

The builder produces readonly model objects, which you can also create with named arguments:

use Faber\Enum\SandboxProfile;
use Faber\Model\ExecuteRequest;
use Faber\Model\FileReference;
use Faber\Model\InlineFile;
use Faber\Model\Task;
$request = new ExecuteRequest(
[
new Task('gcc', args: ['-O2', '-o', 'main', 'main.c'], cache: true, files: [
'main.c' => $source, // a plain string is UTF-8 content
'data/blob.bin' => InlineFile::binary("\x00\x01\x02"),
'run.sh' => new InlineFile("#!/bin/sh\nexec ./main\n", executable: true),
'input.txt' => new FileReference('9f86d081884c7d65...'),
]),
[
new Task('./main', stdin: "1 2\n", sandboxProfile: SandboxProfile::NativeV2),
new Task('./main', stdin: "40 2\n", sandboxProfile: SandboxProfile::NativeV2),
],
],
stopOnFailure: true,
);
$response = $faber->execute($request);

Every model has a static fromArray() that decodes the API’s JSON (as returned by json_decode($json, true)) and implements JsonSerializable.

Task::command($cmd, ...$args) starts a TaskBuilder. Each method sets one field and returns the builder; build() returns the Task. Builders are accepted anywhere a task is, so you rarely call build() yourself.

MethodEffect
->args(...$args)Append command-line arguments
->env('NAME', 'value') or ->env([...])Set environment variables
->stdin($text)Text written to standard input, which is then closed
->file($path, $text, executable: false)Create a workspace file from text; content that is not valid UTF-8 is sent base64-encoded automatically
->binaryFile($path, $bytes, executable: false)Create a workspace file from bytes, always base64-encoded
->fileRef($path, $file, executable: false)Create a workspace file from an uploaded file: its id, an UploadResponse or a FileInfo
->files([...])Add several files (string, InlineFile or FileReference values)
->workingDir($path)Absolute directory inside the sandbox to start in (default /faber)
->profile($profile)Sandbox profile: a SandboxProfile case or a string such as 'native_v2'
->cache(true)Declare the task deterministic so its step may be cached; cache(false) opts out

File paths are relative to the workspace, without .. or a leading /.

StepBuilder collects the steps:

MethodEffect
StepBuilder::create()A new, empty builder
->step($task)Add a step that runs one task
->parallel(...$tasks)Add a step whose tasks run concurrently, each in its own PID namespace and cgroup
->build()The list of steps
->toRequest(cache: null, stopOnFailure: null)An ExecuteRequest with these steps

StepBuilder is also Countable and iterable.

ExecuteResponse::$steps mirrors the request: step $i of the response is a single TaskResult when step $i was a task, and a list of them (in the same order) when it was a parallel step. ExecuteResponse::$cache reports whether the server cache is enabled, how many steps were replayed (hits) and how many were stored.

TaskResult is an abstract class with two final subclasses:

  • CompletedTaskResult: the process ran. Properties stdout, stderr, exitCode (128 + signal number when killed by a signal), stats and cached.
  • FailedTaskResult: the task never reached an exit status (setup failure, command not found, or skipped by stopOnFailure). Properties error, stats and cached.

Both have isSuccessful() (exited with status 0) and outcome() (shortcut for $stats->outcome):

use Faber\Enum\TaskOutcome;
use Faber\Model\CompletedTaskResult;
use Faber\Model\FailedTaskResult;
foreach ($response->taskResults() as $result) {
echo match (true) {
$result instanceof CompletedTaskResult && $result->isSuccessful() => 'ok: ' . trim($result->stdout),
$result->outcome() === TaskOutcome::TimedOut => 'too slow',
$result instanceof CompletedTaskResult => "failed: exit {$result->exitCode}",
$result instanceof FailedTaskResult => "did not run: {$result->error}",
default => 'unknown',
}, "\n";
}

Helpers on the response:

HelperMeaning
$response->isSuccessful()Every task exited with status 0
$response->taskResults()Every task result in request order, parallel steps flattened
$response->firstFailure()The first result that did not exit with status 0, or null

TaskStats holds memoryPeakBytes, cpuUsageUsec, pidsPeak, executionTimeMs, stdoutTruncated, stderrTruncated, outcome, terminationSignal and the optional cgroup counters. Cached results carry the stats of the run that was stored. The outcome tells you how the task ended:

TaskOutcomeMeaning
ExitedThe process exited on its own; check exitCode
SignaledEnded by a signal that none of the limits below explains
TimedOutExceeded the wall-clock or CPU time limit
OutOfMemory / AncestorOutOfMemoryKilled by the cgroup memory limit
PidsLimitHit the process count limit
OutputLimitWrote more output than allowed
PolicyViolationMade a system call its sandbox profile denies (SIGSYS)
NotStartedSkipped because an earlier step failed
InfrastructureFailureThe sandbox could not run the task
UnknownAn outcome newer than this SDK; see $stats->rawOutcome

Uploaded files are stored under the SHA-256 of their content, so uploading the same bytes twice returns the same id with alreadyExists set. Reference a stored file from any task instead of sending its content again.

$uploaded = $faber->uploadFile("42\n7\n19\n", 'numbers.txt', 'text/plain');
$faber->uploadPath('data/large.csv'); // the file name is sent too
$faber->uploadStream(fopen('model.bin', 'rb'), 'model.bin'); // a resource or a PSR-7 stream
$result = $faber->run(Task::command('sort', '-n', 'numbers.txt')->fileRef('numbers.txt', $uploaded));
foreach ($faber->listFiles() as $info) { // list<FileInfo>
echo $info->id, ' ', $info->size, ' ', $info->filename ?? '-', ' ', $info->createdAt->format(DATE_RFC3339), "\n";
}
$bytes = $faber->downloadFile($uploaded); // string
$faber->deleteFile($uploaded);

The file methods take a file id, an UploadResponse or a FileInfo. Uploads are sent as one buffered multipart body so they can be retried. Referencing an id that is not stored makes execute fail with 404 and ErrorCode::FileNotFound.

MethodRouteReturns
health()GET /healthHealthResponse with status and version. A draining service returns HealthStatus::ShuttingDown instead of throwing. No API key needed.
info()GET /infoServiceInfo: version, architecture, allowed and default sandbox profiles, every effective limit, cache settings and installed toolchains ($info->toolchain('python'))
clearCache()DELETE /cacheCacheClearResponse with the number of layers removed. Throws CacheDisabled when the cache is off.
metrics()GET /metricsThe Prometheus text exposition, as a string

Every failed request throws Faber\FaberException (a RuntimeException):

PropertyMeaning
statusHTTP status, or null when no response arrived (also the exception code)
errorCodeErrorCode from the error body (Unknown for codes newer than the SDK), or null
rawErrorCodeThe code exactly as the server sent it
requestIdThe x-request-id response header; quote it when reading server logs
getMessage()The server’s message

For transport failures the PSR-18 exception is getPrevious(). Invalid input detected before sending (an empty request, text that cannot be encoded as JSON) throws InvalidArgumentException.

use Faber\Enum\ErrorCode;
use Faber\FaberException;
try {
$response = $faber->execute($steps);
} catch (FaberException $e) {
match ($e->errorCode) {
ErrorCode::CapacityExhausted,
ErrorCode::RateLimited => retryLater(), // still busy or rate limited after the automatic retries
ErrorCode::ProfileNotAllowed => useAnotherProfile(),
default => throw $e,
};
}
StatusErrorCode
400InvalidRequest
401Unauthorized
404NotFound, FileNotFound
409CacheDisabled
413PayloadTooLarge
422LimitsExceeded, ProfileNotAllowed
429RateLimited (retried automatically)
500SandboxSetupFailed, InternalError
503CapacityExhausted, ShuttingDown, Cancelled (retried automatically)
504DeadlineExceeded

A task that fails is not an exception. Non-zero exits, timeouts, OOM kills and policy violations come back in the task’s result with 200 OK.

Enums decoded from responses (TaskOutcome, ErrorCode, SandboxProfile, HealthStatus, CacheBackend) have an Unknown case: a value added by a newer server maps to it instead of breaking the response, and isKnown() returns false. TaskStats::$rawOutcome and FaberException::$rawErrorCode keep the original strings. Tasks accept a string profile for profiles newer than the SDK. Unknown JSON fields are ignored.

The examples/ directory has runnable scripts. Each reads FABER_URL and FABER_API_KEY:

Terminal window
cd sdks/php
composer install
export FABER_URL=http://localhost:3000 FABER_API_KEY=your-api-key
php examples/hello_world.php # run one command
php examples/compile_c.php # compile C, run it with input on stdin
php examples/cached_test_cases.php # compile once (cached), run test cases in parallel, twice
php examples/upload_file.php # upload a file and use it as a task input
php examples/errors_and_limits.php # FaberException vs timeout, OOM and policy violation outcomes

The C examples need gcc in the Faber image (the default vgfractal/faber image has it). The cache example shows hits on its second run only when the server runs with CACHE_ENABLED=true.