One step. Several tickets. eToro fills what it can. Vercel Workflows retries what you mark retryable. If a closed commodity is a RetryableError, the step starts over and the filled legs buy twice.
Duplicate eToro positions on names that already filled. A parent FatalError after four retries. Residual cash that tries GOLD again instead of walking past it. The account sees extra tickets. The workflow log looks busy and proud.
I shipped a production trading agent that opens a basket of tickets through eToro. One Vercel Workflows step. Four legs. The first two filled. The third was a commodity closed for the session. Local validation said, correctly, not currently tradable.
I wrapped that as Vercel Workflows RetryableError. Vercel Workflows did what it does: it retried the whole step. Four times. Legs one and two opened again. The remaining names never ran. The parent then threw Vercel Workflows FatalError.
That is not a flaky socket. That is a class of error that cannot succeed on retry in the same session: not tradable, side disabled, leverage out of range. Treating those as retryable is how you invent duplicate eToro fills.
Vercel Workflows checkpoints steps, not the legs inside a step. If you treat a closed eToro market as retryable, 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 your mental model. It just opens.
Prefetch the constraints and skip the untradable leg. If the eToro client still throws those messages, treat them as skip, not retry. Put the matchers in one shared helper so the throw text and the skip patterns cannot drift. A wording change in the eToro client should not silently turn a skip back into a four-retry replay.
HTTP 4xx stays fatal. Other errors stay retryable. 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. Skipping the next time you see the same validation is the fix going forward.
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. attemptOpen returns { status: 'skipped' } instead of throwing Vercel Workflows RetryableError. The step finishes. Remaining legs run.
Four-leg open step
Leg 1: EQ-A
Equity A
Leg 2: EQ-B
Equity B
Leg 3: CMD-X
Commodity X (session closed)
Leg 4: EQ-C
Equity C
Idle. Open the basket, then compare Retry against Skip-untradable.
Hit Retry and watch filled legs clone themselves while the rest never start. Hit Skip-untradable and the remaining names complete.
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. Now 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.
A closed eToro market is not leftover capacity. It is a hard no. The new picker still sorts by weight descending. It filters first.
canOpenPositionNow is false: skipskipReasonForOpenConstraints is not null: skipfunction 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. If a later throw happens, that throw does not retry the Vercel Workflows step. Residual sweep is not a retry loop. It is a one-shot allocation onto a name that can actually take the eToro order.
Two tests keep this honest. An untradable GOLD-like name at the top of leftover weight does not eat the cash. A later throw does not retry the sweep step. Closed eToro markets stay closed. Leftover cash still lands. Same skip helper. Same Vercel Workflows step. No RetryableError for a session that will not open.
Happy coding!
Sander