A mock interview on CogniPrep costs one credit. You record answers to five questions, a background job processes them, and feedback comes back by email. If the job cannot finish, the credit goes back.
That last sentence is the whole subject of this post, because "the credit goes back" has to mean exactly once, and a job that retries makes that harder than it sounds.
The shape of the run
It is a Trigger.dev task with ordinary retry settings:
export const processInterviewTask = task({
id: 'process-interview',
retry: {
maxAttempts: 3,
minTimeoutInMs: 2000,
maxTimeoutInMs: 30000,
factor: 2,
randomize: true,
},
run: async (payload) => { /* ... */ },
The run fetches the interview record, marks it processing, pulls the videos out of object storage, derives what the feedback step needs from each one, produces the feedback, persists it, deletes the source videos, and emails the user. Seven steps, three attempts, one credit.
Why the naive placement is wrong
The obvious place to refund is a catch around the body. It is also wrong, and wrong in the expensive direction.
A catch inside run fires on every failed attempt. A network blip on attempt one refunds the credit, attempt two succeeds, and the user now has their feedback and their credit back. Do that reliably and you have built a free tier by accident.
The inverse mistake is just as available: refund only on a specific error type, and every error you did not think of leaves a user charged for nothing.
What you want is a hook that fires when the run has permanently failed, and never on an intermediate attempt. Trigger.dev calls that onFailure:
/**
* Runs exactly once when the run has permanently failed (retries exhausted or
* skipped). This is the ONLY place a failed interview is refunded, which keeps
* refunds single and idempotent regardless of how many attempts ran.
*/
onFailure: async ({ payload, error }) => { /* status, refund, email */ },
So a run that fails twice and succeeds on the third attempt is never refunded, and a run that exhausts its attempts is refunded once. The invariant falls out of where the code lives rather than out of a flag someone has to check.
Three error paths, because "failed" is not one thing
onFailure only answers when to refund. Deciding whether to keep trying is a separate question with its own two answers.
Some conditions cannot be fixed by a retry and should not consume one. An interview id that does not exist will not start existing, and an interview with no uploaded video means the upload never completed:
const interview = await getInterviewById(interviewId);
if (!interview) {
throw new AbortTaskRunError(`Interview ${interviewId} not found`);
}
if (!interview.videoR2Key) {
throw new AbortTaskRunError(
`Interview ${interviewId} has no video R2 key: upload may not have completed`
);
}
AbortTaskRunError ends the run immediately, which still counts as a permanent failure, so onFailure still runs and the credit still comes back.
Then there are domain errors raised deeper in, where whether a retry helps is a property of the error rather than of the call site. Those carry the answer with them, and a single hook reads it:
catchError: async ({ error }) => {
if (error instanceof InterviewError && !error.recoverable) {
logInfo(`[interview-processor] Non-recoverable error (${error.code}) ...`);
return { skipRetrying: true };
}
return undefined;
},
Returning undefined means "use the normal retry policy". Returning skipRetrying burns no further attempts on something that will fail identically in two seconds. Either way the terminal state is the same one onFailure handles.
The point of no return
Once the feedback is persisted, the user has what they paid for. Every step after that is a side effect: delete the source videos, send the email.
Which creates a trap. If the delete throws, the run fails, onFailure runs, and the interview that genuinely completed gets marked failed and refunded. The user has their feedback and their credit, and the ledger says the job did not work.
So everything after the persist is best-effort and cannot throw. The comment in the file is blunt about it:
Step 6 is the point of no return. Everything after it (delete, email) is
best-effort and cannot throw, because a completed interview must never be
flipped to 'failed' and refunded by onFailure over a side effect.
"This function must not throw" is a weak guarantee written as a comment. It is the kind of thing a reviewer has to enforce, which is why it is stated in the module header rather than left for someone to infer from the catch blocks.
And the refund is idempotent anyway
onFailure running once is a property of the orchestrator. We also do not want to depend on it.
There is a second path that can refund the same interview: the completion route has a rollback for an interview it cannot hand off, and in principle that can race a permanently failing run. So the refund is written to be safe under concurrency on its own terms:
await tx
.select({ balance: interviewCreditsTable.balance })
.from(interviewCreditsTable)
.where(eq(interviewCreditsTable.user_id, userId))
.limit(1)
.for('update');
A SELECT ... FOR UPDATE on the user's balance row, then two checks inside the same transaction: there must be a recorded spend for this interview, and there must not already be a refund for it. Without the lock both callers pass the "already refunded?" check and both credit the balance. With it, the second one sees the row the first inserted and returns false.
The function's contract is that it returns whether it did anything, not whether it succeeded:
/** @returns `true` if a refund was issued, `false` if it was a no-op. */
That signature is the part I would keep if I rewrote all of it. A refund helper that throws on a double refund pushes the decision back to a caller that does not know how many times it has been called. One that reports "already done" lets every caller be naive, which is the only realistic expectation of a retry hook, a webhook and a rollback path all pointed at the same row.
If you want to see the thing all this accounting is protecting, the interview practice page is the user-facing end, and pricing is where the credits come from.
Top comments (0)