From b0f5593d9f5d482723be0355b587d854948a2f87 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:40:01 +0530 Subject: [PATCH 1/6] Preserve idempotency key across reschedules From 7443807f6465d07f594f6deb6cb244379de0ed83 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:40:06 +0530 Subject: [PATCH 2/6] Enforce holds and booking limits consistently --- src/slotify/availability.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/slotify/availability.py b/src/slotify/availability.py index a7c797f..5c62227 100644 --- a/src/slotify/availability.py +++ b/src/slotify/availability.py @@ -666,6 +666,7 @@ def hold( if expires_at <= current: raise ValueError("expires_at must be in the future.") self.policy.validate(slot, now=current) + self._validate_limits(slot, now=current) if self.resource_pool: for resource_id in self._ordered_pool_resources(slot): candidate = self._candidate(slot, resource_id=resource_id) @@ -697,6 +698,8 @@ def confirm_hold( """Convert an active hold into a confirmed booking.""" current = self._current(now, self.generator) hold = self.hold_store.get(hold_id, now=current) + self.policy.validate(hold.slot, now=current) + self._validate_limits(hold.slot, now=current) booking = self._candidate( hold.slot, resource_id=hold.resource_id, @@ -877,6 +880,11 @@ def reschedule( candidate = booking.reschedule(new_slot) + if self.hold_store.conflicts(candidate, now=current): + raise BookingConflictError( + "The requested interval is temporarily held." + ) + available = self.store.available_capacity( candidate, capacity=self.capacity, From 6836d35e98d5651593c411feaf0f9033a60a19b8 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:40:36 +0530 Subject: [PATCH 3/6] Harden idempotency retries and reject key reuse for another slot --- src/slotify/availability.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/slotify/availability.py b/src/slotify/availability.py index 5c62227..2ff5a5c 100644 --- a/src/slotify/availability.py +++ b/src/slotify/availability.py @@ -728,9 +728,6 @@ def reserve( generator = self._generator_for(schedule_name) current = self._current(now, generator) - self.policy.validate(slot, now=current) - self._validate_limits(slot, now=current) - if idempotency_key is not None: if not isinstance(idempotency_key, str) or not idempotency_key.strip(): raise ValueError("idempotency_key must be a non-empty string or None.") @@ -738,8 +735,15 @@ def reserve( if finder is not None: existing = finder(idempotency_key) if existing is not None: + if existing.slot != slot: + raise BookingConflictError( + "The idempotency key is already associated with a different slot." + ) return existing + self.policy.validate(slot, now=current) + self._validate_limits(slot, now=current) + if self.resource_pool: last_error: BookingConflictError | None = None for resource_id in self._ordered_pool_resources( @@ -778,14 +782,6 @@ def reserve( "No resource in the resource pool is available." ) from last_error - if idempotency_key is not None: - if not isinstance(idempotency_key, str) or not idempotency_key.strip(): - raise ValueError("idempotency_key must be a non-empty string or None.") - finder = getattr(self.store, "find_by_idempotency_key", None) - if finder is not None: - existing = finder(idempotency_key) - if existing is not None: - return existing booking = self._booking_for( slot, metadata=metadata, From 58bdd92639e0db07f95b084895f35a2cde0923c5 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:40:47 +0530 Subject: [PATCH 4/6] Add adversarial lifecycle regression tests --- tests/test_v050_features.py | 63 +++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_v050_features.py b/tests/test_v050_features.py index f872416..2fff2fa 100644 --- a/tests/test_v050_features.py +++ b/tests/test_v050_features.py @@ -92,3 +92,66 @@ def test_different_idempotency_keys_still_conflict(): engine.reserve(slot, idempotency_key="one", now=now) with pytest.raises(BookingConflictError): engine.reserve(slot, idempotency_key="two", now=now) + + +def test_idempotency_key_cannot_be_reused_for_another_slot(): + engine = AvailabilityEngine(make_generator()) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + engine.reserve(slots[0], idempotency_key="same-request", now=now) + with pytest.raises(BookingConflictError): + engine.reserve(slots[1], idempotency_key="same-request", now=now) + + +def test_reschedule_preserves_idempotency_key(): + engine = AvailabilityEngine(make_generator()) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + booking = engine.reserve( + slots[0], idempotency_key="checkout-456", now=now + ) + moved = engine.reschedule(booking.booking_id, slots[1], now=now) + assert moved.idempotency_key == "checkout-456" + + +def test_hold_respects_booking_limits(): + policy = BookingPolicy(max_upcoming_bookings=1) + engine = AvailabilityEngine(make_generator(), policy=policy) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + engine.reserve(slots[0], now=now) + with pytest.raises(SlotUnavailableError): + engine.hold( + slots[1], + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + + +def test_confirm_hold_rechecks_booking_limits(): + policy = BookingPolicy(max_upcoming_bookings=1) + engine = AvailabilityEngine(make_generator(), policy=policy) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + hold = engine.hold( + slots[0], + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + engine.reserve(slots[1], now=now) + with pytest.raises(SlotUnavailableError): + engine.confirm_hold(hold.hold_id, now=now) + + +def test_reschedule_cannot_bypass_active_hold(): + engine = AvailabilityEngine(make_generator()) + slots = make_generator().generate_for_date("2026-10-05") + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + booking = engine.reserve(slots[0], now=now) + engine.hold( + slots[1], + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + with pytest.raises(BookingConflictError): + engine.reschedule(booking.booking_id, slots[1], now=now) From 24ad06bc5dc2dc2ad277bcfe61eac63a53c0d14a Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:40:54 +0530 Subject: [PATCH 5/6] Import lifecycle exception for edge tests --- tests/test_v050_features.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_v050_features.py b/tests/test_v050_features.py index 2fff2fa..0176869 100644 --- a/tests/test_v050_features.py +++ b/tests/test_v050_features.py @@ -8,6 +8,7 @@ BusyPeriod, BookingConflictError, SlotGenerator, + SlotUnavailableError, ) From 284ca2a82487b6010b2778c767b35589c001b55c Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:41:31 +0530 Subject: [PATCH 6/6] Actually preserve idempotency key on reschedule --- src/slotify/booking.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/slotify/booking.py b/src/slotify/booking.py index 188a093..fad6bfd 100644 --- a/src/slotify/booking.py +++ b/src/slotify/booking.py @@ -174,6 +174,7 @@ def reschedule(self, new_slot: Slot) -> "Booking": resource_ids=self.resource_ids, series_id=self.series_id, rescheduled_count=self.rescheduled_count + 1, + idempotency_key=self.idempotency_key, ) def to_dict(self) -> dict[str, Any]: