Skip to content

Make POST /tasks/{id}/complete idempotent for an already-completed task (404 today) #11

Description

@schmunk42

Problem

POST /tasks/{id}/complete fails with 404 when the task has already been completed, so a retried completion cannot be distinguished from a genuinely unknown task id.

Path today:

  • TaskCompleteProcessor::process() forwards to FlowableClient::completeTask()
  • the engine answers 404 for a task that is no longer in ACT_RU_TASK
  • FlowableApiException::fromUpstreamStatus() maps upstream 404 → HTTP 404 Flowable resource not found.

Why that hurts

Task completion is a natural at-least-once operation: the client may lose the response (network drop, PHP-FPM timeout, a mobile client syncing queued work later) and retry. The retry is semantically a no-op — the task is completed — but the caller sees a hard 404 and has no way to tell "already done" from "bad task id".

It gets worse for consumers that decorate the processor to write their own domain record before forwarding the completion (write-first / complete-second, so a failed engine call leaves the task open for an idempotent retry). There, the happy-path retry — domain record already written, engine already completed — is the one that returns 404, which reads as a failure to every layer above.

Proposed fix

On upstream 404 from completeTask(), check whether the task exists in history and has ended:

// TaskCompleteProcessor
try {
    $client->completeTask($taskId, $payload);
} catch (FlowableApiException $e) {
    if ($e->getStatusCode() !== 404 || !$this->wasCompleted($client, $taskId)) {
        throw $e;
    }
    // already completed → the request's intent is satisfied
}

with wasCompleted() = findHistoricTask($taskId) and a non-null endTime. FlowableClientInterface::findHistoricTask() already exists, and the extra roundtrip only happens on the error path, so the happy path is unchanged.

A task id that is unknown to history keeps returning 404. Worth deciding: whether the idempotent success should be distinguishable from a fresh completion (e.g. a flag in the audit entry — $this->audit('task.complete', ['task' => $taskId, 'alreadyCompleted' => true])), and whether this should be default behaviour or opt-in via bundle config for consumers that rely on the current 404.

Suggested test

Two cases against a mocked client: (1) completeTask() throws upstream 404 + findHistoricTask() returns ['endTime' => '…'] ⇒ no exception; (2) same 404 + findHistoricTask() returns nullFlowableApiException (404) propagates.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions