The live eToro execution workflow opens a basket of tickets inside one Vercel Workflows step. The operator watching the account sees duplicate positions on names that already filled, then residual cash stuck on a commodity that was closed for the session. Local validation said, correctly, not currently tradable. I had wrapped that as Vercel Workflows RetryableError, so the runtime retried the whole step from the top. Filled legs bought twice. Remaining names never ran.
One step held four legs. The first two filled. The third was a commodity closed for the session. Vercel Workflows did what it does: four retries. Legs one and two opened again. The parent then threw Vercel Workflows FatalError.
The socket was fine. These error classes cannot succeed on retry in the same session: not tradable, side disabled, leverage out of range. Treating those as retryable is how I invented duplicate eToro fills.
Checkpoints are steps, not legs
Vercel Workflows checkpoints steps, not the legs inside a step. A closed eToro market is not retryable in the same session, and the runtime does not resume at the failed name. It re-enters the step from the top. Every earlier openPosition runs again. eToro has no memory of my mental model. It just opens.
I prefetch the constraints and skip the untradable leg. If the eToro client still throws those messages, attemptOpen returns skipped instead of wrapping them as Vercel Workflows RetryableError. The matchers live in one shared helper so the throw text and the skip patterns cannot drift.
HTTP 4xx stays fatal. Other errors stay retryable, because a timeout can succeed on the next attempt. A session-closed commodity cannot.
This does not unwind duplicates already live. Those fills are on the eToro book. The next time the same validation fires, I skip that leg.
Skip the leg, keep the Vercel Workflows step
type OpenAttempt = { status: 'placed'; orderId?: string } | { status: 'skipped' };
async function attemptOpen(input: OpenInput): Promise<OpenAttempt> {
try {
const receipt = await etoro.openPosition(input);
return { status: 'placed', orderId: receipt?.orderId };
} catch (error) {
if (isSkippableOpenValidationError(error)) {
return { status: 'skipped' }; // not Vercel Workflows RetryableError
}
throw error;
}
}isSkippableOpenValidationError is the only place that knows which eToro messages mean this name will not trade this session. The step finishes. Remaining legs run.
Press Retry whole step in the demo and watch filled legs clone while the rest never start. Switch to Skip untradable leg and the remaining names complete without replaying earlier opens.
Four-leg open step
- pending
Leg 1: EQ-A
Equity A
- pending
Leg 2: EQ-B
Equity B
- pending
Leg 3: CMD-X
Commodity X (session closed)
- pending
Leg 4: EQ-C
Equity C
Idle. Open the basket, then compare Retry against Skip-untradable.
Residual eToro cash sweep uses the same skip
Primary eToro fills land. Cash remains. I sweep that residual into the highest-weight target that can still take an eToro open. Weight picks the winner. Tradability decides who is even in the race. I used to let weight win alone.
The old helper sorted remaining targets by weight and grabbed index zero. A closed eToro commodity with the fattest remainder still won. Think GOLD when isTradable is false or canOpenPositionNow is false. The sweep then tried to open it.
I wrapped that miss as Vercel Workflows RetryableError. A closed session could abort the leftover or no-op the whole step twice: first on the primary path, again on the sweep. One untradable eToro name became a second way to stall leftover cash.
The new picker still sorts by weight descending. It drops a target first when the instrument ID is missing, constraints are missing, canOpenPositionNow is false, or skipReasonForOpenConstraints is not null.
function pickResidualSweepTarget(targets, symbolToInstrument, constraintById, eligibilityById) {
const sorted = Object.entries(targets)
.filter(([symbol, target]) => {
const id = symbolToInstrument[symbol];
if (id === undefined) return false;
const constraints = constraintById[id];
if (!constraints) return false;
if (!canOpenPositionNow(eligibilityById[id])) return false;
return skipReasonForOpenConstraints(constraints, target.side, target.leverage) === null;
})
.sort((a, b) => b[1].weight - a[1].weight);
return sorted[0];
}GOLD sits at the top of the remainder and is untradable. The picker walks past it. The next eligible eToro target still opens. Closed eToro markets stay closed while leftover cash still lands, using the same skip helper inside the same Vercel Workflows step, with no RetryableError for a session that will not open.
Happy coding! Sander