tryMax(3) retrying a POST creates duplicate records when the first attempt timed out server-side
0 reputation · 08 Mar 2025, 07:11 UTC
0 reputation · 08 Mar 2025, 07:11 UTC
I have a Gatling simulation (3.x line) that wraps a write request in tryMax(3) so transient failures don't fail the whole scenario. The wrapped chain is a POST /orders with a status.is(201) check. The API under test is not idempotent and has no deduplication on its side.
When the first attempt succeeds on the server but the response is lost (client-side timeout), tryMax re-sends the entire POST, and the server persists a second order. From the client side there is no way to tell whether the write landed, so the retry decision is ambiguous. The report also shows inflated request counts for this endpoint, which makes per-attempt latency hard to read.
I considered keying the retry only on transport-level failure versus on specific statuses, but statuses like 409 or 5xx still leave "did the write happen?" undecidable from the simulation.
tryMax entirely?tryMax offer any hook to distinguish attempt outcomes beyond the embedded checks, or is that out of scope for the DSL?tryMax (or restrict retries to explicit transport‑level failures only).tryMax does not expose a hook to inspect individual attempt outcomes beyond the embedded checks; retry decisions are based solely on whether the wrapped chain fails according to the supplied criteria.In Gatling 3.x, tryMax(n) retries the entire chain when the chain fails. A chain fails if any check inside it fails (e.g., status.is(201)) or if an exception (such as a client‑side timeout) is thrown. When the first POST /orders succeeds on the server but the response is lost (client timeout), the check never sees a 201, so the chain fails and tryMax re‑sends the POST. Because the endpoint is not idempotent and has no deduplication, the server persists a second order.
Generate a unique key (UUID) per virtual user or per iteration and add it as a header (e.g., Idempotency-Key). The server must recognise this header and suppress duplicate processing for the same key.
val idempotencyFeeder = Iterator.continually(Map("idempotencyKey" -> java.util.UUID.randomUUID.toString))
scn.feed(idempotencyFeeder)
.exec(http("Create Order")
.post("/orders")
.header("Idempotency-Key", "${idempotencyKey}")
.check(status.is(201)))
With this pattern, even if tryMax retries, the server sees the same key and treats the retry as a duplicate, preventing a second record.
If you cannot modify the server to accept an idempotency key, do not wrap the non‑idempotent POST in tryMax. Instead, let the request fail and handle the failure at the scenario level (e.g., with exitHereIfFailed or a custom retry loop that only retries on transport‑level exceptions like io.gatling.http.client.engine.HttpException).
The DSL does not provide a callback or hook to distinguish "server succeeded but response lost" from a genuine failure. The retry decision is binary: the chain either passes all checks or it doesn’t. Any logic that requires inspecting the response body, headers, or timing of each attempt must be implemented outside tryMax (e.g., a manual loop with doWhile and session‑based state).
logLevel = DEBUG) and observe the console: you will see the same POST sent multiple times when the first attempt times out after a 201.tryMax(3) and confirm the mock receives two identical POST bodies.tryMax from the POST chain and run the scenario; the request count should match the number of iterations, confirming no duplicate posts.This behaviour applies to Gatling 3.x (tested up to 3.10). The DSL has not changed in this regard in recent minor releases. If you are on Gatling 2.x, the same principles apply but the syntax differs.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.