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.
Install
Section titled “Install”composer require fractal-tess/faber-sdk guzzlehttp/guzzleGuzzle 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.
Configure
Section titled “Configure”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.
| Argument | Default | Meaning |
|---|---|---|
baseUrl | FABER_URL, else http://localhost:3000 | Service root or API root |
apiKey | FABER_API_KEY | Sent as Authorization: Bearer <key> |
timeout | 60.0 | Seconds; applied when the SDK creates the Guzzle client |
retry | new RetryPolicy() | When and how long to wait before retrying (see below) |
headers | [] | Extra headers sent with every request |
httpClient | Guzzle if installed, else discovered | Any Psr\Http\Client\ClientInterface |
requestFactory, streamFactory | discovered | PSR-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']),);Retries
Section titled “Retries”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.
Run code
Section titled “Run code”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, aTaskBuilder, or a list of them for a parallel step:$faber->execute([Task::command('make'), [Task::command('./a'), Task::command('./b')]]), - an
ExecuteRequestmodel.
The named arguments set the request envelope and override the values of an
ExecuteRequest you pass:
| Argument | Meaning |
|---|---|
cache | Default 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. |
stopOnFailure | After 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;}Models instead of the builder
Section titled “Models instead of the builder”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.
The step builder
Section titled “The step builder”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.
| Method | Effect |
|---|---|
->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:
| Method | Effect |
|---|---|
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.
Read results
Section titled “Read results”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. Propertiesstdout,stderr,exitCode(128 + signal number when killed by a signal),statsandcached.FailedTaskResult: the task never reached an exit status (setup failure, command not found, or skipped bystopOnFailure). Propertieserror,statsandcached.
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:
| Helper | Meaning |
|---|---|
$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:
TaskOutcome | Meaning |
|---|---|
Exited | The process exited on its own; check exitCode |
Signaled | Ended by a signal that none of the limits below explains |
TimedOut | Exceeded the wall-clock or CPU time limit |
OutOfMemory / AncestorOutOfMemory | Killed by the cgroup memory limit |
PidsLimit | Hit the process count limit |
OutputLimit | Wrote more output than allowed |
PolicyViolation | Made a system call its sandbox profile denies (SIGSYS) |
NotStarted | Skipped because an earlier step failed |
InfrastructureFailure | The sandbox could not run the task |
Unknown | An 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.
Service operations
Section titled “Service operations”| Method | Route | Returns |
|---|---|---|
health() | GET /health | HealthResponse with status and version. A draining service returns HealthStatus::ShuttingDown instead of throwing. No API key needed. |
info() | GET /info | ServiceInfo: version, architecture, allowed and default sandbox profiles, every effective limit, cache settings and installed toolchains ($info->toolchain('python')) |
clearCache() | DELETE /cache | CacheClearResponse with the number of layers removed. Throws CacheDisabled when the cache is off. |
metrics() | GET /metrics | The Prometheus text exposition, as a string |
Errors
Section titled “Errors”Every failed request throws Faber\FaberException (a RuntimeException):
| Property | Meaning |
|---|---|
status | HTTP status, or null when no response arrived (also the exception code) |
errorCode | ErrorCode from the error body (Unknown for codes newer than the SDK), or null |
rawErrorCode | The code exactly as the server sent it |
requestId | The 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, };}| Status | ErrorCode |
|---|---|
| 400 | InvalidRequest |
| 401 | Unauthorized |
| 404 | NotFound, FileNotFound |
| 409 | CacheDisabled |
| 413 | PayloadTooLarge |
| 422 | LimitsExceeded, ProfileNotAllowed |
| 429 | RateLimited (retried automatically) |
| 500 | SandboxSetupFailed, InternalError |
| 503 | CapacityExhausted, ShuttingDown, Cancelled (retried automatically) |
| 504 | DeadlineExceeded |
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.
Forward compatibility
Section titled “Forward compatibility”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.
Examples
Section titled “Examples”The examples/
directory has runnable scripts. Each reads FABER_URL and FABER_API_KEY:
cd sdks/phpcomposer installexport FABER_URL=http://localhost:3000 FABER_API_KEY=your-api-keyphp examples/hello_world.php # run one commandphp examples/compile_c.php # compile C, run it with input on stdinphp examples/cached_test_cases.php # compile once (cached), run test cases in parallel, twicephp examples/upload_file.php # upload a file and use it as a task inputphp examples/errors_and_limits.php # FaberException vs timeout, OOM and policy violation outcomesThe 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.