Under Laravel Octane on Swoole, what happens when a task in Octane::concurrently throws an exception or runs past the wait time, and how should the caller handle it?
answer
- exception rebuilt as TaskException
- getClass() names the original type
- default wait 3000 milliseconds
- all late: TaskTimeoutException
- some late: false in that slot
basics
~20 sA task's exception is rethrown in the caller as Laravel\Octane\Exceptions\TaskException with the original class name, message, file and line. A failed wait (3000 ms default) throws TaskTimeoutException; a task missing from the results comes back as false.
solid answer
~40 sExceptions cannot cross processes intact, so the task worker converts a thrown exception into a `TaskExceptionResult`; back in the HTTP worker Octane throws `Laravel\Octane\Exceptions\TaskException` whose `getClass()` returns the original class name and whose message, code, file and line are copied. A `catch (ModelNotFoundException $e)` around `concurrently()` therefore never matches; catch `TaskException` and inspect `getClass()`. For time, the second argument is the wait in milliseconds (default 3000). When Swoole's `taskWaitMulti()` returns `false`, Octane throws `TaskTimeoutException` ("Task timed out after 3000 milliseconds."); when it returns a result set that lacks some tasks, their slots are filled with `false` and no exception is thrown. So check for `false` explicitly, make tasks return something that cannot be confused with it, and keep tasks well inside the budget - or move them to a queue.
code
php · 20 lines<?php
use Laravel\Octane\Exceptions\TaskException;
use Laravel\Octane\Exceptions\TaskTimeoutException;
use Laravel\Octane\Facades\Octane;
try {
$panels = Octane::concurrently([
'sales' => fn () => salesPanel($teamId),
'traffic' => fn () => trafficPanel($teamId),
], 1500);
} catch (TaskException $e) {
report($e); // $e->getClass() holds the original class name
$panels = [];
} catch (TaskTimeoutException $e) {
$panels = [];
}
// A task that missed the deadline comes back as false
$sales = ($panels['sales'] ?? false) === false ? null : $panels['sales'];go deeper
Recall that errors inside Octane::concurrently come back as TaskException and that there is a wait time, 3000 ms by default.
Explain why exceptions are rebuilt across processes, getClass(), the difference between a full timeout exception and false in a missed slot.
Show defensive callers: catch both exception types, treat false as unfinished, pick a wait budget, and keep tasks idempotent because timeouts do not cancel them.
Decide where in-request fan-out ends and durable asynchronous processing begins, based on failure handling, retries and latency budgets.
## Two ways a task can go wrong `Octane::concurrently()` runs closures in **Swoole task workers** - separate processes - and waits for their results. A task can fail by **throwing** or by **being slow**. Octane handles each in a way that surprises people who expect it to behave like calling the closures inline. ## When a task throws The task worker cannot send a live exception object back to the HTTP worker: exceptions often reference things that do not serialize, such as closures, resources or the whole application. So Octane's worker catches it, dispatches `WorkerErrorOccurred` (which reports it), and returns a `TaskExceptionResult` holding only: - the original exception **class name**, - the **message** and **code**, - the **file** and **line** (for closures, the first real file in the trace). When the HTTP worker sees that result, it throws `Laravel\Octane\Exceptions\TaskException` built from those fields. `TaskException::getClass()` returns the original class name. The original type is gone: | Inside the task | What the caller catches | |---|---| | `ModelNotFoundException` | `TaskException`, `getClass()` = `...ModelNotFoundException` | | `RequestException` from the HTTP client | `TaskException`, no response object attached | | `ValidationException` | `TaskException`, no error bag | So `catch (ModelNotFoundException $e)` around `concurrently()` never fires, and Laravel's exception handler will not render a 404 for it automatically. The first failing task in key order is the one rethrown; the others' results are discarded. ## When a task is slow The second argument is the wait time in **milliseconds**, **3000** by default. Octane passes it to Swoole's `taskWaitMulti()` in seconds and then handles two outcomes: 1. **The wait failed as a whole** - `taskWaitMulti()` returns `false`, and Octane throws `Laravel\Octane\Exceptions\TaskTimeoutException` with the message "Task timed out after 3000 milliseconds." (with your value). 2. **A result set came back without some tasks** - typically because those tasks had not finished when the wait ran out. Octane fills each missing slot with **`false`** and returns normally. No exception, no log line. Case 2 is the trap. A dashboard panel whose query took 3.5 seconds silently becomes `false`, and a view that does `$sales->total` fails later with a confusing error, or a `false` is rendered as an empty panel. Note also that the timeout does not stop the slow task: the task worker keeps running it, still occupying a slot in the shared task pool. ## Handling both in the caller 1. **Catch `TaskException`** around the call and branch on `getClass()` if the type matters; rethrow or map it to the response you want. 2. **Catch `TaskTimeoutException`** separately and decide on a fallback - cached data, a partial page, or an error. 3. **Check every slot for `false`** before use. Make tasks return values that can never legitimately be `false` (an object, an array, `null` for "nothing"), so `false` unambiguously means "did not finish". 4. **Set a wait time that matches the endpoint**, and keep it below the request's own time limits. 5. **Keep tasks idempotent and side-effect light**, since a timed-out task may still complete later. ## A worked example A dashboard calls `concurrently()` with a 1500 ms budget for `sales` and `traffic`. On a normal day both return in 400 ms. During a reporting-database slowdown the `sales` query takes 2 seconds: the call returns after 1.5 s with `traffic` filled and `sales` set to `false`, and the sales task keeps running in its task worker for another half-second. Later, a bad team ID makes `traffic` throw `ModelNotFoundException`; the controller receives a `TaskException` whose `getClass()` names that class, and a `catch (ModelNotFoundException)` block written for the sequential version of the code is skipped entirely. Both cases need explicit handling in the caller. ## When the answer is a queue If a task regularly needs close to the wait budget, must be retried on failure, or must not be lost on a restart, it does not belong in `concurrently()`. Queued jobs provide retries, failure storage and durable execution; that design is its own topic. ## Summary Thrown exceptions arrive as `TaskException` with `getClass()`; a complete timeout throws `TaskTimeoutException`; a partial timeout quietly yields `false` in the slots that missed the deadline.
- Why doesn't Octane rethrow the original exception object from a task?The task ran in another process, and exceptions frequently hold unserializable data such as closures, resources or the application. Octane sends back only the class name, message, code, file and line, and rebuilds a `TaskException` from them, exposing the original class through `getClass()`.
- Does a TaskTimeoutException stop the slow task?No. The timeout only stops the caller from waiting. The task worker keeps executing the closure until it returns, so it still consumes a task worker and still performs any side effects. Keep tasks short and idempotent, and move long work to queued jobs.
saying these in an interview costs you the question
- catch (ModelNotFoundException $e) catches a task's model-not-found error
- Any task missing the deadline makes concurrently throw a timeout
- The wait argument is in seconds, so 3 means three seconds
- A timeout cancels the slow task in its task worker
- A failed task returns null in its slot and the rest continue