Skip to content

Exception Handling

v3 turns failures into data, not crashes. A handler may throw any Throwable; the runtime catches it, records a failed ExecutionResult, and the worker decides whether to requeue with backoff or abandon the message. The worker process itself keeps running.

Where exceptions are handled

Layer Class Responsibility
Single attempt Daycry\Jobs\Execution\JobRuntime Run the handler, capture output, apply the timeout, turn any Throwable into a failed ExecutionResult.
Retry decision Daycry\Jobs\Worker\QueueWorker Inspect the result; ack on success, nack(delay) with retries left, abandon($lease) (backend-specific) plus a critical log line when exhausted.
Timeout Daycry\Jobs\Execution\Timeout Throw JobException::forJobTimeout() at the deadline (SIGALRM, interrupts CPU-bound code) or via a soft post-hoc check without pcntl.

Key principle: exceptions never crash the worker. They are caught at the execution boundary, recorded, and routed through the same retry/abandon path as a logical failure.

Single attempt — JobRuntime

JobRuntime::run(JobDefinition $def, JobContext $ctx): ExecutionResult wraps the whole attempt:

try {
    $handler = $this->registry->resolveForQueue($definition->handler, $context->queue ?? 'default');
} catch (Throwable $e) {
    // Handler key unknown, or blocked by the per-queue allowlist.
    return new ExecutionResult(false, null, $e->getMessage(), $start, microtime(true));
}

try {
    $handler->beforeRun($context);
    ob_start();
    $returned = $this->timeout->run($timeoutSeconds, static fn () => $handler->handle($context), $label);
    $result   = new ExecutionResult(true, $this->normalizeOutput($returned, ob_get_clean()), null, $start, microtime(true), $handler::class);
    $this->safeAfterRun($handler, $context, $result);

    return $result;
} catch (Throwable $e) {
    if (ob_get_level() > 0) {
        ob_end_clean();
    }
    $result = new ExecutionResult(false, null, $e->getMessage(), $start, microtime(true), $handler::class);
    $this->safeAfterRun($handler, $context, $result);

    return $result;
}

What is caught:

Source Result
Unknown handler key (forInvalidJob) failed ExecutionResult
Handler not allowed on this queue failed ExecutionResult
beforeRun() / handle() throws failed ExecutionResult with the message
Timeout exceeded failed ExecutionResult (JobException::forJobTimeout)
Single-instance lock already held failed ExecutionResult ("single-instance job '...' is already running") — the worker requeues it (see Concurrency)
Any Throwable (Error, TypeError, ...) failed ExecutionResult

afterRun() runs through safeAfterRun(): an exception there is swallowed and never changes the recorded outcome.

Retry decision — QueueWorker

The worker reads ExecutionResult::$success and acts:

  • success → ack()
  • failure, attempts < maxRetriesnack($lease, $delay) (backend requeues with backoff)
  • failure, retries exhausted → abandon($lease) plus a critical log line

abandon() is backend-specific, and "dead-letter" only describes one of them:

Backend abandon() behaviour
Beanstalk buries the job — beanstalkd's native dead-letter facility
Redis drops the message from the processing list (no DLQ)
Database marks the row failed (retained for audit, never re-fetched)

The app-level Daycry\Jobs\Libraries\DeadLetterQueue helper is opt-in and is not called by the worker: QueueWorker::processOnce() invokes $this->backend->abandon($lease) only. If you want a DLQ on Redis/Database, wire DeadLetterQueue::store() yourself.

See Retries for the backoff model and Attempts for the counter.

Built-in exceptions

JobException (Daycry\Jobs\Exceptions\JobException)

Named constructors for definition/validation/execution errors:

JobException::forInvalidJob('unknown_handler');
JobException::validationError('CommandHandler payload must be a non-empty string.');
JobException::forShellCommandNotAllowed('/usr/bin/rm');
JobException::forShellCommandsNotConfigured();      // deny-by-default ShellHandler
JobException::forEventNotAllowed('user.deleted');   // not in $allowedEvents
JobException::forInvalidMethod('TRACE');            // UrlHandler method allowlist
JobException::forInvalidPriority(99);
JobException::forJobTimeout('app:report', 120);
JobException::forRateLimitExceeded('default', 50);

QueueException (Daycry\Jobs\Exceptions\QueueException)

Backend/queue resolution errors:

QueueException::forInvalidWorker('does-not-exist'); // unknown $backends key
QueueException::forInvalidQueue('nope');
QueueException::forInvalidConnection('redis refused');

QueueException::forInvalidWorker() is thrown by BackendFactory::make() when the requested backend name is absent from Config\Jobs::$backends or does not implement QueueBackend.

Some failures are intentional refusals rather than bugs:

  • Invalid signature — when verifyEnvelopeSignature is on and the HMAC _sig does not verify, the worker logs critical and abandons the message (WorkerResult status rejected). It is not retried.
  • Blocked handler — a handler key not allowed on the queue raises a validation error inside JobRuntime, recorded as a normal failed attempt.
  • Shell / event refusalsShellHandler (deny-by-default) and EventHandler (empty allowlist) throw JobException before doing any work.

These refusals are detailed in Security.

Writing handlers

Throw freely — the runtime records the message and the worker handles retries. Prefer specific, descriptive exceptions:

use Daycry\Jobs\Handlers\AbstractJobHandler;
use Daycry\Jobs\Execution\JobContext;
use RuntimeException;

final class ImportUsers extends AbstractJobHandler
{
    public function handle(JobContext $ctx): mixed
    {
        $payload = $ctx->payload;
        if (! isset($payload['file'])) {
            throw new RuntimeException('ImportUsers payload missing "file".');
        }

        // ... business logic ...
        return 'imported';
    }
}

Guidelines:

  • Make handlers idempotent (delivery is at-least-once).
  • Clean up resources with try/finally so a thrown exception still releases files/connections.
  • Tune maxRetries per job: few for cheap work, more for flaky external calls, 0 to fail fast.

Running the worker under a supervisor

Exceptions inside a handler never stop the worker, but OOM, kill -9, or a backend outage can. Run it under a process supervisor so it restarts:

[program:jobs-worker]
command=php spark jobs:queue:work default
autorestart=true
stopsignal=TERM
stderr_logfile=/var/log/jobs-worker.err.log

SIGTERM/SIGINT trigger a graceful shutdown: the current cycle finishes and the worker exits.