Files
saas_backend/alembic/versions/c9f2b5d7e10d_idempotency_records.py
T

92 lines
3.9 KiB
Python
Raw Normal View History

2026-08-31 20:04:12 -04:00
"""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")