Run a flow in the background and wait for its result

PHP

Send a run without waiting for it, wait for an execution that answered queued or waiting, and read its status and its trace.

7 min read

A run does not always come back finished. The flow can stop at a node that waits, and your application can ask Slen to run the flow in the background. In both cases the answer carries a status that is not final, and the result is read later.

When an answer is not final

Three statuses mean that the execution is still going to move.

StatusWhat it means
queuedThe run was sent in async mode. It is accepted, and no worker has started the flow yet
runningThe flow is being walked now
waitingThe flow stopped at a node that waits. It continues by itself when what it waits for happens

A request is answered with 202 whenever its execution is not over: queued in async mode, waiting at a node that waits, and running when a repeated request meets an execution that is still being walked. The answer carries the id of the execution and retry_after_seconds — how long Slen suggests waiting before the status is read.

A waiting execution says what it waits for in waiting_on. node_key names the node, resume_at is the moment the wait ends when there is one, and type is the reason.

typeWhat the execution waits for
queue_nodeA Queue node holds it until resume_at
approvalA person decides on Approvals. There is no resume_at
rate_limit_queueNo connection of the provider pool can take the call now, and AI Model or its pool is set to wait rather than to refuse. The flow tries the call again at resume_at
provider_backgroundThe provider runs the call as a background job, and Slen asks it for the answer. When the provider cannot be asked even after many tries, the execution ends uncertain. A call the provider keeps running is waited for as long as any wait — 72 hours in all by default — and then the execution ends failed with parked_expired

A queued execution answers with waiting_on set to null.

  • Request — Default → Queue
  • Queue — Default → AI Model
  • AI Model — Success → Return Response

A run of this flow is answered with waiting as soon as the execution reaches Queue.

Send a run without waiting for it

operations.executeAsync() takes the same arguments as operations.execute(). The flow runs in the background, and the call returns at once with the status queued.

PHP
use Slen\Sdk\Modules\Operations\Options\ExecuteOptions;

$queued = $slen->operations()->executeAsync(
    'summarize_report',
    ['messages' => [['role' => 'user', 'content' => 'Summarize the third-quarter report.']]],
    new ExecuteOptions(idempotencyKey: 'report-2026-q3:summary'),
);

echo $queued->status->value, PHP_EOL;

The idempotency key works as in a synchronous run. A run repeated under the same key with the same body starts nothing. It is answered with the execution the first request started, as it is at that moment: queued until a worker takes it, and its result once it is over.

An execution that runs in the background has no request to answer with an error. A refusal or a failure is read from its status.

Wait for the result

executions.wait() reads the status of the execution again and again until the execution is over.

PHP
use Slen\Sdk\Exceptions\SlenException;
use Slen\Sdk\Modules\Executions\Options\WaitOptions;

// In a queue job or a script, not in an HTTP handler: the wait sleeps in the process.
$finished = $slen->executions()->wait($queued->executionId, new WaitOptions(
    timeoutSeconds: 60,
    onPollError: static function (SlenException $error, int $failuresInARow): void {
        echo sprintf('Status read failed (%d in a row): %s', $failuresInARow, $error->getMessage()), PHP_EOL;
    },
));
  • The pause between two reads is the one Slen asks for: a status that is not final carries retry_after_seconds, and the next read comes after that many seconds. A status without it gets a pause that starts at one second and doubles up to 30. Every pause is shifted by up to a quarter either way, so that executions sent together are not read at the same instant.

  • The timeout — timeoutSeconds of the options, WaitOptions in both SDKs — is how long the call waits: 300 seconds unless you pass another, and at least 1. A pause is cut to what is left of the timeout, and the status is read once more when the timeout is up.

  • A timeout is not an error. The call returns the last status it read, and that status is still not final. Call executions.wait() again to go on waiting.

A read is allowed what is left of the timeout — at least five seconds, and no more than the timeout of the config — so the call returns at most five seconds after its timeout. Guzzle and Symfony HttpClient take the timeout of a read. Any other HTTP client reads with its own timeouts, and with it the call can return that much later.

A wait reads the status with the same API key, and reading needs the read scope. A key with gateway:write has it.

Read what came of it

PHP
echo match (true) {
    $finished->isCompleted() => $finished->output['content'],
    $finished->isPending() => 'Not over yet: ' . $finished->status->value,
    default => sprintf('Ended as %s: %s', $finished->status->value, $finished->errorCode),
}, PHP_EOL;

The answer of a wait ends in one of three ways.

  • completed — output holds what the flow returned.

  • queued, waiting or running — the timeout came first. Wait again.

  • rejected, failed or uncertain — the execution did not complete. error_code says why, and error_message says it in a sentence for a person. A failed execution closed by an error the API has no code for has neither: both are null.

A wait returns ExecutionStatusDTO: the fields of ExecutionDTO without error and replayed.

When a status read fails

A read that fails for a passing reason does not end the wait. The client does not send that read again by itself: the wait reads the status anew after a pause that starts at one second and doubles up to 30.

  • A passing reason is a request that got no answer, a 429 and any 5xx. Reading the status counts against the rate limit of the key, so a 429 is one of them.

  • Any other error — a 404, a 401 — ends the wait at once.

  • When the timeout comes, the call returns the last status it read. It raises the last error only when no read succeeded at all.

So a status that is still not final does not say whether the execution is slow or the API could not be asked. onPollError does. The wait calls it with every failed read it goes on through, and with the number of failed reads in a row. An error that ends the wait is raised and is not passed to it, and an error onPollError throws ends the wait.

Read the status yourself

executions.get() reads the status once. It is the request GET /v1/executions/{id}/status, the one a wait repeats.

executions.trace() reads the whole story of the execution: every node it passed and every event. It is the request GET /v1/executions/{id} — Executions describes its fields.

In the console the same executions are on the Executions tab of their flow, with the statuses Queued, Running and Waiting.

In a service, use the webhook

A wait lasts as long as the execution takes, and sleeps between its reads. That suits a job of your queue or a script, not a handler of an HTTP request: the caller waits all that time, and a PHP worker serves no other request meanwhile. A service sends the run with operations.executeAsync() and answers its own caller at once. It reads the result when the execution.finished webhook arrives — Webhooks — or in a job of its queue that waits for it.

Where to go next