Skip to content

    Forward Deployed Engineer

    Timeouts, Retries, Idempotency; Sync or Async

    Treat the call into a Python AI service as a contract: deadlines, bounded retries with jitter, idempotency keys that replay a stored answer, and sync or async.

    By Published 7 min read

    Share

    This lesson is part of Forward Deployed Engineer: Integrate and Ship. Enroll for free, then sign in, to read it in full.

    What you'll learn: what the caller and the Python service each promise at the integration boundary, how a retry with an idempotency key replays a stored answer instead of running the work twice, what the capstone's in-memory store does not do, and how to choose between a synchronous answer and an asynchronous job.

    At a glance

    Figure 1. A lost reply, retried with the same key

    sequenceDiagram accTitle: A lost reply retried with the same idempotency key accDescr: The caller sends a request with an Idempotency-Key. The service runs the pipeline and stores the response under the key, but the reply is lost and the caller times out. After a jittered wait the caller sends the same request with the same key. The service finds the stored response and returns it with Idempotent-Replay true, without running the pipeline again. participant C as Caller participant S as Python AI service participant K as Idempotency store C->>S: POST /discovery, Idempotency-Key k1 S->>K: any response stored for k1? K-->>S: none S->>S: run the pipeline S->>K: store the response under k1 S--xC: reply lost, caller times out Note over C: wait with jitter, then retry C->>S: POST /discovery, Idempotency-Key k1 S->>K: any response stored for k1? K-->>S: yes, same request body S-->>C: stored response, Idempotent-Replay true

    How to read this: the second request does not start the pipeline; the key and body together show it is a retry of finished work.

    • The boundary is a contract: the caller promises a deadline and a key, the service a replay.
    • A POST is safe to retry with an idempotency key.
    • Bound retries by count and deadline, and add jitter.
    • The capstone's store is in memory; production needs a shared one.

    Scenario

    Composite scenario, not a real company. At Lumen Industrial Supply, the fictional distributor, the sales portal calls the discovery service, and a call takes seconds. Sales engineers double-click and a flaky network drops replies. Last week one request ran three times and the team paid for three model calls. The portal team asks for retries; the service owner asks what a retry must not do. Numbers are illustrative.

    What you will be able to do

    • Write the boundary contract for caller and service.
    • Predict a retry's result for the same key and body, a different body, and no key.
    • Compute how many attempts a deadline allows.
    • Choose between a synchronous answer and an asynchronous job.

    Prerequisites

    Async, HTTP clients, timeouts and retries and Reliability and scaling.

    Core ideas

    The boundary is a contract

    Predict first. A caller sends a request with Idempotency-Key: k1. The service finishes, but the reply is lost, so the caller retries with the same key and body. What comes back, and how often did the pipeline run?

    Answer

    The stored response, with the header Idempotent-Replay: true (defined by this capstone, not a standard). The pipeline ran once.

    Side Promises
    Caller A deadline on every call; one key per user action, reused on retries; retry only 408, 429, 502, 503 and 504; a bounded number of attempts
    Service Same key and body, same response; same key, different body, refused (409); one error shape with a trace id

    RFC 9110 says PUT, DELETE and safe methods are idempotent, and that a client should not automatically retry a request with a non-idempotent method unless it can know the request is effectively idempotent or was never applied. POST is not in that list; an idempotency key is that means.

    The Idempotency-Key header is a draft, not a standard

    As of 2026-11-02 the header is described by an Internet-Draft, draft-ietf-httpapi-idempotency-key-header, an active draft of the IETF httpapi working group (revision 08, updated 2026-10-22, expiring 25 April 2027), not an RFC.

    The draft suggests 409 when a retry arrives while the first request still runs and 422 when a key is reused with a different payload. The capstone answers 409 for a different payload and has no "still running" case.

    What the capstone's store does

    Verified: capstone/fde-product-discovery-copilot/app/idempotency.py

        def get(self, identity: Identity, key: str, body: str) -> dict[str, object] | None:
            """Keys are scoped to tenant and user, so one caller can never read another's response."""
            with self._lock:
                entry = self._items.get((identity.tenant, identity.user_id, key))
            if entry is None:
                return None
            if entry[0] != self._fingerprint(body):
                raise IdempotencyConflict("This Idempotency-Key was already used with a different request.")
            return entry[1]

    The store is keyed by tenant, user and key, and keeps a hash of the request body next to the response. Its limits (tests in lessons/M5-L3):

    Limit What happens What production needs
    In memory, per process A retry on another replica starts over A shared store
    Check, then run, then store Two simultaneous first attempts both run An atomic "insert if absent"
    No expiry; 1000 entries, oldest evicted A late retry runs again A time to live over your retry window
    Only successes stored A failed attempt is run again A rule per error class

    Timeouts, retries and jitter

    Figure 2. Sync or async?

    flowchart TD accTitle: Synchronous or asynchronous accDescr: If the work finishes inside the caller's deadline at the slow end, answer synchronously. If not, ask whether the caller can continue without the result. If it can, return 202 Accepted with a status resource and run the work in a worker. If it cannot, shorten the work or raise the deadline. A["Can the work finish within the caller's deadline at the slow end?"] -->|yes| S["Synchronous: 200 with the result"] A -->|no| B{"Can the caller continue without the result now?"} B -->|yes| C["Asynchronous: 202 Accepted, a status resource, a worker"] B -->|no| D["Shorten the work, or raise the deadline and say why"]

    How to read this: the deadline at the slow end decides, not the average.

    The httpx client times out after 5 seconds of network inactivity by default, short for a model call; set httpx.Timeout explicitly. The AWS Builders' Library warns that when failures come from overload, retries that increase load can make matters significantly worse, and says to limit retries and add jitter.

    Verified: capstone/fde-product-discovery-copilot/lessons/M5-L3/m5_l3_boundary.py

    # connect fast or give up; allow the model-backed work longer to answer
    DEFAULT_TIMEOUT = httpx.Timeout(10.0, connect=2.0)
    RETRYABLE_STATUS = frozenset({408, 429, 502, 503, 504})
    
    
    def backoff_delay(attempt: int, base_s: float = 0.2, cap_s: float = 5.0, rng: Callable[[], float] = random.random) -> float:
        """Exponential backoff with full jitter: a random wait between 0 and min(cap, base * 2**attempt)."""
        return rng() * min(cap_s, base_s * 2**attempt)

    The retryable statuses are this lesson's policy choice. post_with_retry retries only with an Idempotency-Key, honours a numeric Retry-After, and stops when the overall deadline would be passed.

    For long work, 202 Accepted means accepted but not finished; RFC 9110 says the response ought to describe the current status and point to a status monitor. The capstone is synchronous.

    Worked example

    Assumptions: illustrative; each attempt waits out the full 10 s read timeout; deadline_s is 20; three attempts allowed; jitter at its maximum.

    • Three attempts at 10 s plus waits of 0.2 s and 0.4 s would take 30.6 s.
    • Attempt 1 ends at 10 s, a 0.2 s wait gives 10.2 s, attempt 2 ends at 20.2 s.
    • The next wait of 0.4 s would end at 20.6 s, past 20 s, so no third attempt starts.
    • With the key, the caller makes 2 attempts. Without it, 1.

    Backoff doubles from 0.2 s: 0.2, 0.4, 0.8, 1.6, 3.2, then 6.4, cut to 5.0 by the cap.

    At the customer: "we will just retry three times." A good answer asks: does the request carry a key, does the retry fit the user's deadline, and who else retries (the gateway, the SDK)?

    Build it

    ./.venv/Scripts/python -m pytest lessons/M5-L3

    This test loses the first reply after the server finished.

    Verified: capstone/fde-product-discovery-copilot/lessons/M5-L3/test_m5_l3_boundary.py

    def test_a_retry_with_the_same_key_replays_the_stored_answer(real: TestClient) -> None:
        flaky = LosesTheFirstResponse(real)
        final = post_with_retry(flaky, URL, json=BODY, headers={**auth(), "Idempotency-Key": "lost-reply-1"}, sleep=lambda s: None)
        first_run, replay = flaky.seen
        assert "Idempotent-Replay" not in first_run.headers
        assert final.headers["Idempotent-Replay"] == "true"
        assert replay.json()["trace_id"] == first_run.json()["trace_id"]  # the pipeline did not run a second time

    Decision exercise. Sync or async? (a) A sales engineer waits on the search page for an answer that takes about 3 s (illustrative). (b) A nightly rerun of all 36 evaluation questions. (c) A quote draft that waits for a human approval.

    Model answer

    (a) Sync: the user waits and the work fits the deadline; add a timeout, a key and a bounded retry. (b) A job: nobody waits. (c) Async: approval takes a human's time, so return 202 with a status resource.

    Exercise

    Write the test for this rule: a 503 with Retry-After: 60 and a 20-second deadline returns the 503 at once, with no sleep and no retry.

    Acceptance criteria:

    • A fake client and a fake clock; nothing waits.
    • It asserts the status, the call count and that no sleep happened.
    • Removing the deadline check from post_with_retry makes it fail.

    Done when the test passes, fails without the deadline check, and a second test shows Retry-After: 3 being waited out.

    Hint 1: reuse the fake client

    Copy the scripted client from the existing tests and pass sleep=waits.append.

    Hint 2: control time

    Pass clock=lambda: 0.0 and deadline_s=20.0, so a 60 s wait is over the deadline.

    Common mistakes

    • Retrying every POST. Without a key a retry can run the work twice.
    • A new key on every attempt. Then nothing matches.
    • Retrying a 4xx answer. A 400, 403 or 422 fails again; only 408 and 429 are worth a retry.
    • Stacking retries. A client, a gateway and an SDK each retrying three times make 27 attempts.

    Knowledge check

    1. Why can a POST be retried safely only with an idempotency key?

    Without a key the service cannot tell a retry from a new request. POST is not idempotent in RFC 9110.

    2. In the worked example, why only two attempts?

    Two timeouts and a wait use 20.2 s, and the next wait would end at 20.6 s, past the 20 s deadline.

    3. A key is reused with a different body. What happens?

    The capstone returns 409 with the code idempotency_conflict, because the stored body fingerprint does not match. The draft suggests 422, so document your own contract.

    4. Name two reasons the in-memory store is not enough.

    It is per process, so a retry on another replica runs again. Checking and storing are separate steps, so two simultaneous first attempts both run.

    5. When is asynchronous the right choice?

    When the work can outlast the caller's deadline and the caller can continue without the result. The service returns 202 Accepted with a status resource, and a worker runs the job.

    You can now

    • Write the boundary contract for a call into the service.
    • Predict what a retry returns with the same key, a different body and no key.
    • Compute how many attempts a deadline allows.
    • Choose sync or async.

    Further reading

    Last verified: 2026-11-02

    Next

    Next lesson: The integration design note, and when Java wins

    Enroll to keep reading

    Lesson 3 of 5 · Forward Deployed Engineer: Integrate and Ship

    Treat the call into a Python AI service as a contract: deadlines, bounded retries with jitter, idempotency keys that replay a stored answer, and sync or async.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Integrate and Ship, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments