Ana içeriğe geç
Kesit Python
İleri · Web ve API

API tasarımına giriş: HTTP, doğrulama ve ilk servis

HTTP'nin temelleri, requests/httpx kullanım kalıpları, FastAPI ile uç nokta yazma, JSON doğrulama, hata kodları, yeniden deneme ve zaman aşımı; tarayıcıda sahte istemci ve fonksiyon tabanlı yönlendiriciyle çalıştır.

Uygulamada aç, 21 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Neden önemli?

Kurumsal Python kodunun büyük bölümü bir ağ sınırında durur: Anadolu Market'in sipariş servisi kargo firmasının API'sine istek atar, Ilgaz Bank'ın kredi modülü skor servisinden yanıt bekler. Bu sınırda her şey ters gidebilir: zaman aşımı, 503, bozuk JSON, beklenmeyen alan. Bu derste HTTP'yi, istemci kütüphanelerini ve bir API yazmanın iskeletini öğreneceksin. Tarayıcıda ağ erişimi olmadığı için örnekler sahte istemci (fake client) ile çalışır; gerçek requests/httpx/FastAPI kodu gösterim olarak verilir. Sahte istemci yaklaşımı aynı zamanda ekiplerin birim testte kullandığı yöntemdir, boşa gitmez.

HTTP temelleri

Bir HTTP isteği: yöntem (GET, POST, PUT, PATCH, DELETE), yol (/siparisler/42), başlıklar (Authorization, Content-Type) ve gövde (genelde JSON). Yanıt: durum kodu, başlıklar, gövde. GET ve DELETE idempotent (tekrarlanınca aynı sonuç), POST değildir; bu yüzden POST'u körlemesine yeniden denemek çift sipariş yaratabilir.

KodAnlamıİstemci ne yapmalı
200 / 201Başarılı / oluşturulduGövdeyi işle
204Başarılı, gövde yokJSON parse etmeye çalışma
400 / 422Hatalı istek / doğrulama hatasıYeniden deneme; girdiyi düzelt
401 / 403Kimlik yok / yetki yokToken yenile ya da dur
404Kaynak yokİş kuralına göre karar
429Çok fazla istekRetry-After kadar bekle
500 / 502 / 503Sunucu hatasıÜstel geri çekilmeyle yeniden dene

requests ve httpx kalıpları

İki kütüphane de benzer arayüz sunar; httpx async ve HTTP/2 destekler. Üç kural: her isteğe zaman aşımı ver (varsayılan sonsuzdur), oturum (Session/Client) kullan (bağlantı tekrar kullanımı, ortak başlık), raise_for_status() ile 4xx/5xx'i istisnaya çevir.

# Gösterim: gerçek projede böyle yazılır (burada çalışmaz)
GOSTERIM = '''
import httpx

with httpx.Client(base_url="https://api.anadolu.example",
                  headers={"Authorization": "Bearer TOKEN"},
                  timeout=httpx.Timeout(5.0, connect=2.0)) as client:
    r = client.get("/siparisler", params={"durum": "bekliyor", "limit": 50})
    r.raise_for_status()
    siparisler = r.json()
    r2 = client.post("/siparisler", json={"musteri": 10, "tutar": 99.9})
    print(r2.status_code, r2.json()["id"])
'''
print(GOSTERIM.strip().splitlines()[0])
print('Satır sayısı:', len(GOSTERIM.strip().splitlines()))

Şimdi aynı sözleşmeye uyan, elimizdeki yanıtları döndüren küçük bir sahte istemci yazalım. Gerçek kodda bu sınıfın yerine httpx.Client geçer; iş mantığı değişmez.

import json

class SahteYanit:
    def __init__(self, status_code, govde='', headers=None):
        self.status_code = status_code
        self.text = govde
        self.headers = headers or {}
    def json(self):
        return json.loads(self.text)
    def raise_for_status(self):
        if self.status_code >= 400:
            raise RuntimeError(f'HTTP {self.status_code}')

