diff --git a/i18n/ar/code.json b/i18n/ar/code.json index 55f7400..466b209 100644 --- a/i18n/ar/code.json +++ b/i18n/ar/code.json @@ -909,5 +909,13 @@ "homepage.projects.multiAgentResearch.summary": { "message": "ابنِ نظامًا صغيرًا متعدد الوكلاء — مُخطِّطًا وباحثًا وكاتبًا — يقسّم سؤال بحث ويركّب تقريرًا نهائيًا، باستخدام وكلاء deepagents الفرعيين من LangChain ونموذجًا لغويًا من مستوى مجاني.", "description": "Homepage project card summary" + }, + "homepage.projects.rateLimitedApi.title": { + "message": "بناء خدمة API محدودة المعدل", + "description": "Homepage project card title" + }, + "homepage.projects.rateLimitedApi.summary": { + "message": "ابنِ خدمة FastAPI حقيقية تغلّف مجموعة بياناتك الخاصة، مع مصادقة حقيقية بمفتاح API ومحدِّد معدل بنافذة منزلقة تبنيّه من الصفر.", + "description": "Homepage project card summary" } } diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx b/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx index 88e5a86..b23cb2e 100644 --- a/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -149,5 +149,11 @@ import {mergeProjectMeta} from '@site/src/data/projects'; summary: 'ابنِ نظامًا صغيرًا متعدد الوكلاء — مُخطِّطًا وباحثًا وكاتبًا — يقسّم سؤال بحث ويركّب تقريرًا نهائيًا، باستخدام وكلاء deepagents الفرعيين من LangChain ونموذجًا لغويًا من مستوى مجاني.', }, + { + id: '2027-rate-limited-api', + title: 'بناء خدمة API محدودة المعدل', + summary: + 'ابنِ خدمة FastAPI حقيقية تغلّف مجموعة بياناتك الخاصة، مع مصادقة حقيقية بمفتاح API ومحدِّد معدل بنافذة منزلقة تبنيّه من الصفر.', + }, ])} /> diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/rate-limited-api/index.md b/i18n/ar/docusaurus-plugin-content-docs/current/projects/rate-limited-api/index.md new file mode 100644 index 0000000..c056756 --- /dev/null +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/rate-limited-api/index.md @@ -0,0 +1,402 @@ +--- +id: rate-limited-api +title: "بناء خدمة API محدودة المعدل" +sidebar_label: "بناء خدمة API محدودة المعدل" +slug: /projects/rate-limited-api +description: "انتقل من بيئة البرمجة داخل المتصفح إلى بايثون حقيقية: ابنِ خدمة FastAPI تغلّف مجموعة بياناتك الخاصة، مع مصادقة حقيقية بمفتاح API ومحدِّد معدل تبنيّه من الصفر." +--- + +import ProjectProgressCheckbox from '@site/src/components/ProjectProgressCheckbox'; +import ProjectPublishedDate from '@site/src/components/ProjectPublishedDate'; +import ProjectGreeting from '@site/src/components/ProjectGreeting'; +import {StepChecklist, StepChecklistItem} from '@site/src/components/StepChecklist'; + +# 🌍 بناء خدمة API محدودة المعدل + + + + + +كل مشروع آخر في هذا القسم يبني *عميلًا* من نوع ما — سكربتًا أو وكيلًا يستدعي API شخصٍ آخر. هذا المشروع يقلب الأمر رأسًا على عقب: أنت تبني الـAPI. يُقيم هذا المشروع خدمة [FastAPI](https://fastapi.tiangolo.com/) حقيقية تغلّف مجموعة بيانات من بضع مئات من الاقتباسات والنكات تأتي مع المشروع، مع الأمرين اللذين يحتاجهما كل API عام حقيقي وتتجاهلهما الأمثلة التافهة عادة — مصادقة مفتاح API وتحديد المعدل — مبنيّين يدويًا، لا مستوردَين من مكتبة. يفترض المشروع إلمامًا بمستوى Python 101؛ لا شيء من تحليل البيانات مطلوب. + +هذا المشروع اختياري وغير مُقيَّم؛ راجع [مشاريع من العالم الحقيقي](/docs/projects) للاطلاع على القائمة الكاملة والنامية. + +## 🎯 ما ستفعله + +1. تثبيت `uv` وإعداد مشروع FastAPI محلي — لا حاجة إلى مفتاح API خارجي، إذ يوفّر المشروع مجموعة بياناته الخاصة. +2. تضمين مجموعة بيانات وبناء نقطتي نهاية `list`/`get` مقسّمتين إلى صفحات فوقها. +3. إضافة تصفية حسب الفئة والمؤلف باستخدام معاملات استعلام. +4. بناء إصدار مفاتيح API حقيقي وتبعية تتحقق من المفتاح على نقاط النهاية المحمية. +5. تنفيذ محدد معدل بنافذة منزلقة من الصفر وإرجاع استجابات حقيقية `429 Too Many Requests` مع ترويسة `Retry-After` بمجرد أن يتجاوز المفتاح حصته. + +## أين تُشغّل هذا + +**محليًا باستخدام `uv`** هو المسار الأساسي والموصى به — فمغزى هذا المشروع برمّته تشغيل عملية خادم حقيقية طويلة الأمد وضربها بطلبات HTTP حقيقية، تمامًا كما يعمل أي API إنتاجي. + +**GitHub Codespaces** يعمل جيدًا أيضًا: افتح [مستودع الدورة كاملًا في Codespace مجاني](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node وPython وuv مثبّتة بالفعل، وفق `.devcontainer/devcontainer.json` الخاص بالمستودع)، وشغّل الخادم بالطريقة نفسها التي ستعمل بها محليًا، ووجّه المنفذ إلى الأمام — عادةً يطلب Codespaces فعل ذلك تلقائيًا في اللحظة التي يبدأ فيها `uvicorn` بالاستماع. وبعد التوجيه، يمكنك تنفيذ `curl` عليه من طرفية جهازك الخاص، أو فتح صفحة `/docs` الخاصة بالعنوان الموجَّه في متصفح، تمامًا كما لو كان يعمل محليًا. + +**أما دفاتر الملاحظات فهي مناسبة فعلًا هنا، بخلاف معظم مشاريع الخادم الطويلة الأخرى في هذه السلسلة** — مع تحفّظ واحد. لا يمكن لخلية دفتر ملاحظات أن تمسك منفذ استماع حقيقي بالطريقة التي تحصر بها Colab وKaggle وBinder الشبكات، لذا فهي خيار رديء *لتشغيل* `uvicorn` فعلًا وضربه عبر HTTP حقيقي. لكن FastAPI يشحن `TestClient` يخاطب كائن `app` الخاص بك مباشرة، داخل العملية، دون أي مقبس أو منفذ على الإطلاق — نفس المسارات ورموز الحالة والترويسات تمامًا، لكن استدعاءً لها كدوال بايثون بدل طلبات شبكة. هذا عرض توضيحي جيد بحق لمنطق التقسيم إلى صفحات والتصفية والمصادقة وتحديد المعدل، و[`examples/rate-limited-api/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/rate-limited-api/notebook.ipynb) يفعل ذلك بالضبط: + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/rate-limited-api/notebook.ipynb) +[![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/rate-limited-api/notebook.ipynb) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Frate-limited-api%2Fnotebook.ipynb) + +عاملِ الدفتر كطريقة *لرؤية* سلوك الـAPI بسرعة، لا كبديل عن تشغيل `uvicorn` محليًا فعلًا وإطلاق طلبات حقيقية نحوه — الخطوات أدناه تفعل الشيء الحقيقي. + +## الإعداد + +`uv` أداة واحدة تحل محل السلسلة المعتادة "ثبّت Python، ثم ثبّت pip، ثم ثبّت أداة بيئة افتراضية، ثم ثبّت الحزم" — يمكنها تثبيت وإدارة إصدارات Python بنفسها، إلى جانب تبعيات مشروعك. + +**macOS / Linux** (الطرفية): + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +**Windows** (PowerShell): + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +أغلق الطرفية وأعد فتحها، ثم تأكد من تثبيتها: + +```bash +uv --version +``` + +ثم أنشئ مشروعًا وثبّت FastAPI وخادمًا لتشغيله: + +```bash +uv init rate-limited-api +cd rate-limited-api +uv add fastapi "uvicorn[standard]" +``` + +لاحظ ما *ليس* هنا: لا مفتاح API لطلبه، لا تسجيل في مستوى مجاني، لا شيء لتكوينه قبل طلبك الأول. هذا المشروع يوفّر مجموعة بياناته ويصدر مفاتيحه الخاصة — أنت تبني الشيء الذي تستهلكه بقية المشاريع في هذه السلسلة. + +## الخطوة 1: تضمين مجموعة البيانات وبناء نقاط نهاية أساسية + +الـAPIs الحقيقية تخدم بيانات حقيقية. أنشئ `quotes_data.py` بمجموعة بيانات صغيرة مكتوبة يدويًا — قائمة بايثون عادية من القواميس كافية؛ لا قاعدة بيانات مطلوبة بعد: + +```python +# quotes_data.py +_RAW_QUOTES = [ + # (text, author, category) + ("Programs must be written for people to read, and only incidentally for machines to execute.", "Harold Abelson", "programming"), + ("The unexamined life is not worth living.", "Socrates", "wisdom"), + ("Why do programmers prefer dark mode? Because light attracts bugs.", "Anonymous", "humor"), + # ... a few hundred more, spanning several categories +] + +QUOTES = [ + {"id": i, "text": text, "author": author, "category": category} + for i, (text, author, category) in enumerate(_RAW_QUOTES, start=1) +] + +CATEGORIES = sorted({q["category"] for q in QUOTES}) +``` + +اكتب ما تريد — بضع عشرات تكفي للبدء، واهدف إلى بضع مئات بحلول الوقت الذي تنتهي فيه، موزعة على ثلاث أو أربع فئات على الأقل. ثم أنشئ `main.py` بالتطبيق ونقطتي قراءة: + +```python +# main.py +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel + +from quotes_data import QUOTES + +app = FastAPI(title="Quotes API") + + +class QuoteOut(BaseModel): + id: int + text: str + author: str + category: str + + +class QuotesPage(BaseModel): + items: list[QuoteOut] + total: int + limit: int + offset: int + + +@app.get("/quotes", response_model=QuotesPage) +def list_quotes( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> QuotesPage: + page = QUOTES[offset : offset + limit] + return QuotesPage(items=[QuoteOut(**q) for q in page], total=len(QUOTES), limit=limit, offset=offset) + + +@app.get("/quotes/{quote_id}", response_model=QuoteOut) +def get_quote(quote_id: int) -> QuoteOut: + for quote in QUOTES: + if quote["id"] == quote_id: + return QuoteOut(**quote) + raise HTTPException(status_code=404, detail=f"No quote with id {quote_id}.") +``` + +شغّله: + +```bash +uv run uvicorn main:app --reload +``` + +ثم في طرفية أخرى: + +```bash +curl "http://127.0.0.1:8000/quotes?limit=3" +curl "http://127.0.0.1:8000/quotes/1" +curl -i "http://127.0.0.1:8000/quotes/99999" # a real 404 +``` + +تقسيم `limit`/`offset` إلى صفحات هو النمط نفسه الكامن خلف نقطة قائمة كل API REST عام تقريبًا — إنه يحدّ من حجم البيانات التي يمكن لاستجابة واحدة إرجاعها (`le=100` هنا)، ويسمح للعميل بالمرور عبر مجموعة البيانات كاملة صفحةً صفحة باستخدام `total` ليعرف متى يتوقف. + +**✅ قائمة التحقق** + + +يبدأ `uv run uvicorn main:app --reload` دون أخطاء. +يُرجع `GET /quotes?limit=3` 3 عناصر بالضبط و`total` مطابقًا لحجم مجموعة بياناتك الكاملة. +يُرجع `GET /quotes/{a-real-id}` ذلك الاقتباس؛ بينما يُرجع `GET /quotes/99999` خطأ `404` حقيقيًا، لا `500` ولا `200` فارغًا. + + +**🤔 أسئلة سقراطية** + +- لماذا نحدّ `limit` بـ100 (`le=100`) بدل السماح للعميل بطلب كل اقتباساتك في استجابة واحدة؟ ماذا سيفعل عميل ببطء في اتصاله، أو عميل خبيث، بشكل مختلف لو لم يكن هناك حد؟ +- `get_quote` يمرّ على القائمة كاملة لإيجاد معرف واحد. مع بضع مئات من الاقتباسات يكون هذا فوريًا؛ مع بضعة ملايين لن يكون كذلك. ما بنية البيانات التي ستصيّر البحث بالمعرف سريعًا مهما بلغ حجم مجموعة البيانات؟ + +## الخطوة 2: إضافة التصفية + +وسّع `list_quotes` بمعاملات استعلام اختيارية للفئة والمؤلف: + +```python +@app.get("/categories", response_model=list[str]) +def list_categories() -> list[str]: + from quotes_data import CATEGORIES + return CATEGORIES + + +@app.get("/quotes", response_model=QuotesPage) +def list_quotes( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + category: str | None = Query(default=None, description="Filter by exact category."), + author: str | None = Query(default=None, description="Case-insensitive substring match on author."), +) -> QuotesPage: + filtered = QUOTES + if category is not None: + filtered = [q for q in filtered if q["category"] == category] + if author is not None: + needle = author.lower() + filtered = [q for q in filtered if needle in q["author"].lower()] + + page = filtered[offset : offset + limit] + return QuotesPage(items=[QuoteOut(**q) for q in page], total=len(filtered), limit=limit, offset=offset) +``` + +```bash +curl "http://127.0.0.1:8000/quotes?category=science&limit=5" +curl "http://127.0.0.1:8000/quotes?author=sagan" +curl "http://127.0.0.1:8000/categories" +``` + +`total` في الاستجابة يعكس العدد *المصفّى*، لا مجموعة البيانات كاملة — وهذا مهم لعميل يحاول المرور عبر صفحات اقتباسات العلم فقط، إذ كان سيظنّ خلاف ذلك أن هناك صفحات متبقية أكثر بكثير مما هو موجود فعلًا. + +**✅ قائمة التحقق** + + +يُرجع `?category=` اقتباسات من تلك الفئة فقط، ويعكس `total` العدد المصفّى. +يطابق `?author=` دون تمييز بين الأحرف الكبيرة والصغيرة (مثلًا `sagan` يطابق `Carl Sagan`). +دمج `category` و`author` معًا يضيّق النتائج أكثر، لا واحدًا منهما فقط. + + +**🤔 أسئلة سقراطية** + +- ماذا يجب أن يُرجع `GET /quotes?category=nonexistent` — قائمة فارغة مع `total: 0`، أم `404`؟ ماذا بنيت، ولماذا يعدّ هذا هو الخيار الأكثر اتساقًا مع REST لنقطة نهاية *مجموعة* مقابل `GET /quotes/{id}` لعنصر واحد؟ +- لو أضفت عامل تصفية ثانٍ يحتاج أيضًا "أيًّا من عدة قيم" (مثلًا فئات متعددة دفعة واحدة)، كيف توسّع معامل الاستعلام ليقبل قائمة؟ + +## الخطوة 3: إصدار مفاتيح API والتحقق منها + +API حقيقي يحتاج إلى معرفة من يستدعيه. أضف إصدار مفاتيح ذاتي الخدمة وتبعية تتحقق من المفتاح على المسارات المحمية: + +```python +import secrets + +from fastapi import Depends, Header + +_VALID_KEYS: set[str] = set() + + +class ApiKeyResponse(BaseModel): + api_key: str + + +@app.post("/keys", response_model=ApiKeyResponse) +def issue_api_key() -> ApiKeyResponse: + new_key = secrets.token_urlsafe(24) + _VALID_KEYS.add(new_key) + return ApiKeyResponse(api_key=new_key) + + +def require_api_key(x_api_key: str | None = Header(default=None)) -> str: + if x_api_key is None or x_api_key not in _VALID_KEYS: + raise HTTPException(status_code=401, detail="Missing or invalid API key. Get one from POST /keys.") + return x_api_key + + +@app.get("/me") +def whoami(api_key: str = Depends(require_api_key)) -> dict: + return {"api_key": api_key} +``` + +`secrets.token_urlsafe` — لا `random`، الذي ليس آمنًا تشفيريًا — يولّد مفتاحًا لا يستطيع أحد تخمينه. `Depends(require_api_key)` هو نظام حقن التبعية في FastAPI: أي مسار يأخذ `api_key: str = Depends(require_api_key)` كمعامل يشغّل `require_api_key` أولًا، ولا يتابع إلا إذا عاد بنجاح بدل أن يرفع استثناء. + +```bash +curl -i "http://127.0.0.1:8000/me" # 401, no key +curl -X POST "http://127.0.0.1:8000/keys" # {"api_key": "..."} +curl -i -H "X-API-Key: " "http://127.0.0.1:8000/me" # 200 +``` + +:::tip[مخزن المفاتيح في الذاكرة هذا ينسى كل شيء عند إعادة التشغيل، وهذا جيد هنا] +`_VALID_KEYS` يعيش في `set` بايثون عادي في ذاكرة هذه العملية — أعد تشغيل الخادم وسيتوقف كل مفتاح أصدرته سابقًا عن العمل. منتج حقيقي سيبقي المفاتيح في قاعدة بيانات (ويخزّن *تجزئة* كل مفتاح، لا القيمة الخام، بنفس الطريقة التي تُجزَّأ بها كلمات المرور — حتى لا يسرّب تسريب قاعدة البيانات مفاتيح قابلة للاستخدام مباشرة). لمشروع تعلّم محلي، النسخة الذاكرية صادقة وكافية؛ فقط لا تتفاجأ عندما يتوقف مفتاحك عن العمل بعدما يعيد `--reload` تشغيل العملية. +::: + +**✅ قائمة التحقق** + + +يُرجع `GET /me` دون ترويسة `X-API-Key` خطأ `401` حقيقيًا، بجسم يقول كيف تحصل على مفتاح. +يُرجع `POST /keys` مفتاحًا جديدًا في كل مرة تستدعيه. +يُرجع `GET /me` بمفتاح صالح في `X-API-Key` رمز `200`؛ وبمفتاح مختلق ما زال يُرجع `401`. + + +**🤔 أسئلة سقراطية** + +- يقرأ `require_api_key` المفتاح من ترويسة `X-API-Key` مخصصة بدل معامل استعلام (`?api_key=...`). معاملات الاستعلام تنتهي عادةً في سجلات وصول الخادم وسجل المتصفح. ماذا يقترح ذلك بشأن النهج الأكثر أمانًا لقيمة سرية؟ +- الآن يستطيع أي شخص استدعاء `POST /keys` عدد ما يشاء من المرات دون أي حد إطلاقًا. هل هذه مشكلة في *هذا* المشروع؟ ماذا ستضيف لو كانت خدمة عامة حقيقية؟ + +## الخطوة 4: تحديد معدل حقيقي + +هذه هي الغاية الفعلية من المشروع. ابنِ محدد معدل بنافذة منزلقة يتتبّع الطوابع الزمنية لطلبات كل مفتاح الأخيرة ويرفض الطلبات بمجرد أن يتجاوز المفتاح حصته داخل النافذة: + +```python +# rate_limit.py +import time +from collections import defaultdict, deque + + +class SlidingWindowRateLimiter: + def __init__(self, max_requests: int, window_seconds: float) -> None: + self.max_requests = max_requests + self.window_seconds = window_seconds + self._history: dict[str, deque[float]] = defaultdict(deque) + + def check(self, key: str, now: float | None = None) -> tuple[bool, float]: + now = time.monotonic() if now is None else now + history = self._history[key] + + cutoff = now - self.window_seconds + while history and history[0] <= cutoff: + history.popleft() + + if len(history) < self.max_requests: + history.append(now) + return True, 0.0 + + retry_after = history[0] + self.window_seconds - now + return False, max(retry_after, 0.0) +``` + +لكل مفتاح `deque` خاص به من الطوابع الزمنية، الأقدم أولًا. عند كل فحص، تُسقَط الطوابع الأقدم من `window_seconds` من اليسار قبل عدّ ما تبقى — هذه نافذة منزلقة **دقيقة**، لا تقريب مقسّم يعيد الضبط عند حد ساعة ثابت. هذا التمييز مهم: محدد *النافذة الثابتة* (قل "أعد ضبط العداد كل 10 ثوانٍ على الساعة") يسمح للعميل بأن يطلق حصته الكاملة في نهاية نافذة وحصته الكاملة مجددًا في بداية التالية، ليصل إلى ضعف معدله المقصود في ثوانٍ حقيقية قليلة. تتبّع الطوابع الزمنية الفعلية يتجنّب ذلك. + +اربطه في تبعية واستخدمه على `/me`: + +```python +from fastapi import Response + +RATE_LIMIT_MAX_REQUESTS = 5 +RATE_LIMIT_WINDOW_SECONDS = 10.0 +limiter = SlidingWindowRateLimiter(RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_SECONDS) + + +def enforce_rate_limit(response: Response, api_key: str = Depends(require_api_key)) -> str: + allowed, retry_after = limiter.check(api_key, now=time.monotonic()) + if not allowed: + retry_after_seconds = str(int(retry_after) + 1) + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded: max {RATE_LIMIT_MAX_REQUESTS} per {int(RATE_LIMIT_WINDOW_SECONDS)}s.", + headers={"Retry-After": retry_after_seconds}, + ) + response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT_MAX_REQUESTS) + return api_key + + +@app.get("/me") +def whoami(api_key: str = Depends(enforce_rate_limit)) -> dict: + return {"api_key": api_key} +``` + +لاحظ أن الترويسات تُضبط بطريقتين مختلفتين حسب النتيجة — ليس هذا خيارًا أسلوبيًا، بل ضرورة. أطلق ستة طلبات متتالية بنفس المفتاح: + +```bash +KEY=$(curl -s -X POST "http://127.0.0.1:8000/keys" | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])") +for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code}\n" -H "X-API-Key: $KEY" "http://127.0.0.1:8000/me"; done +``` + +يجب أن تطبع الخمسة الأولى `200`؛ والسادسة `429`. افحص الترويسات على الأخيرة: + +```bash +curl -i -H "X-API-Key: $KEY" "http://127.0.0.1:8000/me" +``` + +:::tip[ترويسات HTTPException، لا `response.headers`، في مسار الخطأ] +يغري ضبط `response.headers["Retry-After"] = ...` مباشرة قبل رفع `HTTPException`، بنفس الطريقة التي يضبط بها مسار النجاح `X-RateLimit-Limit`. لا تفعل — عندما يحوّل FastAPI `HTTPException` مرفوعة إلى استجابة HTTP فعلية، يبني كائن **استجابة جديدًا** من الاستثناء، متجاهلًا ما كُتب في معامل `response` المحقون على الطريق. أي ترويسة تريد ظهورها على استجابة خطأ يجب تمريرها إلى `HTTPException(..., headers={...})` مباشرة، وإلا فلن تصل إلى العميل بصمت. هذا عضّ النسخة الأولى من كود المثال الخاص بهذه الدرس — تحقق مع `curl -i` من أن `429` يحمل فعلًا `Retry-After`، ولا تثق بأن ضبط `response.headers` عمل. +::: + +**✅ قائمة التحقق** + + +تنجح أول `RATE_LIMIT_MAX_REQUESTS` طلبات من مفتاح واحد داخل النافذة برمز `200`. +يُرجع الطلب التالي من نفس المفتاح، ما زال داخل النافذة، خطأ `429` حقيقيًا. +تحمل استجابة `429` ترويسة `Retry-After` فعلًا — تحقّق منها بـ`curl -i`، لا افتراضًا. +الانتظار حتى انتهاء النافذة ثم إعادة المحاولة ينجح مجددًا (الحدّ ليس دائمًا). + + +**🤔 أسئلة سقراطية** + +- لماذا نسند تاريخ محدد المعدل إلى مفتاح API بدل عنوان IP؟ ماذا سيتغير (للأفضل أو للأسوأ) لو أسندته إلى IP، خصوصًا للعملاء خلف NAT شركوي مشترك؟ +- تأخذ دالة `check` في المحدد `now` كمعامل اختياري بدل استدعاء `time.monotonic()` داخليًا دائمًا. ماذا يشتري لك ذلك عند كتابة اختبار له — جرّب كتابة اختبار يزيف مرور الوقت دون `time.sleep()` فعلي. + +:::tip[هذا محدد بمقياس لعبة عن قصد — للإنتاج إجابة حقيقية] +`SlidingWindowRateLimiter` صحيح فعلًا، لكنه أيضًا أحادي العملية فعلًا: الحالة تعيش في قاموس بايثون واحد، في عامل `uvicorn` واحد. شغّله خلف عاملين، أو خلف نسختين من الخادم خلف موازن تحميل، وسيتتبّع كلٌّ منهما عدده المستقل للمفتاح نفسه — يمكن للعميل أن يصل إلى معدل يفوق المقصود بعدد النسخ. تحديد المعدل الإنتاجي لخدمة متعددة النسخ ينقل هذه الحالة دائمًا تقريبًا إلى شيء مشترك، مثل Redis (`INCR` مع `TTL` لبنة بناء شائعة)، حتى ترى كل نسخة العدد نفسه. توجد مكتبات مثل [`slowapi`](https://github.com/laurentS/slowapi) تحديدًا لتغليف هذا النمط في ديكوراتور — جديرة بالمعرفة، حتى وإن بنى هذا الدرس الجزء المثير يدويًا عن قصد بدل استيراده. +::: + +## ⚠️ مطبّات شائعة + +- **ضبط ترويسات على `response` قبل رفع `HTTPException`.** كما ورد أعلاه — إنها تُتجاهل. مرّرها إلى `HTTPException(headers={...})` بدل ذلك. +- **الظن بأن فحوصات أسلوب `raise_for_status` لا تنطبق هنا إطلاقًا — هذا المشروع هو الخادم، لا العميل.** من السهل إضافة معالجة أخطاء لـ*استدعاء* API ردًا على ذلك بينما غاية هذا المشروع *أن يكون* API؛ الأخطاء المهمة هنا هي التي تُرجعها نقاط نهايتك أنت إلى المستدعين (`401`, `404`, `429`)، لا ما تستقبله أنت. +- **استخدام `random` بدل `secrets` لمفاتيح API.** `random` ليس آمنًا تشفيريًا ويمكن التنبؤ بناتجه من حيث المبدأ — `secrets.token_urlsafe()` مبني تحديدًا لرموز حسّاسة أمنيًا مثل هذا. +- **اختبار تحديد المعدل بطلبات متباعدة ثانية أو أكثر يدويًا.** كتابة أوامر `curl` واحدًا تلو الآخر، منتظرًا كل نتيجة، تأخذ وقتًا أطول بسهولة من نافذة معدل قصيرة — النافذة تظل منزلقة ولن ترى `429` أبدًا. أطلق عدة طلبات متتالية (حلقة shell، أو سكربت بايثون قصير) بدل ذلك. +- **حد معدل منخفض جدًا يمنع التصفّح العادي لـ`/quotes` أثناء الاختبار.** يضع هذا الدرس محدد المعدل على `/me` فقط عن قصد، لا على نقطتي `/quotes` المفتوحتين، حتى تتصفّح مجموعة البيانات بحرية أثناء اختبار المصادقة والتحديد منفصلَين. ضع في اعتبارك هذا الفصل إن وسّعت المشروع. + +## ما بنيته للتو + +API REST حقيقي: نقطتا قائمة وتفاصيل مقسّمتان إلى صفحات وقابلة للتصفية فوق مجموعة بيانات كتبتها بنفسك، وإصدار مفاتيح API ذاتي الخدمة، وتبعية تفرض المصادقة فعلًا، ومحدد معدل بنيته سطرًا سطرًا بدل استيراده — منطق نافذة منزلقة، واستجابات `429`، وترويسة `Retry-After` صحيحة ضمنًا. هذا هو نفس شكل تصميم مفتاح-API-زائد-حد-معدل الذي تستخدمه الـAPIs العامة الحقيقية في كل مكان، دون خدمة طرف ثالث تقف خلفه. + +## إلى أين من هنا + +- أبقِ مفاتيح API (مجرّدة، لا خام) وعدادات تحديد المعدل في مخزن بيانات حقيقي — SQLite للمفاتيح، وRedis لعدادات المعدل — حتى ينجو كلاهما من إعادة التشغيل ويعملان صحيحًا عبر أكثر من عملية خادم واحدة. +- أضف مستويات محدِّد معدل لكل مفتاح (مفتاح "مجاني" يحصل على 5 طلبات كل 10 ثوانٍ، ومفتاح "مُحترف" على 50) بتخزين مستوى إلى جانب كل مفتاح مُصدَر والبحث عنه داخل `enforce_rate_limit`. +- انشر هذا فعليًا في مكان يمكن الوصول إليه من خارج جهازك (استضافة صغيرة دائمة التشغيل، أو منصة serverless تدعم تطبيقات ASGI) وضُرب من هاتف أو جهاز صديق — مشروع مثل هذا لا يكتمل إلا حين يستطيع شيء غير `localhost` استدعاءه. + +## شارك مشروعك مع الصف + +بنيت شيئًا تفخر به؟ [`examples/student-projects/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/student-projects) معرض لمشاريع قدّمها طلاب آخرون — ويرشدك ملف README الخاص به خطوة بخطوة، صديقٌ للمبتدئين تمامًا، لإضافة مشروعك عبر **pull request**، حتى لو لم تستخدم git من قبل: نسخ المستودع، وإنشاء فرع، وتنفيذ الالتزام بملفاتك، وفتح الـPR، خطوةً خطوة. لا يفترض أي خبرة سابقة بـgit. + +مرحبًا بك في كتابة بايثون خارج المتصفح. 🎓 + + diff --git a/i18n/es/code.json b/i18n/es/code.json index 92ebe8b..a3b159c 100644 --- a/i18n/es/code.json +++ b/i18n/es/code.json @@ -909,5 +909,13 @@ "homepage.projects.multiAgentResearch.summary": { "message": "Construye un pequeño sistema multi-agente — un planificador, un investigador y un escritor — que descompone una pregunta de investigación y sintetiza un informe final, usando los sub-agentes de deepagents de LangChain y un LLM de nivel gratuito.", "description": "Homepage project card summary" + }, + "homepage.projects.rateLimitedApi.title": { + "message": "Construye un Servicio de API con Límite de Tasa", + "description": "Homepage project card title" + }, + "homepage.projects.rateLimitedApi.summary": { + "message": "Construye un servicio FastAPI real que envuelve tu propio conjunto de datos, con autenticación genuina por clave de API y un limitador de tasa de ventana deslizante que construyes desde cero.", + "description": "Homepage project card summary" } } diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx b/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx index 8afe527..86633e3 100644 --- a/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -149,5 +149,11 @@ Son opcionales y no calificados. Explóralos en cualquier momento — la introdu summary: 'Construye un pequeño sistema multi-agente — un planificador, un investigador y un escritor — que descompone una pregunta de investigación y sintetiza un informe final, usando los sub-agentes de deepagents de LangChain y un LLM de nivel gratuito.', }, + { + id: '2027-rate-limited-api', + title: 'Construye un Servicio de API con Límite de Tasa', + summary: + 'Construye un servicio FastAPI real que envuelve tu propio conjunto de datos, con autenticación genuina por clave de API y un limitador de tasa de ventana deslizante que construyes desde cero.', + }, ])} /> diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/rate-limited-api/index.md b/i18n/es/docusaurus-plugin-content-docs/current/projects/rate-limited-api/index.md new file mode 100644 index 0000000..4b9a2c7 --- /dev/null +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/rate-limited-api/index.md @@ -0,0 +1,402 @@ +--- +id: rate-limited-api +title: "Construye un Servicio de API con Límite de Tasa" +sidebar_label: "Servicio de API con Límite de Tasa" +slug: /projects/rate-limited-api +description: "Graduado del playground en el navegador a Python real: construye un servicio FastAPI que envuelve tu propio conjunto de datos, con autenticación genuina por clave de API y un limitador de tasa que construyes desde cero." +--- + +import ProjectProgressCheckbox from '@site/src/components/ProjectProgressCheckbox'; +import ProjectPublishedDate from '@site/src/components/ProjectPublishedDate'; +import ProjectGreeting from '@site/src/components/ProjectGreeting'; +import {StepChecklist, StepChecklistItem} from '@site/src/components/StepChecklist'; + +# 🌍 Construye un Servicio de API con Límite de Tasa + + + + + +Cada uno de los otros proyectos de esta sección construye un *cliente* de algún tipo — un script o agente que llama a la API de otra persona. Este lo invierte: tú construyes la API. Este proyecto levanta un servicio real de [FastAPI](https://fastapi.tiangolo.com/) que envuelve un conjunto de datos de unos cientos de citas y chistes que viene incluido con el proyecto, con las dos cosas que toda API pública real necesita y que los ejemplos de juguete suelen omitir — autenticación por clave de API y limitación de tasa — construidas a mano, no importadas de una biblioteca. Asume Python a nivel 101; no se requiere nada de Análisis de Datos. + +Esto es opcional y no calificado; consulta [Proyectos del mundo real](/docs/projects) para ver la lista completa y creciente. + +## 🎯 Lo que harás + +1. Instalar `uv` y configurar un proyecto FastAPI local — sin necesidad de una clave de API externa, ya que este proyecto incluye su propio conjunto de datos. +2. Incluir un conjunto de datos y construir endpoints paginados de `list`/`get` sobre él. +3. Añadir filtrado por categoría y autor con parámetros de consulta. +4. Construir una emisión real de claves de API y una dependencia que valida una clave en los endpoints protegidos. +5. Implementar un limitador de tasa de ventana deslizante desde cero y devolver respuestas reales `429 Too Many Requests` con una cabecera `Retry-After` una vez que una clave excede su presupuesto. + +## Dónde ejecutar esto + +**Localmente con `uv`** es el camino principal y recomendado — el punto de todo este proyecto es ejecutar un proceso de servidor real y de larga duración y golpearlo con peticiones HTTP reales, de la misma manera que funciona cualquier API de producción. + +**GitHub Codespaces** también funciona bien: abre [todo el repositorio del curso en un Codespace gratuito](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python y `uv` ya están instalados, según el `.devcontainer/devcontainer.json` del repositorio), ejecuta el servidor igual que lo harías localmente y reenvía el puerto — Codespaces suele ofrecer hacerlo automáticamente en cuanto `uvicorn` comienza a escuchar. Una vez reenviado, puedes ejecutar `curl` desde la terminal de tu propia máquina, o abrir la página `/docs` de la URL reenviada en un navegador, exactamente como si se estuviera ejecutando localmente. + +**Los notebooks son un ajuste genuinamente bueno aquí, a diferencia de la mayoría de los otros proyectos de servidor de larga duración de esta serie** — con una salvedad. Una celda de notebook no puede mantener abierto un puerto de escucha real de la forma en que Colab, Kaggle y Binder aíslan las redes, así que es una mala opción para *ejecutar de verdad* `uvicorn` y golpearlo por HTTP real. Pero FastAPI incluye un `TestClient` que habla con tu objeto `app` directamente, dentro del proceso, sin socket ni puerto de por medio — exactamente las mismas rutas, códigos de estado y cabeceras, solo que invocadas como llamadas a funciones de Python en lugar de peticiones de red. Es una demostración legítimamente buena de la lógica de paginación, filtrado, autenticación y limitación de tasa, y [`examples/rate-limited-api/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/rate-limited-api/notebook.ipynb) hace exactamente eso: + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/rate-limited-api/notebook.ipynb) +[![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/rate-limited-api/notebook.ipynb) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Frate-limited-api%2Fnotebook.ipynb) + +Trata el notebook como una forma de *ver* rápidamente el comportamiento de la API, no como un reemplazo para ejecutar `uvicorn` localmente y lanzar peticiones reales contra él — los pasos de abajo hacen lo real. + +## Configuración + +`uv` es una sola herramienta que reemplaza la cadena habitual de "instala Python, luego instala pip, luego instala una herramienta de entorno virtual, luego instala paquetes" — puede instalar y gestionar versiones de Python por sí misma, junto con las dependencias de tu proyecto. + +**macOS / Linux** (terminal): + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +**Windows** (PowerShell): + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +Cierra y vuelve a abrir tu terminal, luego confirma que se instaló: + +```bash +uv --version +``` + +Luego configura un proyecto e instala FastAPI y un servidor para ejecutarlo: + +```bash +uv init rate-limited-api +cd rate-limited-api +uv add fastapi "uvicorn[standard]" +``` + +Fíjate en lo que *no* hay aquí: ninguna clave de API que solicitar, ningún registro de nivel gratuito, nada que configurar antes de tu primera petición. Este proyecto incluye su propio conjunto de datos y emite sus propias claves — estás construyendo la cosa que consumen los demás proyectos de esta serie. + +## Paso 1: Empaca el conjunto de datos y construye los endpoints básicos + +Las APIs reales sirven datos reales. Crea `quotes_data.py` con un conjunto de datos pequeño escrito a mano — una lista simple de Python de diccionarios es suficiente; no se necesita base de datos todavía: + +```python +# quotes_data.py +_RAW_QUOTES = [ + # (text, author, category) + ("Programs must be written for people to read, and only incidentally for machines to execute.", "Harold Abelson", "programming"), + ("The unexamined life is not worth living.", "Socrates", "wisdom"), + ("Why do programmers prefer dark mode? Because light attracts bugs.", "Anonymous", "humor"), + # ... a few hundred more, spanning several categories +] + +QUOTES = [ + {"id": i, "text": text, "author": author, "category": category} + for i, (text, author, category) in enumerate(_RAW_QUOTES, start=1) +] + +CATEGORIES = sorted({q["category"] for q in QUOTES}) +``` + +Escribe el tuyo — unas pocas docenas bastan para empezar, apunta a un par de cientos para cuando termines, abarcando al menos tres o cuatro categorías. Luego crea `main.py` con la aplicación y dos endpoints de lectura: + +```python +# main.py +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel + +from quotes_data import QUOTES + +app = FastAPI(title="Quotes API") + + +class QuoteOut(BaseModel): + id: int + text: str + author: str + category: str + + +class QuotesPage(BaseModel): + items: list[QuoteOut] + total: int + limit: int + offset: int + + +@app.get("/quotes", response_model=QuotesPage) +def list_quotes( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> QuotesPage: + page = QUOTES[offset : offset + limit] + return QuotesPage(items=[QuoteOut(**q) for q in page], total=len(QUOTES), limit=limit, offset=offset) + + +@app.get("/quotes/{quote_id}", response_model=QuoteOut) +def get_quote(quote_id: int) -> QuoteOut: + for quote in QUOTES: + if quote["id"] == quote_id: + return QuoteOut(**quote) + raise HTTPException(status_code=404, detail=f"No quote with id {quote_id}.") +``` + +Ejecútalo: + +```bash +uv run uvicorn main:app --reload +``` + +Luego, en otra terminal: + +```bash +curl "http://127.0.0.1:8000/quotes?limit=3" +curl "http://127.0.0.1:8000/quotes/1" +curl -i "http://127.0.0.1:8000/quotes/99999" # a real 404 +``` + +La paginación `limit`/`offset` es el mismo patrón detrás del endpoint de lista de casi todas las APIs REST públicas — limita cuántos datos puede devolver una sola respuesta (`le=100` aquí) y permite que un cliente recorra el conjunto de datos completo página a página usando `total` para saber cuándo detenerse. + +**✅ Lista de verificación** + + +`uv run uvicorn main:app --reload` arranca sin errores. +`GET /quotes?limit=3` devuelve exactamente 3 elementos y un `total` que coincide con el tamaño de tu conjunto de datos completo. +`GET /quotes/{a-real-id}` devuelve esa cita; `GET /quotes/99999` devuelve un `404` real, no un `500` ni un `200` vacío. + + +**🤔 Pregunta(s) socrática(s)** + +- ¿Por qué limitar `limit` a 100 (`le=100`) en lugar de permitir que un cliente pida todas tus citas en una sola respuesta? ¿Qué haría de forma diferente un cliente con una conexión lenta, o uno malintencionado, si no hubiera límite? +- `get_quote` recorre toda la lista para encontrar un id. Con unos cientos de citas esto es instantáneo; con unos pocos millones no lo sería. ¿Qué estructura de datos haría rápido buscar por id independientemente del tamaño del conjunto de datos? + +## Paso 2: Añade filtrado + +Extiende `list_quotes` con parámetros de consulta opcionales para categoría y autor: + +```python +@app.get("/categories", response_model=list[str]) +def list_categories() -> list[str]: + from quotes_data import CATEGORIES + return CATEGORIES + + +@app.get("/quotes", response_model=QuotesPage) +def list_quotes( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + category: str | None = Query(default=None, description="Filter by exact category."), + author: str | None = Query(default=None, description="Case-insensitive substring match on author."), +) -> QuotesPage: + filtered = QUOTES + if category is not None: + filtered = [q for q in filtered if q["category"] == category] + if author is not None: + needle = author.lower() + filtered = [q for q in filtered if needle in q["author"].lower()] + + page = filtered[offset : offset + limit] + return QuotesPage(items=[QuoteOut(**q) for q in page], total=len(filtered), limit=limit, offset=offset) +``` + +```bash +curl "http://127.0.0.1:8000/quotes?category=science&limit=5" +curl "http://127.0.0.1:8000/quotes?author=sagan" +curl "http://127.0.0.1:8000/categories" +``` + +`total` en la respuesta refleja el recuento *filtrado*, no todo el conjunto de datos — eso importa para un cliente que intente paginar solo por las citas de ciencia, que de otro modo pensaría que quedan muchas más páginas de las que realmente hay. + +**✅ Lista de verificación** + + +`?category=` devuelve solo citas de esa categoría, y `total` refleja el recuento filtrado. +`?author=` coincide sin distinguir mayúsculas/minúsculas (p. ej. `sagan` coincide con `Carl Sagan`). +Combinar `category` y `author` juntos reduce aún más los resultados, no solo uno u otro. + + +**🤔 Pregunta(s) socrática(s)** + +- ¿Qué debería devolver `GET /quotes?category=nonexistent` — una lista vacía con `total: 0`, o un `404`? ¿Cuál construiste, y por qué es esa la opción más RESTful para un endpoint de *colección* frente al de elemento único `GET /quotes/{id}`? +- Si añadieras un segundo filtro que también necesitara "cualquiera de varios valores" (p. ej. varias categorías a la vez), ¿cómo extenderías el parámetro de consulta para que aceptara una lista? + +## Paso 3: Emisión y validación de claves de API + +Una API real necesita saber quién la está llamando. Añade emisión de claves de autoservicio y una dependencia que verifica una clave en las rutas protegidas: + +```python +import secrets + +from fastapi import Depends, Header + +_VALID_KEYS: set[str] = set() + + +class ApiKeyResponse(BaseModel): + api_key: str + + +@app.post("/keys", response_model=ApiKeyResponse) +def issue_api_key() -> ApiKeyResponse: + new_key = secrets.token_urlsafe(24) + _VALID_KEYS.add(new_key) + return ApiKeyResponse(api_key=new_key) + + +def require_api_key(x_api_key: str | None = Header(default=None)) -> str: + if x_api_key is None or x_api_key not in _VALID_KEYS: + raise HTTPException(status_code=401, detail="Missing or invalid API key. Get one from POST /keys.") + return x_api_key + + +@app.get("/me") +def whoami(api_key: str = Depends(require_api_key)) -> dict: + return {"api_key": api_key} +``` + +`secrets.token_urlsafe` — no `random`, que no es criptográficamente seguro — genera una clave que nadie puede adivinar. `Depends(require_api_key)` es el sistema de inyección de dependencias de FastAPI: cualquier ruta que tome `api_key: str = Depends(require_api_key)` como parámetro ejecuta `require_api_key` primero, y solo continúa si retorna con éxito en lugar de lanzar una excepción. + +```bash +curl -i "http://127.0.0.1:8000/me" # 401, no key +curl -X POST "http://127.0.0.1:8000/keys" # {"api_key": "..."} +curl -i -H "X-API-Key: " "http://127.0.0.1:8000/me" # 200 +``` + +:::tip[Este almacén de claves en memoria lo olvida todo al reiniciar, y eso está bien aquí] +`_VALID_KEYS` vive en un `set` simple de Python en la memoria de este proceso — reinicia el servidor y toda clave emitida previamente deja de funcionar. Un producto real persistiría las claves en una base de datos (y almacenaría un *hash* de cada clave, no el valor crudo, de la misma manera que se hacen hash las contraseñas — para que una fuga de la base de datos no filtre claves utilizables directamente). Para un proyecto local de aprendizaje, la versión en memoria es honesta y suficiente; solo no te sorprendas cuando tu clave deje de funcionar después de que `--reload` reinicie el proceso. +::: + +**✅ Lista de verificación** + + +`GET /me` sin cabecera `X-API-Key` devuelve un `401` real, con un cuerpo que dice cómo obtener una clave. +`POST /keys` devuelve una clave nueva cada vez que lo llamas. +`GET /me` con una clave válida en `X-API-Key` devuelve `200`; con una clave inventada sigue devolviendo `401`. + + +**🤔 Pregunta(s) socrática(s)** + +- `require_api_key` lee la clave de una cabecera `X-API-Key` personalizada en lugar de un parámetro de consulta (`?api_key=...`). Los parámetros de consulta terminan habitualmente en los registros de acceso del servidor y en el historial del navegador. ¿Qué sugiere eso sobre cuál enfoque es más seguro para un valor secreto? +- Ahora mismo cualquiera puede llamar a `POST /keys` tantas veces como quiera sin ningún límite. ¿Es eso un problema para *este* proyecto? ¿Qué añadirías si fuera un servicio público real? + +## Paso 4: Limitación de tasa real + +Este es el auténtico punto del proyecto. Construye un limitador de tasa de ventana deslizante que rastrea las marcas de tiempo recientes de las peticiones de cada clave y rechaza las peticiones una vez que una clave excede su presupuesto dentro de una ventana: + +```python +# rate_limit.py +import time +from collections import defaultdict, deque + + +class SlidingWindowRateLimiter: + def __init__(self, max_requests: int, window_seconds: float) -> None: + self.max_requests = max_requests + self.window_seconds = window_seconds + self._history: dict[str, deque[float]] = defaultdict(deque) + + def check(self, key: str, now: float | None = None) -> tuple[bool, float]: + now = time.monotonic() if now is None else now + history = self._history[key] + + cutoff = now - self.window_seconds + while history and history[0] <= cutoff: + history.popleft() + + if len(history) < self.max_requests: + history.append(now) + return True, 0.0 + + retry_after = history[0] + self.window_seconds - now + return False, max(retry_after, 0.0) +``` + +Cada clave tiene su propio `deque` de marcas de tiempo, de más antigua a más reciente. En cada comprobación, las marcas de tiempo más antiguas que `window_seconds` se descartan por la izquierda antes de contar lo que queda — esta es una ventana deslizante **exacta**, no una aproximación por cubos que se reinicia en un límite de reloj fijo. Esa distinción importa: un limitador de *ventana fija* (digamos, "reinicia el contador cada 10 segundos según el reloj") permite que un cliente dispare su cuota completa justo al final de una ventana y su cuota completa de nuevo justo al inicio de la siguiente, alcanzando hasta 2x su tasa prevista en un par de segundos reales. Rastrear marcas de tiempo reales evita eso. + +Conéctalo a una dependencia y úsalo en `/me`: + +```python +from fastapi import Response + +RATE_LIMIT_MAX_REQUESTS = 5 +RATE_LIMIT_WINDOW_SECONDS = 10.0 +limiter = SlidingWindowRateLimiter(RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_SECONDS) + + +def enforce_rate_limit(response: Response, api_key: str = Depends(require_api_key)) -> str: + allowed, retry_after = limiter.check(api_key, now=time.monotonic()) + if not allowed: + retry_after_seconds = str(int(retry_after) + 1) + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded: max {RATE_LIMIT_MAX_REQUESTS} per {int(RATE_LIMIT_WINDOW_SECONDS)}s.", + headers={"Retry-After": retry_after_seconds}, + ) + response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT_MAX_REQUESTS) + return api_key + + +@app.get("/me") +def whoami(api_key: str = Depends(enforce_rate_limit)) -> dict: + return {"api_key": api_key} +``` + +Fíjate en que las cabeceras se establecen de dos maneras diferentes según el resultado — no es una elección estilística, es necesario. Dispara seis peticiones en rápida sucesión con la misma clave: + +```bash +KEY=$(curl -s -X POST "http://127.0.0.1:8000/keys" | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])") +for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code}\n" -H "X-API-Key: $KEY" "http://127.0.0.1:8000/me"; done +``` + +Las cinco primeras deberían imprimir `200`; la sexta debería imprimir `429`. Comprueba las cabeceras de esa última: + +```bash +curl -i -H "X-API-Key: $KEY" "http://127.0.0.1:8000/me" +``` + +:::tip[Cabeceras de HTTPException, no `response.headers`, en la ruta de error] +Es tentador establecer `response.headers["Retry-After"] = ...` justo antes de lanzar `HTTPException`, de la misma manera que la ruta de éxito establece `X-RateLimit-Limit`. No lo hagas — cuando FastAPI convierte una `HTTPException` lanzada en una respuesta HTTP real, construye un objeto de respuesta **nuevo** a partir de la excepción, descartando por el camino lo que se haya escrito en el parámetro `response` inyectado. Cualquier cabecera que deba aparecer en una respuesta de error tiene que pasarse directamente a `HTTPException(..., headers={...})`, o nunca llega al cliente, en silencio. Esto mordió la primera versión del código de ejemplo de esta misma lección — verifica con `curl -i` que tu `429` realmente lleva `Retry-After`, no confíes simplemente en que establecer `response.headers` funcionó. +::: + +**✅ Lista de verificación** + + +Las primeras `RATE_LIMIT_MAX_REQUESTS` peticiones de una clave dentro de la ventana tienen éxito con `200`. +La siguiente petición de esa misma clave, aún dentro de la ventana, devuelve un `429` real. +La respuesta `429` realmente lleva una cabecera `Retry-After` — verificada con `curl -i`, no asumida. +Esperar hasta que pase la ventana y reintentar vuelve a tener éxito (el límite no es permanente). + + +**🤔 Pregunta(s) socrática(s)** + +- ¿Por qué usar la clave de API como clave del historial del limitador de tasa en lugar de la dirección IP? ¿Qué cambiaría (para bien o para mal) si lo usaras por IP, especialmente para clientes detrás de un NAT corporativo compartido? +- El método `check` del limitador toma `now` como parámetro opcional en lugar de llamar siempre a `time.monotonic()` internamente. ¿Qué te compra eso al escribir un test para él — intenta escribir uno que finja el paso del tiempo sin un `time.sleep()` real? + +:::tip[Este es un limitador a escala de juguete a propósito — la producción tiene una respuesta real] +`SlidingWindowRateLimiter` es genuinamente correcto, pero también es genuinamente de un solo proceso: el estado vive en un dict de Python, en un worker de `uvicorn`. Ejecútalo detrás de dos workers, o de dos réplicas de servidor detrás de un balanceador de carga, y cada uno rastrea su propio recuento independiente para la misma clave — un cliente podría alcanzar hasta N-veces-la-instancia la tasa prevista. La limitación de tasa en producción para un servicio multi-instancia casi siempre mueve este estado a algo compartido, como Redis (`INCR` con un `TTL` es un bloque de construcción común), para que cada instancia vea el mismo recuento. Bibliotecas como [`slowapi`](https://github.com/laurentS/slowapi) existen específicamente para envolver ese patrón en un decorador — vale la pena conocerlas, aunque esta lección construyó deliberadamente a mano la parte interesante en lugar de importarla. +::: + +## ⚠️ Errores comunes + +- **Establecer cabeceras en `response` antes de lanzar un `HTTPException`.** Como se cubrió arriba — se descartan. Pásalas a `HTTPException(headers={...})` en su lugar. +- **Olvidar que los checks tipo `raise_for_status` no aplican en ninguna parte aquí — este proyecto es el servidor, no el cliente.** Es fácil añadir por reflejo manejo de errores para *llamar* a una API cuando el punto de todo este proyecto es *ser* una; los errores que importan aquí son los que tus propios endpoints devuelven a los llamadores (`401`, `404`, `429`), no los que recibes tú. +- **Usar `random` en lugar de `secrets` para las claves de API.** `random` no es criptográficamente seguro y su salida puede, en principio, predecirse — `secrets.token_urlsafe()` está construido específicamente para tokens sensibles a la seguridad como este. +- **Probar la limitación de tasa a mano con peticiones espaciadas un segundo o más.** Escribir comandos `curl` uno a la vez, esperando cada resultado, fácilmente tarda más que una ventana de límite de tasa corta — la ventana sigue deslizándose y nunca verás un `429`. Dispara varias peticiones seguidas (un bucle de shell, o un script corto de Python) en su lugar. +- **Un límite de tasa tan bajo que bloquea la navegación normal por `/quotes` mientras pruebas.** Esta lección pone deliberadamente el limitador de tasa solo en `/me`, no en los endpoints abiertos `/quotes`, para que puedas explorar el conjunto de datos libremente mientras pruebas la autenticación y la limitación por separado. Ten en cuenta esa separación si lo extiendes. + +## Lo que acabas de construir + +Una API REST real: endpoints de lista y detalle paginados y filtrables sobre un conjunto de datos que escribiste tú mismo, emisión de claves de API de autoservicio, una dependencia que realmente aplica la autenticación, y un limitador de tasa que construiste línea por línea en lugar de importar — lógica de ventana deslizante, respuestas `429` y una cabecera `Retry-After` correcta incluidas. Es la misma forma de diseño de clave-de-API-más-límite-de-tasa que usan las APIs públicas reales en todas partes, solo que sin un servicio de terceros detrás. + +## A dónde ir desde aquí + +- Persiste las claves de API (con hash, no crudas) y los contadores de limitación de tasa en un almacén de datos real — SQLite para las claves, Redis para los contadores de tasa — para que ambos sobrevivan a un reinicio y funcionen correctamente entre más de un proceso de servidor. +- Añade niveles de límite de tasa por clave (una clave "free" obtiene 5 peticiones por 10 segundos, una clave "pro" obtiene 50) almacenando un nivel junto a cada clave emitida y consultándolo dentro de `enforce_rate_limit`. +- Despliega esto de verdad en algún lugar alcanzable desde fuera de tu propia máquina (un pequeño host siempre encendido, o una plataforma serverless que soporte apps ASGI) y golpéalo desde un teléfono o la máquina de un amigo — un proyecto como este solo está completo cuando algo distinto de `localhost` puede llamarlo. + +## Comparte tu proyecto con la clase + +¿Construiste algo de lo que estás orgulloso? [`examples/student-projects/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/student-projects) es una galería de proyectos que otros estudiantes han enviado — y su README tiene un recorrido completo y amigable para principiantes sobre cómo agregar el tuyo vía un **pull request**, incluso si nunca has usado git antes: hacer fork del repositorio, crear una rama, confirmar tus archivos, y abrir el PR, un paso a la vez. No se asume experiencia previa con git. + +Bienvenido a escribir Python fuera del navegador. 🎓 + + diff --git a/i18n/fr/code.json b/i18n/fr/code.json index 256f4dc..c1941ce 100644 --- a/i18n/fr/code.json +++ b/i18n/fr/code.json @@ -909,5 +909,13 @@ "homepage.projects.multiAgentResearch.summary": { "message": "Construis un petit système multi-agents — un planificateur, un chercheur et un rédacteur — qui décompose une question de recherche et synthétise un rapport final, en utilisant les sous-agents deepagents de LangChain et un LLM de palier gratuit.", "description": "Homepage project card summary" + }, + "homepage.projects.rateLimitedApi.title": { + "message": "Construis un Service d'API à Débit Limité", + "description": "Homepage project card title" + }, + "homepage.projects.rateLimitedApi.summary": { + "message": "Construis un vrai service FastAPI qui enveloppe ton propre jeu de données, avec une authentification par clé API authentique et un limiteur de débit à fenêtre glissante que tu construis de zéro.", + "description": "Homepage project card summary" } } diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx b/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx index dbd27f7..1f45a14 100644 --- a/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -149,5 +149,11 @@ Ils sont optionnels et non notés. Parcourez-les à tout moment — l'introducti summary: "Construis un petit système multi-agents — un planificateur, un chercheur et un rédacteur — qui décompose une question de recherche et synthétise un rapport final, en utilisant les sous-agents deepagents de LangChain et un LLM de palier gratuit.", }, + { + id: '2027-rate-limited-api', + title: "Construis un Service d'API à Débit Limité", + summary: + "Construis un vrai service FastAPI qui enveloppe ton propre jeu de données, avec une authentification par clé API authentique et un limiteur de débit à fenêtre glissante que tu construis de zéro.", + }, ])} /> diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/rate-limited-api/index.md b/i18n/fr/docusaurus-plugin-content-docs/current/projects/rate-limited-api/index.md new file mode 100644 index 0000000..9ff03bd --- /dev/null +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/rate-limited-api/index.md @@ -0,0 +1,402 @@ +--- +id: rate-limited-api +title: "Construis un Service d'API à Débit Limité" +sidebar_label: "Service d'API à Débit Limité" +slug: /projects/rate-limited-api +description: "Passe du terrain de jeu dans le navigateur à du vrai Python : construis un service FastAPI qui enveloppe ton propre jeu de données, avec une authentification par clé API authentique et un limiteur de débit que tu construis de zéro." +--- + +import ProjectProgressCheckbox from '@site/src/components/ProjectProgressCheckbox'; +import ProjectPublishedDate from '@site/src/components/ProjectPublishedDate'; +import ProjectGreeting from '@site/src/components/ProjectGreeting'; +import {StepChecklist, StepChecklistItem} from '@site/src/components/StepChecklist'; + +# 🌍 Construis un Service d'API à Débit Limité + + + + + +Chaque autre projet de cette section construit un *client* d'une sorte ou d'une autre — un script ou un agent qui appelle l'API de quelqu'un d'autre. Celui-ci inverse ça : c'est toi qui construis l'API. Ce projet met en place un vrai service [FastAPI](https://fastapi.tiangolo.com/) qui enveloppe un jeu de données de quelques centaines de citations et de blagues livré avec le projet, avec les deux choses dont toute vraie API publique a besoin et que les exemples jouets ignorent habituellement — l'authentification par clé API et la limitation de débit — construites à la main, pas importées d'une bibliothèque. Cela suppose du Python de niveau Python 101 ; rien de l'Analyse de Données n'est requis. + +C'est optionnel et non noté ; voir [Projets du monde réel](/docs/projects) pour la liste complète et croissante. + +## 🎯 Ce que tu vas faire + +1. Installer `uv` et configurer un projet FastAPI local — aucune clé API externe nécessaire, puisque ce projet fournit son propre jeu de données. +2. Emballer un jeu de données et construire des endpoints paginés `list`/`get` par-dessus. +3. Ajouter un filtrage par catégorie et par auteur avec des paramètres de requête. +4. Construire une véritable émission de clés API et une dépendance qui valide une clé sur les endpoints protégés. +5. Implémenter un limiteur de débit à fenêtre glissante de zéro et retourner de vraies réponses `429 Too Many Requests` avec un en-tête `Retry-After` une fois qu'une clé dépasse son budget. + +## Où exécuter ceci + +**En local avec `uv`** est le chemin principal et recommandé — tout l'intérêt de ce projet, c'est de faire tourner un vrai processus serveur de longue durée et de le frapper avec de vraies requêtes HTTP, exactement comme fonctionne n'importe quelle API de production. + +**GitHub Codespaces** fonctionne bien aussi : ouvre [tout le dépôt du cours dans un Codespace gratuit](https://codespaces.new/abderrahim-lectures/python-data-analysis-course) (Node, Python et `uv` sont déjà installés, selon le `.devcontainer/devcontainer.json` du dépôt), lance le serveur de la même façon que tu le ferais en local, et redirige le port — Codespaces propose généralement de le faire automatiquement dès que `uvicorn` commence à écouter. Une fois redirigé, tu peux lui faire un `curl` depuis le terminal de ta propre machine, ou ouvrir la page `/docs` de l'URL redirigée dans un navigateur, exactement comme s'il tournait en local. + +**Les notebooks sont un ajustement authentiquement bon ici, contrairement à la plupart des autres projets de serveur de longue durée de cette série** — avec une réserve. Une cellule de notebook ne peut pas maintenir ouvert un vrai port d'écoute comme Colab, Kaggle et Binder isolent les réseaux, donc c'est un mauvais choix pour *faire tourner réellement* `uvicorn` et le frapper en vrai HTTP. Mais FastAPI fournit un `TestClient` qui parle à ton objet `app` directement, dans le processus, sans socket ni port du tout — exactement les mêmes routes, codes de statut et en-têtes, juste invoqués comme des appels de fonctions Python plutôt que des requêtes réseau. C'est une démonstration légitimement bonne de la logique de pagination, de filtrage, d'authentification et de limitation de débit, et [`examples/rate-limited-api/notebook.ipynb`](https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/rate-limited-api/notebook.ipynb) fait exactement cela : + +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/rate-limited-api/notebook.ipynb) +[![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/rate-limited-api/notebook.ipynb) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Frate-limited-api%2Fnotebook.ipynb) + +Considère le notebook comme un moyen de *voir* rapidement le comportement de l'API, pas comme un remplaçant pour faire tourner réellement `uvicorn` en local et envoyer de vraies requêtes dessus — les étapes ci-dessous font la vraie chose. + +## Configuration + +`uv` est un seul outil qui remplace la chaîne habituelle « installe Python, puis installe pip, puis installe un outil d'environnement virtuel, puis installe les paquets » — il peut installer et gérer les versions de Python lui-même, en plus des dépendances de ton projet. + +**macOS / Linux** (terminal) : + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +**Windows** (PowerShell) : + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +Ferme et rouvre ton terminal, puis confirme que c'est installé : + +```bash +uv --version +``` + +Puis configure un projet et installe FastAPI et un serveur pour le faire tourner : + +```bash +uv init rate-limited-api +cd rate-limited-api +uv add fastapi "uvicorn[standard]" +``` + +Remarque ce qui n'est *pas* là : aucune clé API à demander, aucune inscription gratuite, rien à configurer avant ta première requête. Ce projet fournit son propre jeu de données et émet ses propres clés — tu construis la chose que consomment les autres projets de cette série. + +## Étape 1 : Emballer le jeu de données et construire les endpoints de base + +Les vraies API servent de vraies données. Crée `quotes_data.py` avec un petit jeu de données écrit à la main — une simple liste Python de dicts suffit ; pas de base de données nécessaire pour l'instant : + +```python +# quotes_data.py +_RAW_QUOTES = [ + # (text, author, category) + ("Programs must be written for people to read, and only incidentally for machines to execute.", "Harold Abelson", "programming"), + ("The unexamined life is not worth living.", "Socrates", "wisdom"), + ("Why do programmers prefer dark mode? Because light attracts bugs.", "Anonymous", "humor"), + # ... a few hundred more, spanning several categories +] + +QUOTES = [ + {"id": i, "text": text, "author": author, "category": category} + for i, (text, author, category) in enumerate(_RAW_QUOTES, start=1) +] + +CATEGORIES = sorted({q["category"] for q in QUOTES}) +``` + +Écris le tien — quelques dizaines suffisent pour commencer, vise quelques centaines une fois terminé, répartis sur au moins trois ou quatre catégories. Puis crée `main.py` avec l'application et deux endpoints de lecture : + +```python +# main.py +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel + +from quotes_data import QUOTES + +app = FastAPI(title="Quotes API") + + +class QuoteOut(BaseModel): + id: int + text: str + author: str + category: str + + +class QuotesPage(BaseModel): + items: list[QuoteOut] + total: int + limit: int + offset: int + + +@app.get("/quotes", response_model=QuotesPage) +def list_quotes( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> QuotesPage: + page = QUOTES[offset : offset + limit] + return QuotesPage(items=[QuoteOut(**q) for q in page], total=len(QUOTES), limit=limit, offset=offset) + + +@app.get("/quotes/{quote_id}", response_model=QuoteOut) +def get_quote(quote_id: int) -> QuoteOut: + for quote in QUOTES: + if quote["id"] == quote_id: + return QuoteOut(**quote) + raise HTTPException(status_code=404, detail=f"No quote with id {quote_id}.") +``` + +Lance-le : + +```bash +uv run uvicorn main:app --reload +``` + +Puis, dans un autre terminal : + +```bash +curl "http://127.0.0.1:8000/quotes?limit=3" +curl "http://127.0.0.1:8000/quotes/1" +curl -i "http://127.0.0.1:8000/quotes/99999" # a real 404 +``` + +La pagination `limit`/`offset` est le même pattern derrière l'endpoint de liste de presque chaque API REST publique — elle plafonne la quantité de données qu'une seule réponse peut retourner (`le=100` ici), et permet à un client de parcourir tout le jeu de données page par page en utilisant `total` pour savoir quand s'arrêter. + +**✅ Liste de vérification** + + +`uv run uvicorn main:app --reload` démarre sans erreur. +`GET /quotes?limit=3` retourne exactement 3 éléments et un `total` correspondant à la taille de ton jeu de données complet. +`GET /quotes/{a-real-id}` retourne cette citation ; `GET /quotes/99999` retourne un vrai `404`, pas un `500` ni un `200` vide. + + +**🤔 Question(s) socratique(s)** + +- Pourquoi plafonner `limit` à 100 (`le=100`) plutôt que laisser un client demander toutes tes citations en une seule réponse ? Que ferait différemment un client avec une connexion lente, ou un client malveillant, s'il n'y avait pas de plafond ? +- `get_quote` parcourt toute la liste pour trouver un id. Avec quelques centaines de citations c'est instantané ; avec quelques millions ça ne le serait pas. Quelle structure de données rendrait la recherche par id rapide quelle que soit la taille du jeu de données ? + +## Étape 2 : Ajoute le filtrage + +Étends `list_quotes` avec des paramètres de requête optionnels pour la catégorie et l'auteur : + +```python +@app.get("/categories", response_model=list[str]) +def list_categories() -> list[str]: + from quotes_data import CATEGORIES + return CATEGORIES + + +@app.get("/quotes", response_model=QuotesPage) +def list_quotes( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + category: str | None = Query(default=None, description="Filter by exact category."), + author: str | None = Query(default=None, description="Case-insensitive substring match on author."), +) -> QuotesPage: + filtered = QUOTES + if category is not None: + filtered = [q for q in filtered if q["category"] == category] + if author is not None: + needle = author.lower() + filtered = [q for q in filtered if needle in q["author"].lower()] + + page = filtered[offset : offset + limit] + return QuotesPage(items=[QuoteOut(**q) for q in page], total=len(filtered), limit=limit, offset=offset) +``` + +```bash +curl "http://127.0.0.1:8000/quotes?category=science&limit=5" +curl "http://127.0.0.1:8000/quotes?author=sagan" +curl "http://127.0.0.1:8000/categories" +``` + +`total` dans la réponse reflète le nombre *filtré*, pas tout le jeu de données — cela compte pour un client qui essaie de paginer à travers seulement les citations scientifiques, qui penserait sinon qu'il reste bien plus de pages qu'il n'y en a réellement. + +**✅ Liste de vérification** + + +`?category=` retourne seulement les citations de cette catégorie, et `total` reflète le nombre filtré. +`?author=` fait une correspondance insensible à la casse (ex. `sagan` correspond à `Carl Sagan`). +Combiner `category` et `author` ensemble réduit encore plus les résultats, pas juste l'un ou l'autre. + + +**🤔 Question(s) socratique(s)** + +- Que devrait retourner `GET /quotes?category=nonexistent` — une liste vide avec `total: 0`, ou un `404` ? Lequel as-tu construit, et pourquoi est-ce le choix le plus RESTful pour un endpoint de *collection* par rapport au `GET /quotes/{id}` à élément unique ? +- Si tu ajoutais un second filtre qui a aussi besoin de « l'un de plusieurs valeurs » (ex. plusieurs catégories à la fois), comment étendrais-tu le paramètre de requête pour qu'il accepte une liste ? + +## Étape 3 : Émission et validation des clés API + +Une vraie API a besoin de savoir qui l'appelle. Ajoute une émission de clés en libre-service et une dépendance qui vérifie une clé sur les routes protégées : + +```python +import secrets + +from fastapi import Depends, Header + +_VALID_KEYS: set[str] = set() + + +class ApiKeyResponse(BaseModel): + api_key: str + + +@app.post("/keys", response_model=ApiKeyResponse) +def issue_api_key() -> ApiKeyResponse: + new_key = secrets.token_urlsafe(24) + _VALID_KEYS.add(new_key) + return ApiKeyResponse(api_key=new_key) + + +def require_api_key(x_api_key: str | None = Header(default=None)) -> str: + if x_api_key is None or x_api_key not in _VALID_KEYS: + raise HTTPException(status_code=401, detail="Missing or invalid API key. Get one from POST /keys.") + return x_api_key + + +@app.get("/me") +def whoami(api_key: str = Depends(require_api_key)) -> dict: + return {"api_key": api_key} +``` + +`secrets.token_urlsafe` — pas `random`, qui n'est pas cryptographiquement sûr — génère une clé que personne ne peut deviner. `Depends(require_api_key)` est le système d'injection de dépendances de FastAPI : toute route qui prend `api_key: str = Depends(require_api_key)` comme paramètre exécute `require_api_key` d'abord, et ne continue que si elle retourne avec succès au lieu de lever une exception. + +```bash +curl -i "http://127.0.0.1:8000/me" # 401, no key +curl -X POST "http://127.0.0.1:8000/keys" # {"api_key": "..."} +curl -i -H "X-API-Key: " "http://127.0.0.1:8000/me" # 200 +``` + +:::tip[Ce magasin de clés en mémoire oublie tout au redémarrage, et c'est très bien ici] +`_VALID_KEYS` vit dans un simple `set` Python dans la mémoire de ce processus — redémarre le serveur et chaque clé émise précédemment cesse de fonctionner. Un vrai produit persisterait les clés dans une base de données (et stockerait un *hash* de chaque clé, pas la valeur brute, de la même façon que les mots de passe sont hachés — pour qu'une fuite de la base ne fuite pas des clés utilisables directement). Pour un projet d'apprentissage local, la version en mémoire est honnête et suffisante ; ne sois juste pas surpris quand ta clé cesse de fonctionner après que `--reload` a redémarré le processus. +::: + +**✅ Liste de vérification** + + +`GET /me` sans en-tête `X-API-Key` retourne un vrai `401`, avec un corps qui dit comment obtenir une clé. +`POST /keys` retourne une nouvelle clé à chaque fois que tu l'appelles. +`GET /me` avec une clé valide dans `X-API-Key` retourne `200` ; avec une clé inventée, il retourne toujours `401`. + + +**🤔 Question(s) socratique(s)** + +- `require_api_key` lit la clé depuis un en-tête `X-API-Key` personnalisé plutôt qu'un paramètre de requête (`?api_key=...`). Les paramètres de requête finissent couramment dans les journaux d'accès du serveur et l'historique du navigateur. Qu'est-ce que cela suggère sur l'approche la plus sûre pour une valeur secrète ? +- En ce moment, n'importe qui peut appeler `POST /keys` autant de fois qu'il veut, sans aucune limite. Est-ce un problème pour *ce* projet ? Qu'ajouterais-tu si c'était un vrai service public ? + +## Étape 4 : Une vraie limitation de débit + +C'est le vrai but du projet. Construis un limiteur de débit à fenêtre glissante qui suit les horodatages récents des requêtes de chaque clé et rejette les requêtes une fois qu'une clé dépasse son budget dans une fenêtre : + +```python +# rate_limit.py +import time +from collections import defaultdict, deque + + +class SlidingWindowRateLimiter: + def __init__(self, max_requests: int, window_seconds: float) -> None: + self.max_requests = max_requests + self.window_seconds = window_seconds + self._history: dict[str, deque[float]] = defaultdict(deque) + + def check(self, key: str, now: float | None = None) -> tuple[bool, float]: + now = time.monotonic() if now is None else now + history = self._history[key] + + cutoff = now - self.window_seconds + while history and history[0] <= cutoff: + history.popleft() + + if len(history) < self.max_requests: + history.append(now) + return True, 0.0 + + retry_after = history[0] + self.window_seconds - now + return False, max(retry_after, 0.0) +``` + +Chaque clé a son propre `deque` d'horodatages, du plus ancien au plus récent. À chaque vérification, les horodatages plus anciens que `window_seconds` sont retirés par la gauche avant de compter ce qui reste — c'est une fenêtre glissante **exacte**, pas une approximation par compartiments qui se réinitialise à une frontière d'horloge fixe. Cette distinction compte : un limiteur à *fenêtre fixe* (disons, « réinitialise le compteur toutes les 10 secondes sur l'horloge ») laisse un client envoyer toute sa quote-part juste à la fin d'une fenêtre et toute sa quote-part de nouveau juste au début de la suivante, atteignant jusqu'à 2x son débit prévu en quelques vraies secondes. Suivre les horodatages réels évite cela. + +Branche-le dans une dépendance et utilise-le sur `/me` : + +```python +from fastapi import Response + +RATE_LIMIT_MAX_REQUESTS = 5 +RATE_LIMIT_WINDOW_SECONDS = 10.0 +limiter = SlidingWindowRateLimiter(RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_SECONDS) + + +def enforce_rate_limit(response: Response, api_key: str = Depends(require_api_key)) -> str: + allowed, retry_after = limiter.check(api_key, now=time.monotonic()) + if not allowed: + retry_after_seconds = str(int(retry_after) + 1) + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded: max {RATE_LIMIT_MAX_REQUESTS} per {int(RATE_LIMIT_WINDOW_SECONDS)}s.", + headers={"Retry-After": retry_after_seconds}, + ) + response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT_MAX_REQUESTS) + return api_key + + +@app.get("/me") +def whoami(api_key: str = Depends(enforce_rate_limit)) -> dict: + return {"api_key": api_key} +``` + +Remarque que les en-têtes sont définis de deux façons différentes selon le résultat — ce n'est pas un choix stylistique, c'est requis. Envoie six requêtes en rapide succession avec la même clé : + +```bash +KEY=$(curl -s -X POST "http://127.0.0.1:8000/keys" | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])") +for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code}\n" -H "X-API-Key: $KEY" "http://127.0.0.1:8000/me"; done +``` + +Les cinq premières devraient afficher `200` ; la sixième devrait afficher `429`. Vérifie les en-têtes sur cette dernière : + +```bash +curl -i -H "X-API-Key: $KEY" "http://127.0.0.1:8000/me" +``` + +:::tip[En-têtes HTTPException, pas `response.headers`, sur le chemin d'erreur] +C'est tentant de définir `response.headers["Retry-After"] = ...` juste avant de lever `HTTPException`, de la même façon que le chemin de succès définit `X-RateLimit-Limit`. Ne le fais pas — quand FastAPI transforme une `HTTPException` levée en une véritable réponse HTTP, il construit un objet de réponse **neuf** à partir de l'exception, jetant au passage tout ce qui a été écrit dans le paramètre `response` injecté. Tout en-tête qui doit apparaître sur une réponse d'erreur doit être passé directement à `HTTPException(..., headers={...})`, sinon il n'atteint jamais le client, en silence. Cela a mordu la toute première version du code d'exemple de cette leçon elle-même — vérifie avec `curl -i` que ton `429` porte réellement `Retry-After`, ne fais pas simplement confiance au fait que définir `response.headers` a fonctionné. +::: + +**✅ Liste de vérification** + + +Les premières `RATE_LIMIT_MAX_REQUESTS` requêtes d'une clé dans la fenêtre réussissent avec `200`. +La requête suivante de cette même clé, toujours dans la fenêtre, retourne un vrai `429`. +La réponse `429` porte réellement un en-tête `Retry-After` — vérifié avec `curl -i`, pas supposé. +Attendre que la fenêtre soit passée et réessayer réussit de nouveau (la limite n'est pas permanente). + + +**🤔 Question(s) socratique(s)** + +- Pourquoi rattacher l'historique du limiteur à la clé API plutôt qu'à l'adresse IP ? Qu'est-ce qui changerait (en mieux ou en pire) si tu le rattachais à l'IP, surtout pour des clients derrière un NAT d'entreprise partagé ? +- La méthode `check` du limiteur prend `now` comme paramètre optionnel au lieu d'appeler toujours `time.monotonic()` en interne. Qu'est-ce que cela t'apporte quand tu écris un test pour elle — essaie d'en écrire un qui simule le passage du temps sans un vrai `time.sleep()`. + +:::tip[C'est un limiteur à l'échelle jouet exprès — la production a une vraie réponse] +`SlidingWindowRateLimiter` est authentiquement correct, mais il est aussi authentiquement mono-processus : l'état vit dans un seul dict Python, dans un seul worker `uvicorn`. Fais-le tourner derrière deux workers, ou deux répliques de serveur derrière un load balancer, et chacun suit son propre comptage indépendant pour la même clé — un client pourrait atteindre jusqu'à N-fois-les-instances le débit prévu. La limitation de débit en production pour un service multi-instances déplace presque toujours cet état vers quelque chose de partagé, comme Redis (`INCR` avec un `TTL` est un bloc de construction courant), pour que chaque instance voie le même comptage. Des bibliothèques comme [`slowapi`](https://github.com/laurentS/slowapi) existent spécifiquement pour envelopper ce pattern dans un décorateur — bon à savoir, même si cette leçon a délibérément construit la partie intéressante à la main plutôt que de l'importer. +::: + +## ⚠️ Pièges courants + +- **Définir des en-têtes sur `response` avant de lever un `HTTPException`.** Comme couvert ci-dessus — ils sont jetés. Passe-les à `HTTPException(headers={...})` à la place. +- **Oublier que les contrôles de style `raise_for_status` n'appliquent nulle part ici — ce projet est le serveur, pas le client.** C'est facile d'ajouter par réflexe une gestion d'erreurs pour *appeler* une API alors que tout l'intérêt de ce projet est d'en *être* une ; les erreurs qui comptent ici sont celles que tes propres endpoints retournent aux appelants (`401`, `404`, `429`), pas celles que tu reçois. +- **Utiliser `random` au lieu de `secrets` pour les clés API.** `random` n'est pas cryptographiquement sûr et sa sortie peut, en principe, être prédite — `secrets.token_urlsafe()` est construit spécifiquement pour des jetons sensibles à la sécurité comme celui-ci. +- **Tester la limitation de débit avec des requêtes espacées d'une seconde ou plus, à la main.** Taper les commandes `curl` une par une, en attendant chaque résultat, prend facilement plus de temps qu'une courte fenêtre de débit — la fenêtre ne cesse de glisser et tu ne verras jamais de `429`. Envoie plutôt plusieurs requêtes coup sur coup (une boucle shell, ou un court script Python). +- **Une limite de débit si basse qu'elle bloque la navigation normale sur `/quotes` pendant les tests.** Cette leçon met délibérément le limiteur de débit uniquement sur `/me`, pas sur les endpoints ouverts `/quotes`, pour que tu puisses parcourir le jeu de données librement tout en testant l'authentification et la limitation séparément. Garde cette séparation à l'esprit si tu l'étends. + +## Ce que tu viens de construire + +Une vraie API REST : des endpoints de liste et de détail paginés et filtrables par-dessus un jeu de données que tu as écrit toi-même, une émission de clés API en libre-service, une dépendance qui applique réellement l'authentification, et un limiteur de débit que tu as construit ligne par ligne au lieu de l'importer — logique à fenêtre glissante, réponses `429`, et un en-tête `Retry-After` correct inclus. C'est la même forme de conception clé-API-plus-limitation-de-débit utilisée par les vraies API publiques partout, juste sans un service tiers derrière. + +## Où aller à partir d'ici + +- Persiste les clés API (hachées, pas brutes) et les compteurs de limitation de débit dans un vrai magasin de données — SQLite pour les clés, Redis pour les compteurs de débit — pour que les deux survivent à un redémarrage et fonctionnent correctement sur plus d'un processus serveur. +- Ajoute des paliers de débit par clé (une clé « free » obtient 5 requêtes par 10 secondes, une clé « pro » en obtient 50) en stockant un palier à côté de chaque clé émise et en le consultant dans `enforce_rate_limit`. +- Déploie réellement ceci quelque part d'atteignable depuis l'extérieur de ta propre machine (un petit hôte toujours allumé, ou une plateforme serverless qui supporte les apps ASGI) et frappe-le depuis un téléphone ou la machine d'un ami — un projet comme celui-ci n'est complet que lorsqu'autre chose que `localhost` peut l'appeler. + +## Partage ton projet avec la classe + +Tu as construit quelque chose dont tu es fier ? [`examples/student-projects/`](https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/examples/student-projects) est une galerie de projets soumis par d'autres élèves — et son README a un tutoriel complet et adapté aux débutants pour ajouter le tien via une **pull request**, même si tu n'as jamais utilisé git avant : forker le dépôt, créer une branche, commiter tes fichiers, et ouvrir la PR, une étape à la fois. Aucune expérience préalable avec git n'est supposée. + +Bienvenue à l'écriture de Python hors du navigateur. 🎓 + +