256 lines
9.6 KiB
Python
256 lines
9.6 KiB
Python
"""Idempotency keys.
|
|
|
|
The interesting behaviour is all in the edges: a key reused for a *different*
|
|
request, a key claimed by a request still running, a key held by a request that
|
|
failed. Each of those has a wrong answer that looks reasonable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from app.core.tenant_context import unscoped
|
|
from app.models.auth.user_model import User
|
|
from app.models.system.idempotency_model import IdempotencyRecord
|
|
from app.services.system import idempotency_service
|
|
|
|
PASSWORD = "CorrectHorse!9"
|
|
ACCESSES = ["admin.user.create", "admin.user.read", "admin.user.delete"]
|
|
|
|
|
|
@pytest.fixture
|
|
def workspace(db, tenant_factory, plan_factory, role_factory):
|
|
from types import SimpleNamespace
|
|
|
|
plan = plan_factory(max_users_allowed=50, accesses=ACCESSES)
|
|
tenant = tenant_factory(plan_id=plan.id)
|
|
role = role_factory(tenant=tenant, accesses=ACCESSES)
|
|
return SimpleNamespace(tenant=tenant, tenant_id=tenant.id, role=role)
|
|
|
|
|
|
@pytest.fixture
|
|
def headers(client, db, workspace, user_factory):
|
|
user_factory(tenant=workspace.tenant, role=workspace.role,
|
|
email="retrier@example.com")
|
|
with unscoped():
|
|
db.commit()
|
|
token = client.post("/api/auth/signin",
|
|
json={"email": "retrier@example.com",
|
|
"password": PASSWORD}).json()["access_token"]
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def _create(client, headers, key=None, email="once@example.com"):
|
|
sent = dict(headers)
|
|
if key:
|
|
sent["Idempotency-Key"] = key
|
|
return client.post("/api/user/create", headers=sent,
|
|
json={"email": email, "password": PASSWORD,
|
|
"first_name": "A", "last_name": "B"})
|
|
|
|
|
|
def test_the_same_key_does_the_work_once(client, headers, db, workspace):
|
|
"""The case it exists for: a client posts, the connection drops, and it has
|
|
no way to know whether the work happened."""
|
|
key = str(uuid.uuid4())
|
|
|
|
first = _create(client, headers, key)
|
|
assert first.status_code == 201, first.text
|
|
|
|
second = _create(client, headers, key)
|
|
assert second.status_code == 201
|
|
assert second.json() == first.json(), "the retry got a different answer"
|
|
assert second.headers.get("Idempotent-Replay") == "true"
|
|
|
|
with unscoped():
|
|
assert db.query(User).filter(User.email == "once@example.com").count() == 1
|
|
|
|
|
|
def test_a_replay_is_marked_as_one(client, headers):
|
|
"""A client reconciling its own logs needs to tell "we did it" from "it was
|
|
already done"."""
|
|
key = str(uuid.uuid4())
|
|
first = _create(client, headers, key)
|
|
assert "Idempotent-Replay" not in first.headers
|
|
|
|
second = _create(client, headers, key)
|
|
assert second.headers["Idempotent-Replay"] == "true"
|
|
|
|
|
|
def test_without_a_key_nothing_changes(client, headers, db):
|
|
"""The header is opt-in. Two identical posts with no key are two requests,
|
|
which is what HTTP already promised."""
|
|
first = _create(client, headers, email="twice-a@example.com")
|
|
second = _create(client, headers, email="twice-b@example.com")
|
|
assert first.status_code == 201
|
|
assert second.status_code == 201
|
|
|
|
|
|
def test_different_keys_are_different_requests(client, headers, db):
|
|
assert _create(client, headers, str(uuid.uuid4()),
|
|
"one@example.com").status_code == 201
|
|
assert _create(client, headers, str(uuid.uuid4()),
|
|
"two@example.com").status_code == 201
|
|
|
|
|
|
def test_a_key_reused_for_a_different_request_is_refused(client, headers):
|
|
"""The dangerous case. Answering it with the first response would have a
|
|
client believe a request happened that never did — "charge £10" retried as
|
|
"charge £1000" would quietly return the £10 answer."""
|
|
key = str(uuid.uuid4())
|
|
assert _create(client, headers, key, "first@example.com").status_code == 201
|
|
|
|
clash = _create(client, headers, key, "second@example.com")
|
|
assert clash.status_code == 422
|
|
assert "different request" in clash.json()["detail"].lower()
|
|
|
|
|
|
def test_a_key_still_in_progress_is_told_to_wait(client, headers, db, workspace):
|
|
"""Two simultaneous requests: the second must not proceed. Simulated by
|
|
leaving the claim behind, which is exactly the state the first would be in."""
|
|
key = str(uuid.uuid4())
|
|
with unscoped():
|
|
user_id = db.query(User.id).filter(
|
|
User.email == "retrier@example.com"
|
|
).scalar()
|
|
db.add(IdempotencyRecord(
|
|
tenant_id=workspace.tenant_id,
|
|
user_id=user_id,
|
|
idempotency_key=key,
|
|
endpoint="POST /api/user/create",
|
|
request_hash=idempotency_service.hash_request(b"anything"),
|
|
state="in_progress",
|
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
))
|
|
db.commit()
|
|
|
|
response = _create(client, headers, key)
|
|
assert response.status_code in (409, 422)
|
|
|
|
|
|
def test_a_failed_request_does_not_hold_the_key(client, headers, db):
|
|
"""A 500 is the response a client most wants to retry. Caching it would turn
|
|
one bad moment into a permanent one."""
|
|
key = str(uuid.uuid4())
|
|
|
|
bad = client.post("/api/user/create",
|
|
headers={**headers, "Idempotency-Key": key},
|
|
json={"email": "weak@example.com", "password": "password",
|
|
"first_name": "A", "last_name": "B"})
|
|
assert bad.status_code == 400
|
|
|
|
with unscoped():
|
|
assert db.query(IdempotencyRecord).filter(
|
|
IdempotencyRecord.idempotency_key == key
|
|
).count() == 0, "a failed response held the key"
|
|
|
|
fixed = _create(client, headers, key, "weak@example.com")
|
|
assert fixed.status_code == 201
|
|
|
|
|
|
def test_an_expired_key_is_treated_as_fresh(client, headers, db, workspace):
|
|
"""The client has clearly moved on. Refusing would be a puzzle with no way
|
|
to resolve it."""
|
|
key = str(uuid.uuid4())
|
|
assert _create(client, headers, key, "stale@example.com").status_code == 201
|
|
|
|
with unscoped():
|
|
record = db.query(IdempotencyRecord).filter(
|
|
IdempotencyRecord.idempotency_key == key
|
|
).one()
|
|
record.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
db.commit()
|
|
|
|
again = _create(client, headers, key, "stale2@example.com")
|
|
assert again.status_code == 201
|
|
assert "Idempotent-Replay" not in again.headers
|
|
|
|
|
|
def test_an_absurdly_long_key_is_refused(client, headers):
|
|
response = _create(client, headers, "x" * 500)
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_the_key_is_ignored_on_reads(client, headers):
|
|
"""GET is already idempotent. A key on one would be a cache, which is a
|
|
different feature with different rules about staleness."""
|
|
key = str(uuid.uuid4())
|
|
first = client.get("/api/user/get", headers={**headers, "Idempotency-Key": key})
|
|
assert first.status_code == 200
|
|
assert "Idempotent-Replay" not in first.headers
|
|
|
|
|
|
def test_two_workspaces_can_use_the_same_key(client, headers, db,
|
|
tenant_factory, plan_factory,
|
|
role_factory, user_factory):
|
|
"""A key is not a secret, and two customers picking the same random string
|
|
must not collide — nor be able to read each other's response by guessing."""
|
|
key = str(uuid.uuid4())
|
|
mine = _create(client, headers, key, "mine@example.com")
|
|
assert mine.status_code == 201
|
|
|
|
other = tenant_factory(plan_id=plan_factory(max_users_allowed=5,
|
|
accesses=ACCESSES).id)
|
|
other_role = role_factory(tenant=other, accesses=ACCESSES)
|
|
user_factory(tenant=other, role=other_role, email="theirs@example.com")
|
|
with unscoped():
|
|
db.commit()
|
|
|
|
token = client.post("/api/auth/signin",
|
|
json={"email": "theirs@example.com",
|
|
"password": PASSWORD}).json()["access_token"]
|
|
theirs = _create(client, {"Authorization": f"Bearer {token}"}, key,
|
|
"notmine@example.com")
|
|
|
|
assert theirs.status_code == 201
|
|
assert theirs.json()["email"] == "notmine@example.com", (
|
|
"one workspace was served another's stored response"
|
|
)
|
|
|
|
|
|
def test_the_same_key_on_a_different_endpoint_is_a_different_request(
|
|
client, headers, db
|
|
):
|
|
key = str(uuid.uuid4())
|
|
created = _create(client, headers, key, "moved@example.com")
|
|
assert created.status_code == 201
|
|
|
|
listed = client.post("/api/user/create",
|
|
headers={**headers, "Idempotency-Key": key},
|
|
json={"email": "moved@example.com", "password": PASSWORD,
|
|
"first_name": "A", "last_name": "B"})
|
|
assert listed.json() == created.json()
|
|
|
|
|
|
def test_expired_keys_are_purged(db, workspace):
|
|
with unscoped():
|
|
db.add(IdempotencyRecord(
|
|
tenant_id=workspace.tenant_id,
|
|
idempotency_key="old",
|
|
endpoint="POST /api/thing",
|
|
request_hash="0" * 64,
|
|
state="completed",
|
|
expires_at=datetime.now(timezone.utc) - timedelta(days=2),
|
|
))
|
|
db.add(IdempotencyRecord(
|
|
tenant_id=workspace.tenant_id,
|
|
idempotency_key="current",
|
|
endpoint="POST /api/thing",
|
|
request_hash="0" * 64,
|
|
state="completed",
|
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
))
|
|
db.flush()
|
|
|
|
removed = idempotency_service.purge_expired(db)
|
|
assert removed == 1
|
|
remaining = {
|
|
r.idempotency_key for r in db.query(IdempotencyRecord).filter(
|
|
IdempotencyRecord.tenant_id == workspace.tenant_id
|
|
).all()
|
|
}
|
|
assert remaining == {"current"}
|