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):
| Endpoint | Klucz 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 authPOST /auth/register— duplikat email zwraca 409POST /auth/2fa/*— TOTP czas-zależnyPOST /reviews— każdy request to nowy review (samebooking_id+user_id= 409 — patient już dodał review)POST /bookings/{id}/cancel— second call zwraca 409already_cancelled