HTTP idempotency concerns the intended effect of repeating an identical request, not whether every response looks the same. This distinction matters when a connection closes after the server applies a change but before the client receives the answer. Repetition can be appropriate, but only within the operation’s actual semantics and the client’s retry policy.

Same effect, not identical responses

RFC 9110 defines an idempotent method by comparing the intended server-side effect of multiple identical requests with one request. PUT, DELETE, and the safe methods are idempotent. A successful first PUT might create a resource and return 201; repeating it might replace that resource and return 204. Deleting an existing resource and then an absent one can likewise produce different status codes while leaving it absent.

“Safe” is a separate property: the client does not request a state change. GET, HEAD, OPTIONS, and TRACE are defined as safe. Incidental access logging is allowed, so safe does not mean literally no writes anywhere. PUT and DELETE are idempotent but not safe.

Method semantics require an implementation

Calling an incrementing endpoint PUT does not magically make repeated increments idempotent. The server must implement the advertised meaning. Likewise, a GET route that deletes a requested object contradicts safe-method semantics; prefetchers and crawlers can trigger it without intending a destructive action.

POST is not generally idempotent, but a particular POST operation can have an application-level deduplication contract. It is also not restricted to resource creation. Understand that contract before automatic replay rather than assuming every POST is impossible to retry or every method name guarantees a correct implementation.

Naturally repeatable writes and remembered operation keys solve related but distinct problems. Replacing a resource with a specified representation expresses desired state. Replaying a keyed order creation recognizes a particular action. Neither means every internal function executes exactly once.

Preconditions address competing changes

Idempotency alone does not prevent overwriting another client’s intervening update. Repeating an unconditional replacement can restore your old value over someone else’s newer value. A strong entity tag with If-Match can condition a write on the representation you actually observed.

A conditional retry can also fail after your original write succeeded but its reply was lost: the original entity tag no longer matches. The fixture returns 412 in that case. RFC 9110 also permits a success response when the requested change appears already applied; do not assume every implementation makes the same choice.

Reconcile the current resource or consult an operation record when an outcome is ambiguous. A status code alone does not reconstruct the full history of concurrent writers.

When it breaks

Blind retries can amplify load, exceed deadlines, repeat billing side effects, or overwrite intervening work. Bound attempts and elapsed time, preserve the intended target and request, and respect applicable server guidance. Idempotency is neither a concurrency-control strategy nor permission to retry indefinitely.

Also distinguish intended resource changes from incidental logging or revision history. Two diagnostic entries do not by themselves prove duplicate business effects. Conversely, two identical successful responses do not prove that only one business action occurred; inspect authoritative state.

Check it quickly

Save the block as methods_check.py and run python3 methods_check.py with Python 3.10 or newer. It ran with Python 3.14.4 and uses only the standard library. It sends real HTTP requests to its own loopback server on an assigned port, then stops it. All business state remains in fixture memory.

from http.client import HTTPConnection, RemoteDisconnected
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread


state = {"body": None, "version": 0, "orders": [], "requests": []}


def etag():
    return '"v%d"' % state["version"]


class Handler(BaseHTTPRequestHandler):
    def handle_request(self):
        state["requests"].append((self.command, self.path))
        body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
        status, answer = 404, b""
        if self.path == "/item":
            if self.command in ("GET", "HEAD"):
                if state["body"] is not None:
                    status, answer = 200, state["body"]
            elif self.command == "PUT":
                condition = self.headers.get("If-Match")
                # The fixture accepts one exact strong entity tag, not the full grammar.
                if condition is not None and (state["body"] is None or condition != etag()):
                    status = 412
                else:
                    status = 201 if state["body"] is None else 204
                    if body != state["body"]:
                        state["version"] += 1
                    state["body"] = body
            elif self.command == "DELETE":
                status = 404 if state["body"] is None else 204
                state["body"] = None
        elif self.path == "/orders" and self.command == "POST":
            state["orders"].append(body)
            status, answer = 201, str(len(state["orders"])).encode()
        if self.headers.get("X-Fixture-Drop-Reply") == "yes" and 200 <= status < 300:
            self.close_connection = True  # Apply the change, then close without a response.
            return
        self.send_response(status)
        if self.path == "/item" and state["body"] is not None:
            self.send_header("ETag", etag())
        if status != 204:
            self.send_header("Content-Length", str(len(answer)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(answer)

    do_GET = do_HEAD = do_PUT = do_DELETE = do_POST = handle_request

    def log_message(self, *_):
        pass


with HTTPServer(("127.0.0.1", 0), Handler) as server:
    thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
    thread.start()

    def request(method, route="/item", body=b"", headers=None):
        client = HTTPConnection(*server.server_address, timeout=2)
        try:
            client.request(method, route, body=body, headers=headers or {})
            response = client.getresponse()
            return response.status, response.read(), response.getheader("ETag")
        finally:
            client.close()

    def lost_put(body, headers=None):
        try:
            request("PUT", body=body, headers={**(headers or {}), "X-Fixture-Drop-Reply": "yes"})
        except RemoteDisconnected:
            return
        raise AssertionError("the fixture should close before sending a response")

    try:
        assert request("PUT", body=b"blue")[0] == 201
        assert request("PUT", body=b"blue")[0] == 204
        before = len(state["requests"])
        assert request("GET")[1] == request("GET")[1] == b"blue"
        assert len(state["requests"]) == before + 2 and state["body"] == b"blue"
        assert request("HEAD")[1] == b""
        assert [request("DELETE")[0], request("DELETE")[0]] == [204, 404]
        print("PUT returns 201 then 204; DELETE returns 204 then 404; intended state is unchanged by repeats")
        print("GET appends separate diagnostic records without changing the requested resource")

        lost_put(b"blue")
        assert request("GET")[1] == b"blue"
        assert request("PUT", body=b"blue")[0] == 204
        old_tag = request("GET")[2]
        lost_put(b"green", {"If-Match": old_tag})
        assert request("PUT", body=b"green", headers={"If-Match": old_tag})[0] == 412
        assert request("GET")[1] == b"green"
        print("a lost reply can follow a committed PUT; replaying its old precondition can return 412")

        tag = request("GET")[2]
        assert request("PUT", body=b"other-writer")[0] == 204
        assert request("PUT", body=b"green", headers={"If-Match": tag})[0] == 412
        assert request("GET")[1] == b"other-writer"
        assert request("PUT", body=b"green")[0] == 204
        assert request("GET")[1] == b"green"
        print("a stale conditional PUT is refused; an unconditional repeat overwrites the intervening writer")

        first = request("POST", "/orders", b"same order")
        second = request("POST", "/orders", b"same order")
        assert first[:2] == (201, b"1") and second[:2] == (201, b"2")
        assert state["orders"] == [b"same order", b"same order"]
        print("two identical POST bodies create two orders in this endpoint's contract")
    finally:
        server.shutdown()
        thread.join(timeout=2)
        assert not thread.is_alive()

Repeated PUT produces 201 then 204; repeated DELETE produces 204 then 404. GET adds two diagnostic records without changing the requested resource, and HEAD returns no body. The test compares state and responses separately.

The server then deliberately applies PUT and closes without replying. An unconditional retry preserves the requested value; replaying an old If-Match after a successful conditional write returns 412. A separate writer’s update survives a stale conditional request but is overwritten by an unconditional replacement.

Finally, identical POST bodies create two orders under this fixture’s unkeyed creation contract. These assertions exercise transport ambiguity and state transitions, not production durability, distributed concurrency, complete HTTP parsing, or business deduplication. If-Match accepts one exact strong tag here, not its full grammar. Python’s basic HTTP server is only the isolated test harness.

Further reading

Sources

  • RFC 9110, sections 9.2.1–9.2.2 and 13.1.1 distinguish safe methods, idempotency, retries, and If-Match, checked 2026-09-08.
  • MDN: Idempotent, intended effects, implementation responsibility, and differing responses, checked 2026-09-08.
  • Python HTTP server, the local fixture’s server API and production-use warning, checked 2026-09-08.