Przejdź do głównej zawartości

Idempotency

Idempotent = ten sam request wykonany 1× lub N× ma ten sam efekt. Kluczowe gdy sieć zawodzi (timeout, 502 od proxy) — możesz retry'ować bez tworzenia duplikatów.

Pandoktorek default — idempotency po external_id​

Wszystkie endpointy ingest są idempotent po (clinic_id, external_id):

EndpointKlucz idempotency
POST /api/v1/clinic/doctors(clinic_id, external_id)
POST /api/v1/clinic/services(clinic_id, external_id)
POST /api/v1/clinic/slots(clinic_id, external_id)
DELETE /api/v1/clinic/slots/{external_id}(clinic_id, external_id)
# Pierwszy push
curl -X POST .../clinic/doctors -d '{"doctors":[{"external_id":"mydr-12345",...}]}'
→ {"created": 1, "updated": 0, "errors": []}

# Re-push tego samego payloadu (np. po timeoutie)
curl -X POST .../clinic/doctors -d '{"doctors":[{"external_id":"mydr-12345",...}]}'
→ {"created": 0, "updated": 1, "errors": []} # update, NIE duplikat

Implementacja po stronie Pandoktorka: INSERT ... ON CONFLICT (clinic_id, external_id) DO UPDATE — atomic, race-safe.

Booking POST /bookings​

Booking NIE jest idempotent — każde wywołanie tworzy próbę rezerwacji. Powód: pacjent może świadomie zarezerwować dwa różne sloty (różne slot_id w body).

Race-safe: atomic UPDATE WHERE status=available — jeden wygrywa (201), loser dostaje 409 Conflict.

Jeśli twój system robi booking on-behalf-of, zaimplementuj client-side deduplication: zapisz (patient_email, slot_id) → booking_id w cache przez 1h. Jeśli widzisz 2× to samo w 1s — to retry, użyj cached booking_id.

Webhook delivery — idempotency po event_id​

Każdy webhook ma unikalny event_id (UUID v4) w body i headerze X-Pandoktorek-Event-Id:

{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "booking.created",
"payload": { ... }
}

Pandoktorek może retry'ować ten sam event po timeoutie. Twój system MUSI deduplikować po event_id (np. tabela processed_events z event_id PK, lub Redis SET z TTL 24h).

@app.post("/webhooks/pandoktorek")
async def receive(request):
body = await request.body()
event = json.loads(body)
event_id = event["event_id"]

# Idempotent guard
if await redis.exists(f"event:{event_id}"):
return Response(status_code=200) # already processed

# Verify signature, process...
process_event(event)

await redis.setex(f"event:{event_id}", 86400, "1")
return Response(status_code=200)

→ Webhook signature verification

Stripe webhook (Pandoktorek odbiera od Stripe)​

POST /webhooks/stripe jest idempotent po Stripe-Signature + event.id. To wewnętrzny endpoint — nie dotyczy klinki integratora, ale wspominamy bo audit log to pokazuje.

Best practice — retry pattern​

def push_with_idempotent_retry(payload, max_retries=3, backoff=2):
for attempt in range(max_retries):
try:
r = requests.post(url, json=payload, headers=headers, timeout=10)
if r.status_code < 500:
return r # success or client error — nie retry
except (requests.Timeout, requests.ConnectionError):
pass
time.sleep(backoff ** attempt) # 1s, 2s, 4s
raise Exception("Max retries exceeded")

Dlaczego retry tylko na 5xx / network errors:

  • 2xx — success, nic nie robić
  • 4xx — client error (zła walidacja, brak auth) — retry niczego nie zmieni
  • 5xx — server error / proxy timeout — retry sensowny
  • timeout — request mógł się powieść po stronie Pandoktorka, ale response się zgubił — idempotent endpoint to wybacza

Co NIE jest idempotent​

  • POST /auth/login — każdy request to próba auth
  • POST /auth/register — duplikat email zwraca 409
  • POST /auth/2fa/* — TOTP czas-zależny
  • POST /reviews — każdy request to nowy review (same booking_id+ user_id = 409 — patient już dodał review)
  • POST /bookings/{id}/cancel — second call zwraca 409 already_cancelled