92 lines
3.9 KiB
Python
92 lines
3.9 KiB
Python
"""Making a retried request safe to retry.
|
|
|
|
A client posts, the connection drops before the response arrives, and now nobody
|
|
knows whether it worked. Every integration hits this eventually, and there are
|
|
only two behaviours available: retry and risk doing it twice, or do not retry and
|
|
risk not doing it at all. Both are wrong.
|
|
|
|
An `Idempotency-Key` header makes the choice unnecessary. The first request runs
|
|
and its response is kept; a repeat with the same key gets that same response back
|
|
without the work happening again.
|
|
|
|
Three columns carry the weight:
|
|
|
|
**`request_hash`.** Reusing a key for a *different* request is a client bug, and
|
|
the dangerous kind: without this, "charge £10" retried as "charge £1000" would
|
|
quietly return the £10 response and the caller would believe the second one
|
|
happened. Mismatched hashes are refused rather than answered.
|
|
|
|
**`state`.** A key inserted before the work starts is what makes two simultaneous
|
|
requests safe — the second loses the unique index and is told the first is still
|
|
running, rather than both proceeding.
|
|
|
|
**`expires_at`.** A retry happens within seconds or minutes. Keeping keys for ever
|
|
would mean a client that generates them per hour eventually collides with its own
|
|
history, and the table grows without bound for no benefit.
|
|
|
|
Revision ID: c9f2b5d7e10d
|
|
Revises: b8e1c4a6f90c
|
|
"""
|
|
|
|
from typing import Sequence, Union
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
|
|
revision: str = "c9f2b5d7e10d"
|
|
down_revision: Union[str, Sequence[str], None] = "b8e1c4a6f90c"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
"idempotency_records",
|
|
sa.Column("id", UUID(as_uuid=True), primary_key=True,
|
|
server_default=sa.text("gen_random_uuid()")),
|
|
sa.Column("tenant_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
|
sa.Column("user_id", UUID(as_uuid=True),
|
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
|
|
sa.Column("idempotency_key", sa.String(255), nullable=False),
|
|
sa.Column("endpoint", sa.String(255), nullable=False),
|
|
sa.Column("request_hash", sa.String(64), nullable=False),
|
|
sa.Column("state", sa.String(20), nullable=False, server_default="in_progress"),
|
|
sa.Column("response_status", sa.Integer(), nullable=True),
|
|
sa.Column("response_body", JSONB(), nullable=True),
|
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.func.now()),
|
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
|
sa.UniqueConstraint("tenant_id", "user_id", "idempotency_key", "endpoint",
|
|
name="uq_idempotency_scope"),
|
|
)
|
|
op.create_index(
|
|
"ix_idempotency_expiry", "idempotency_records", ["expires_at"],
|
|
)
|
|
|
|
op.execute("ALTER TABLE idempotency_records ENABLE ROW LEVEL SECURITY")
|
|
op.execute("ALTER TABLE idempotency_records FORCE ROW LEVEL SECURITY")
|
|
op.execute(
|
|
"""
|
|
CREATE POLICY tenant_isolation ON idempotency_records
|
|
USING (
|
|
current_setting('app.bypass_rls', true) = 'on'
|
|
OR tenant_id IS NULL
|
|
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
|
)
|
|
WITH CHECK (
|
|
current_setting('app.bypass_rls', true) = 'on'
|
|
OR tenant_id IS NULL
|
|
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
|
|
)
|
|
"""
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.execute("DROP POLICY IF EXISTS tenant_isolation ON idempotency_records")
|
|
op.drop_index("ix_idempotency_expiry", table_name="idempotency_records")
|
|
op.drop_table("idempotency_records")
|