class SahteIstemci:
    """Yol -> yanıt tablosu; gerçek httpx.Client ile aynı yüzey."""
    def __init__(self, tablo):
        self.tablo = tablo
        self.istekler = []
    def get(self, yol, params=None, timeout=5.0):
        self.istekler.append(('GET', yol, params))
        return self.tablo.get(yol, SahteYanit(404, '{"detail":"yok"}'))
    def post(self, yol, json=None, timeout=5.0):
        self.istekler.append(('POST', yol, json))
        return self.tablo.get(yol, SahteYanit(404, '{"detail":"yok"}'))

client = SahteIstemci({
    '/siparisler/42': SahteYanit(200, '{"id": 42, "tutar": 149.9, "durum": "kargoda"}'),
    '/siparisler/99': SahteYanit(404, '{"detail": "sipariş yok"}'),
})

def siparis_getir(client, no):
    r = client.get(f'/siparisler/{no}', timeout=5.0)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()

print(siparis_getir(client, 42))
print(siparis_getir(client, 99))
print(client.istekler)
Zaman aşımı yoksa üretimde askıda kalırsınrequests.get(url) varsayılan olarak sonsuza kadar bekler. Bir bağlantı zaman aşımı (connect) ve bir okuma zaman aşımı (read) belirle; httpx'te Timeout(5.0, connect=2.0), requests'te timeout=(2, 5).

FastAPI ile uç nokta

FastAPI, fonksiyon imzasındaki tip ipuçlarından (type hint) hem doğrulamayı hem belgeyi üretir. Gövde modeli pydantic BaseModel'dir: alan tipleri uymazsa otomatik 422 döner.

GOSTERIM = '''
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

app = FastAPI()

class SiparisIstek(BaseModel):
    musteri_id: int
    tutar: float = Field(gt=0)
    sehir: str

DB: dict[int, dict] = {}

@app.post("/siparisler", status_code=201)
def siparis_olustur(s: SiparisIstek):
    no = len(DB) + 1
    DB[no] = s.model_dump() | {"id": no, "durum": "bekliyor"}
    return DB[no]

@app.get("/siparisler/{no}")
def siparis_getir(no: int):
    if no not in DB:
        raise HTTPException(status_code=404, detail="sipariş yok")
    return DB[no]
'''
print('FastAPI uç noktaları:', [s.strip() for s in GOSTERIM.splitlines() if s.startswith('@app')])

Pyodide'de FastAPI yok; ama mantığı fonksiyon tabanlı yönlendirici (router) ile birebir kurabiliriz: yol deseni + yöntem bir fonksiyona eşlenir, gövde bir doğrulayıcıdan geçer, hata durumunda kod ve detay döner.

import json, re
from dataclasses import dataclass, asdict

class HTTPHata(Exception):
    def __init__(self, kod, detay):
        self.kod, self.detay = kod, detay

@dataclass
class SiparisIstek:          # pydantic mantığı, elle
    musteri_id: int
    tutar: float
    sehir: str
    @classmethod
    def dogrula(cls, ham: dict):
        hatalar = []
        if not isinstance(ham.get('musteri_id'), int): hatalar.append('musteri_id int olmalı')
        t = ham.get('tutar')
        if not isinstance(t, (int, float)) or t <= 0: hatalar.append('tutar > 0 olmalı')
        if not isinstance(ham.get('sehir'), str) or not ham['sehir']: hatalar.append('sehir boş olamaz')
        if hatalar:
            raise HTTPHata(422, hatalar)
        return cls(ham['musteri_id'], float(t), ham['sehir'])

class Uygulama:
    def __init__(self):
        self.rotalar = []          # (yöntem, regex, fonksiyon)
    def rota(self, yontem, desen):
        rx = re.compile('^' + re.sub(r'\{(\w+)\}', r'(?P<\1>[^/]+)', desen) + '$')
        def kaydet(fn):
            self.rotalar.append((yontem, rx, fn)); return fn
        return kaydet
    def istek(self, yontem, yol, govde=None):
        for y, rx, fn in self.rotalar:
            m = rx.match(yol)
            if m and y == yontem:
                try:
                    return 200, fn(**m.groupdict(), govde=govde)
                except HTTPHata as e:
                    return e.kod, {'detail': e.detay}
        return 404, {'detail': 'rota yok'}

app = Uygulama()
DB = {}

@app.rota('POST', '/siparisler')
def olustur(govde):
    s = SiparisIstek.dogrula(govde)
    no = len(DB) + 1
    DB[no] = asdict(s) | {'id': no, 'durum': 'bekliyor'}
    return DB[no]

@app.rota('GET', '/siparisler/{no}')
def getir(no, govde=None):
    no = int(no)
    if no not in DB:
        raise HTTPHata(404, 'sipariş yok')
    return DB[no]

print(app.istek('POST', '/siparisler', {'musteri_id': 7, 'tutar': 99.9, 'sehir': 'Bursa'}))
print(app.istek('POST', '/siparisler', {'musteri_id': '7', 'tutar': -1}))
print(app.istek('GET', '/siparisler/1'))
print(app.istek('GET', '/siparisler/5'))
Doğrulama sınırda, güven içerideGövdeyi uç noktada bir kez doğrula; iç fonksiyonlara doğrulanmış dataclass/pydantic nesnesi ver. Hata yanıtı tutarlı bir şekil taşısın: {"detail": ...}. Günlük kaydına istek kimliğini (request id) ekle; destek ekibi hatayı bununla bulur.

Yeniden deneme ve zaman aşımı

Geçici hatalar (429, 502, 503, bağlantı kopması) için üstel geri çekilme (exponential backoff) kullan: 1 s, 2 s, 4 s... ve küçük rastgele sapma (jitter) ekle ki yüzlerce istemci aynı anda tekrar vurmasın. 4xx hatalarında (429 hariç) yeniden deneme yapma; istek zaten yanlış. POST'ta yeniden deneme yapacaksan Idempotency-Key başlığı gönder.

import random

def geri_cekilme_sureleri(deneme, taban=1.0, tavan=30.0, jitter=0.2):
    """deneme 0,1,2... için bekleme süreleri (saniye)."""
    sure = min(tavan, taban * 2 ** deneme)
    return round(sure * (1 + random.uniform(-jitter, jitter)), 2)

def yeniden_dene(fn, deneme_sayisi=4, uyku=lambda s: None):
    son_hata = None
    for i in range(deneme_sayisi):
        try:
            r = fn()
            if r.status_code in (429, 502, 503):
                raise RuntimeError(f'geçici: {r.status_code}')
            return r
        except RuntimeError as e:
            son_hata = e
            if i < deneme_sayisi - 1:
                uyku(geri_cekilme_sureleri(i))
    raise RuntimeError(f'{deneme_sayisi} denemede başarısız: {son_hata}')

random.seed(1)
yanitlar = iter([SahteYanit(503), SahteYanit(503), SahteYanit(200, '{"ok": true}')])
bekleme = []
r = yeniden_dene(lambda: next(yanitlar), uyku=bekleme.append)
print(r.status_code, r.json(), 'beklemeler:', bekleme)
Derinleş

İstemciden sunucuya: API'yi tasarlayan tarafta olmak

İstemci tarafını (istek atmak, yeniden denemek, sayfaları dolaşmak) http-api dersinde, çatıları flask ve fastapi derslerinde ayrıntılı göreceksin. Bu bölüm hangi çatıyı seçersen seç geçerli kalan sunucu tarafı sözleşmeleri ele alıyor: kaynakları adlandırmak, doğru durum kodunu seçmek, hata gövdesini standartlaştırmak, tekrarlanan isteğe dayanmak (idempotency), webhook imzası doğrulamak ve iyimser eşzamanlılık (ETag). Bunlar mülakatlarda en çok sorulan, çatı belgelerinde en az anlatılan konulardır.

REST kaynak tasarımı ve durum kodu seçimi

İstekAnlamıBaşarılı yanıtSık hata yanıtları
GET /siparisler?sehir=İzmir&limit=50Listele, süz200 + liste ve sonraki sayfa imleci400 geçersiz parametre
GET /siparisler/1042Tek kaynak200 (+ ETag)404 yok, 304 değişmedi
POST /siparislerYeni kaynak oluştur201 + Location: /siparisler/1043422 doğrulama, 409 çakışma
PUT /siparisler/1042Tamamını değiştir (idempotent)200 ya da 204412 ETag uyuşmadı
PATCH /siparisler/1042Kısmi güncelle200422, 409
DELETE /siparisler/1042Sil (idempotent)204404 (ya da yine 204)
POST /siparisler/1042/iptalKaynağa uymayan eylem200/202409 durum uygun değil

Kurallar: kaynak adları çoğul isim (/siparisler), fiil URL'de değil yöntemde; 401 "kim olduğunu bilmiyorum", 403 "biliyorum ama yetkin yok"; 422 gövde biçimce doğru ama kurala aykırı, 400 gövde hiç okunamıyor. Uzun süren iş için 202 Accepted + durum sorgulanacak bir adres döndür.

Tek tip hata gövdesi: problem+json

Her uç noktanın hatayı başka biçimde dönmesi istemci yazanların kâbusudur. RFC 9457 (eski adıyla RFC 7807) application/problem+json türünde ortak bir biçim önerir: type, title, status, detail, instance alanları ve gerekirse ek alanlar (ör. alan bazlı errors). Tek bir yardımcı fonksiyon yazıp tüm hataları ondan geçirirsen istemciler tek bir ayrıştırıcıyla yetinir.

Çatısız bir API: WSGI sözleşmesi

Flask ve Django'nun altında WSGI (Web Server Gateway Interface) vardır: sunucu (gunicorn, uWSGI) her istek için uygulamanı uygulama(environ, start_response) diye çağırır. environ isteği anlatan bir sözlüktür (yöntem, yol, başlıklar, gövde akışı); uygulama start_response(durum, basliklar) çağırıp gövdeyi bayt parçaları olarak döndürür. FastAPI'nin altındaki ASGI aynı fikrin async sürümüdür. Sözleşme yalnızca bir fonksiyon olduğu için uygulamayı ağ olmadan, doğrudan çağırarak test edebilirsin; aşağıdaki örnek tarayıcıda çalışır.

import io, json
from wsgiref.util import setup_testing_defaults

SIPARISLER = {1042: {"no": 1042, "sehir": "İzmir", "tutar": 1250}}

def json_yanit(start_response, durum, veri, ek=()):
    govde = json.dumps(veri, ensure_ascii=False).encode("utf-8")
    start_response(durum, [("Content-Type", "application/json; charset=utf-8"),
                           ("Content-Length", str(len(govde))), *ek])
    return [govde]

def uygulama(environ, start_response):
    yontem, yol = environ["REQUEST_METHOD"], environ["PATH_INFO"]
    if yontem == "GET" and yol.startswith("/siparisler/"):
        s = SIPARISLER.get(int(yol.rsplit("/", 1)[1]))
        if s is None:
            return json_yanit(start_response, "404 Not Found",
                              {"title": "Sipariş bulunamadı", "status": 404})
        return json_yanit(start_response, "200 OK", s)
    if yontem == "POST" and yol == "/siparisler":
        boy = int(environ.get("CONTENT_LENGTH") or 0)
        veri = json.loads(environ["wsgi.input"].read(boy))
        no = max(SIPARISLER) + 1
        SIPARISLER[no] = {"no": no, **veri}
        return json_yanit(start_response, "201 Created", SIPARISLER[no],
                          [("Location", f"/siparisler/{no}")])
    return json_yanit(start_response, "405 Method Not Allowed", {"status": 405})

def istek_at(yontem, yol, govde=None):          # test istemcisi: ağ yok
    environ = {}
    setup_testing_defaults(environ)
    ham = json.dumps(govde).encode() if govde else b""
    environ.update(REQUEST_METHOD=yontem, PATH_INFO=yol,
                   CONTENT_LENGTH=str(len(ham)), **{"wsgi.input": io.BytesIO(ham)})
    yakalanan = {}
    def start_response(durum, basliklar):
        yakalanan["durum"], yakalanan["basliklar"] = durum, dict(basliklar)
    govde = b"".join(uygulama(environ, start_response))
    return yakalanan["durum"], yakalanan["basliklar"].get("Location"), json.loads(govde)

print(istek_at("GET", "/siparisler/1042"))
print(istek_at("POST", "/siparisler", {"sehir": "Bursa", "tutar": 300}))
print(istek_at("GET", "/siparisler/9"))

Idempotency anahtarı: çift ödemeyi önlemek

Mobil uygulama "Öde" isteğini gönderdi, ağ koptu, yanıt gelmedi. Uygulama tekrar denerse müşteriden iki kez para çekilebilir; denemezse ödeme belirsiz kalır. Çözüm: istemci her işlem için benzersiz bir Idempotency-Key başlığı üretir (genelde UUID). Sunucu ilk isteğin yanıtını bu anahtarla saklar; aynı anahtarla gelen sonraki istekleri yeniden işlemez, kayıtlı yanıtı döndürür. Aynı anahtar farklı bir gövdeyle gelirse bu istemci hatasıdır (422 ya da 409). Stripe ve birçok banka API'si böyle çalışır.

Webhook imzası: gelen isteğe güvenmek

Ödeme sağlayıcısı "ödeme tamamlandı" bilgisini senin /webhook adresine POST eder. Adres herkese açık olduğu için herkes sahte istek gönderebilir. Sağlayıcı gövdeyi ortak bir gizli anahtarla HMAC-SHA256 ile imzalar ve imzayı başlıkta gönderir; sen aynı hesabı yapıp karşılaştırırsın. Tekrar saldırısına (replay) karşı imzaya zaman damgası da katılır ve eski istekler reddedilir.

import hashlib, hmac, json

GIZLI = b"whsec_anadolu_test"

def imzala(zaman: int, govde: bytes) -> str:
    mesaj = str(zaman).encode() + b"." + govde
    return hmac.new(GIZLI, mesaj, hashlib.sha256).hexdigest()

def dogrula(zaman: int, govde: bytes, imza: str, simdi: int, tolerans=300) -> bool:
    if abs(simdi - zaman) > tolerans:
        return False                                   # eski istek: tekrar saldırısı
    return hmac.compare_digest(imzala(zaman, govde), imza)

govde = json.dumps({"olay": "odeme.tamamlandi", "tutar": 1250}).encode()
imza = imzala(1_700_000_000, govde)
print(dogrula(1_700_000_000, govde, imza, simdi=1_700_000_060))
print(dogrula(1_700_000_000, govde.replace(b"1250", b"9999"), imza, simdi=1_700_000_060))
print(dogrula(1_700_000_000, govde, imza, simdi=1_700_009_999))
İmzayı ham baytlar üzerinde hesaplaGövdeyi önce json.loads ile ayrıştırıp sonra json.dumps ile yeniden üretirsen boşluklar, anahtar sırası ve Unicode kaçışları değişir; imza tutmaz. Çatından ham gövdeyi al (Flask'ta request.get_data(), FastAPI'de await request.body()). Karşılaştırmayı da == ile değil hmac.compare_digest ile yap; == ilk farklı karakterde durduğu için süre ölçümüyle imza tahmin edilebilir (timing attack).

ETag ile iyimser eşzamanlılık

İki operatör aynı siparişi aynı anda açıp düzenlerse sonra kaydeden, öncekinin değişikliğini sessizce ezer (lost update). Sunucu her GET yanıtında kaynağın sürümünü ETag başlığında verir; istemci PUT/PATCH gönderirken bu değeri If-Match başlığına koyar. Sunucu mevcut sürüm farklıysa 412 Precondition Failed döner; istemci kaynağı yeniden okuyup kullanıcıya çakışmayı gösterir. ETag'in ikinci kullanımı önbellektir (cache): If-None-Match eşleşirse 304 Not Modified ve boş gövde döner (istemci tarafını http-api dersinde gördün).

Sürümleme, hız sınırı ve belgeler

  • Sürümleme: Kırıcı değişiklik (alan silmek, tipini değiştirmek) yeni sürüm ister: /v2/siparisler en yaygın ve en görünür yoldur. Alan eklemek kırıcı değildir; istemcileri bilmedikleri alanları yok saymaya alıştır.
  • Hız sınırı (rate limit): Sunucu tarafında genelde jeton kovası (token bucket) kullanılır: kova saniyede r jetonla dolar, en fazla b jeton tutar, her istek bir jeton harcar. Aşımda 429 ve Retry-After başlığı dön.
  • Sayfalama: offset basittir ama araya kayıt eklenince satır atlar/tekrarlar ve büyük offset'lerde veritabanı yavaşlar. İmleç (cursor) sayfalamada "son görülen id"yi opak bir değer olarak (ör. base64) verirsin; sorgu WHERE id > ? olur.
  • Belge: OpenAPI şeması API'nin sözleşmesidir; FastAPI tiplerden otomatik üretir, Flask'ta apispec gibi eklentiler gerekir. Şemadan istemci kodu ve sözleşme testleri üretilebilir.
Kendi bilgisayarında deneÖrnekteki WSGI uygulamasını gerçek bir sunucuyla çalıştırmak için dosyaya kaydedip python -m wsgiref.simple_server yerine üretim sunucusu kullan: pip install gunicorn ve gunicorn modul:uygulama --bind 0.0.0.0:8000 (Windows'ta gunicorn çalışmaz; pip install waitress ve waitress-serve --port=8000 modul:uygulama). Sık hata: ModuleNotFoundError alırsan komutu dosyanın bulunduğu klasörden çalıştır ya da --chdir ver.
Gerçek işteÖdeme, sipariş ve havale gibi para hareketi yaratan her POST uç noktasında ilk soru "bu istek iki kez gelirse ne olur?" olmalıdır. Kod incelemesinde (code review) idempotency anahtarı, webhook imza doğrulaması ve tek tip hata gövdesi yoksa bunlar genellikle güvenlik ya da muhasebe bulgusu olarak geri döner. API'yi yazan ekip, istemcinin yeniden deneme yapacağını varsaymak zorundadır.
Uzmanlaş

Üretim API istemcisi kontrol listesi

Kurumsal bir istemci modülü şu parçaları taşır: tek bir Client nesnesi (oturum), merkezi zaman aşımı, 5xx/429 için geri çekilmeli yeniden deneme, 4xx için anlamlı iş istisnaları (SiparisBulunamadi gibi), yanıt şemasının doğrulanması (TypedDict/pydantic), istek kimliği ve süresinin loglanması. Sahte istemciyi Protocol ile tanımlarsan hem gerçek hem test istemcisi aynı imzaya uyar; iş mantığı ağ olmadan test edilir.

Sunucu tarafında FastAPI'nin bağımlılık enjeksiyonu (Depends) veritabanı oturumu ve kimlik doğrulamayı uç noktadan ayırır. Asenkron uç noktalar (async def) yalnızca I/O beklerken kazanç sağlar; CPU ağırlıklı işi run_in_executor ya da ayrı işçiye ver. Sürümleme (/v1/), sayfalama (limit/offset ya da imleç), oran sınırlama (rate limit) ve OpenAPI belgesi, bir API'nin "üretime hazır" sayılması için beklenen asgari özelliklerdir.

Devre kesici (circuit breaker) kalıbını da bil: dış servis art arda başarısız olunca bir süre hiç istek atmadan hızlı hata dön; böylece hem kendi hizmetin yavaşlamaz hem de karşı tarafa yük bindirmezsin.

Alıştırmalara geç (21)