API Testing / Intermediate: interacting contracts / Lesson 10
Test retries and duplicate suppression
Model uncertain outcomes, scoped replay keys, and bounded retry decisions without sending requests.
On this page
Lesson goals
- Distinguish an unknown outcome from a known failed operation.
- Test a fictional replay policy for same-key and changed-payload cases.
Begin with the uncertain outcome
A timeout means the caller did not receive a result within its deadline; it does not prove that the server performed no work. If a reading-list creation completed before the response was lost, an uncoordinated retry can create another entry. Separate failures before dispatch, failures with a known refusal, and failures after dispatch with an unknown outcome. The client needs a documented recovery strategy for each category. A blanket rule to retry every error confuses these states and can amplify both duplicate business actions and dependency pressure.
Specify the replay policy completely
An application replay key needs scope, payload binding, retention, and conflict behavior. This fictional policy scopes a key to an already trusted caller and operation, returns the stored result for an identical payload, and rejects reuse with a different payload. It does not claim a universal standardized header or a production implementation. The first program demonstrates the three cases in memory. In a real service, atomic storage and concurrent requests are essential: a separate check-then-insert sequence can allow two workers to perform the same action.
saved = {}
def create(scope, key, book):
slot = (scope, key)
if slot in saved:
old_book, result = saved[slot]
return result if old_book == book else "KEY_PAYLOAD_CONFLICT"
result = {"id": "entry-" + str(len(saved) + 1), "book": book}
saved[slot] = (book, result)
return result
first = create("reader-a:add", "lab-key-1", "book-4")
assert create("reader-a:add", "lab-key-1", "book-4") == first
assert create("reader-a:add", "lab-key-1", "book-5") == "KEY_PAYLOAD_CONFLICT"
assert len(saved) == 1
print("replay policy checked")
replay policy checked
The complete program checks replay and payload conflict for synthetic values. The key is a public classroom marker, not a credential. There is no persistence, locking, expiry, HTTP header, or guarantee across processes.
Bound retries by outcome and safety
A retry decision depends on operation semantics, outcome knowledge, the replay mechanism, and remaining budget. An input error will not improve by waiting. A transient dependency failure might be retried when the operation can be repeated safely and a deadline still permits it. The second program is a decision table with a maximum attempt count, not a networking client. It deliberately excludes sleeping and real status handling. A real client must interpret its documented error categories, retry hints, cancellation behavior, and per-operation deadlines rather than treating this teaching policy as universal.
def retry(category, repeat_safe, attempt):
return category == "transient" and repeat_safe is True and attempt < 3
assert retry("transient", True, 1) is True
assert retry("input", True, 1) is False
assert retry("transient", False, 1) is False
assert retry("transient", True, 3) is False
print("four retry decisions checked")
four retry decisions checked
The complete program verifies four declared decisions. It performs no delay, request, or load generation. The value three is an illustrative policy limit, not a recommended universal retry count.
Test the awkward lifecycle cases
Include duplicate delivery before completion, replay after completion, same key under a different caller scope, expired retention, and payload mismatch. Define whether a processing request returns an in-progress observation, waits within a bound, or refuses according to the chosen contract. Also define whether failed outcomes are retained. A stored replay response can itself contain private information, so scope and disclosure checks matter. Use controlled local interleavings or an approved disposable integration fixture; never create repeated requests against a configured service merely to see what happens.
Count business effects independently
The strongest replay evidence combines the returned result with the count of committed business effects. Receiving the same identifier twice is insufficient if two notifications or reservations occurred. Capture the state and relevant event observations, distinguish attempts from committed actions, and record the interruption point that produced the replay. An offline dictionary model can validate policy reasoning but cannot prove distributed exactly-once execution. Use precise language about duplicate suppression and the specific boundaries verified, including what happens when persistence or the downstream consumer fails.
Common errors and fixes
A timeout is treated as proof of no mutation
- Why it happens
- The caller confuses missing response with known server state.
- How to fix it
- Use the documented reconciliation or scoped replay mechanism.
- Prevent it next time
- Test lost-response cases separately from pre-dispatch failures.
A reused key silently accepts another book
- Why it happens
- The replay store does not bind the payload.
- How to fix it
- Compare the documented payload identity and return a conflict.
- Prevent it next time
- Include same-key changed-payload regression cases.
Retry attempts continue after the deadline
- Why it happens
- Attempt count and time budget are not coordinated.
- How to fix it
- Stop according to the operation deadline and cancellation policy.
- Prevent it next time
- Make retry budgets explicit and observable.
Check your understanding
1. Two identical replies are observed after a replay. What else must be checked?
Show worked solution
Check that the protected entry and relevant downstream effects occurred only as the contract allows. Equal response bodies can hide duplicate commits. Also record caller scope, payload binding, and retention assumptions so the result applies to a defined replay policy rather than every possible retry.
2. Should an invalid page-size request be retried three times?
Show worked solution
No under the declared policy. The input category needs correction by the caller, not a delay. Verify that the retry classifier returns false and that the client exposes a useful error. A transient classification should require documented evidence rather than being assigned to every failure.
Key takeaways
- An unknown outcome requires reconciliation, not assumption.
- Replay keys need scope, payload binding, and lifecycle rules.
- Count committed effects as well as repeated responses.