অধ্যায় 6 · অনুশীলন ও ক্যাপস্টোন
অনুশীলন ও ইন্টারভিউ ল্যাব
- পৃষ্ঠা 21 / 22
- 61 মিনিট পড়া
ব্যাকএন্ড, ML ও AI ইঞ্জিনিয়ারের ইন্টারভিউতে সাধারণত সংজ্ঞা মুখস্থ বলতে বলা হয় না। আপনাকে হাতে দেওয়া হয় একটা ভাঙা রিপোজিটরি, একটা ব্যর্থ রিকোয়েস্ট, ডেটা ফাঁস করে দেওয়া একটা এন্ডপয়েন্ট, কিংবা একটা অস্পষ্ট প্রোডাক্ট আইডিয়া, আর ইন্টারভিউয়ার দেখেন আপনি কীভাবে ভাবেন: প্রথমে কী যাচাই করেন, কী প্রশ্ন করেন, কোন ট্রেড-অফগুলোর নাম বলেন, আর সমাধানটা যে কাজ করছে তা কীভাবে প্রমাণ করেন। বাস্তব কাজও ঠিক এমন। এই পাতা হলো এই টিউটোরিয়ালের সবকিছু জুড়ে ধাপে ধাপে কঠিন হওয়া চ্যালেঞ্জের একটা ল্যাব; প্রতিটার সঙ্গে আছে একটা ইঙ্গিত আর চালিয়ে দেখা যায় এমন ব্যাখ্যাসহ সমাধান।
সমাধান পড়ার আগে প্রতিটা চ্যালেঞ্জ নিজে চেষ্টা করুন। ইন্টারভিউয়ের মতো করে যুক্তিগুলো জোরে জোরে বলুন। প্রতিটা Git চ্যালেঞ্জ চলে তার নিজস্ব স্ক্র্যাচ রিপোজিটরিতে, প্রতিটা API চ্যালেঞ্জ চলে http://localhost:8800-এ থাকা BitByte Practice API-এর বিপরীতে (দ্বিতীয় একটা টার্মিনালে fastapi run practice_api.py --port 8800 দিয়ে চালু করুন), আর FastAPI-এর প্রতিটা ফিক্স প্রমাণ হয় TestClient বা pytest দিয়ে।
যা শিখবেন
- ইন্টারভিউয়াররা যেভাবে আশা করেন সেভাবে ডিবাগিং বা ডিজাইনের প্রশ্নে এগোনো: স্পষ্ট করা, রিপ্রোডিউস করা, ঠিক করা, প্রমাণ করা, ট্রেড-অফ ব্যাখ্যা করা
- চাপের মুখে Git উদ্ধারের দক্ষতা: restore, reset, কনফ্লিক্ট, rebase, reflog, হিস্ট্রিতে সিক্রেট, ডিফ রিভিউ
- HTTP ও API দক্ষতা: মেথড, স্ট্যাটাস কোড, ভাঙা JSON, পেজিনেশন, 429, টাইমআউট ও রিট্রাই, ইডেমপোটেন্সি, ওয়েবহুক সিগনেচার, SSE
- টেস্ট দিয়ে প্রমাণ করা যায় এমন FastAPI ও নিরাপত্তা ফিক্স: ভ্যালিডেশন, এরর রেসপন্স, ব্লকিং কল, সেশন লিক, মকিং, অথ, BOLA, SQL ইনজেকশন, CORS, সাইজ লিমিট, SSRF, লগ রিড্যাকশন
- আটটা সিস্টেম-ডিজাইন মিনি ইন্টারভিউ, প্রতিটার জন্য সাজানো একটা উত্তরসহ
ল্যাব কীভাবে কাজ করে
প্রতিটা চ্যালেঞ্জের একটা কঠিনতা ও পয়েন্ট আছে: সহজ (১ পয়েন্ট), মাঝারি (২), কঠিন (৩)। নিজেকে পয়েন্ট দিন শুধু তখনই, যখন আপনার উত্তর সমাধানে দেওয়া ফলো-আপ প্রশ্নটাও সামলাতে পারত। স্কোর করার নির্দেশিকা একেবারে শেষে আছে।
| ধাপ | আপনি কী করেন | ইন্টারভিউয়ার কী শোনেন |
|---|---|---|
| ১. স্পষ্ট করা | সমস্যাটা নিজের ভাষায় বলুন, স্কেল, ব্যবহারকারী ও সীমাবদ্ধতা নিয়ে প্রশ্ন করুন | আপনি ভুল সমস্যা সমাধান করছেন না |
| ২. রিপ্রোডিউস করা | কমান্ড বা টেস্ট দিয়ে ইচ্ছা করে ব্যর্থতাটা ঘটান | আপনি অনুমানে নয়, প্রমাণের ওপর কাজ করেন |
| ৩. ঠিক করা | আগে সবচেয়ে ছোট সঠিক পরিবর্তনটা | আপনি ঝুঁকি কম রাখেন |
| ৪. প্রমাণ করা | একই কমান্ড বা টেস্ট আবার চালান, সঙ্গে একটা এজ কেস | আপনি জানেন কাজ করছে, আশা করছেন না |
| ৫. ব্যাখ্যা করা | ট্রেড-অফটা বলুন, আর বড় স্কেলে কী করতেন তা বলুন | আপনি স্নিপেটের বাইরেও ভাবেন |
ল্যাব সেট আপ করুন
পুরো পাতার জন্য একটা ফোল্ডার আর একটা ভার্চুয়াল এনভায়রনমেন্ট। প্রথমে uv, কমেন্টে pip-এর সমতুল্য দেওয়া আছে:
mkdir -p ~/projects/lab && cd ~/projects/lab
uv venv -q && source .venv/bin/activate
uv pip install -q "fastapi[standard]" httpx2 pytest respx sqlalchemy pyjwt
# pip: python -m venv .venv && source .venv/bin/activate && pip install -q "fastapi[standard]" httpx2 pytest respx sqlalchemy pyjwt
curl -s http://localhost:8800/health{"status":"ok"}শেষ লাইনটা ব্যর্থ হলে বুঝবেন Practice API চলছে না: অন্য একটা টার্মিনালে চালু করে আবার চেষ্টা করুন।
পার্ট ১: Git চ্যালেঞ্জ
G1. মুছে যাওয়া ফাইল ও পুরোনো ভার্সন ফিরিয়ে আনা (সহজ)
কাজ: আপনি ভুল করে prompts.txt মুছে ফেলেছেন, আর একটা খারাপ এডিটে config.py নষ্ট করেছেন। দুটোই ফিরিয়ে আনুন। তারপর config.py-এর এক কমিট আগের ভার্সনটা ফিরিয়ে আনুন, যখন মডেল ছিল 'small'।
mkdir -p ~/projects/lab/git && cd ~/projects/lab/git
git init -q recover && cd recover
echo "MODEL = 'small'" > config.py
echo "Answer politely and briefly." > prompts.txt
git add . && git commit -qm "Add config and prompts"
echo "MODEL = 'large'" > config.py
git commit -qam "Use the large model"
rm prompts.txt
echo "MODEL = ???" > config.py
git status --short M config.py
D prompts.txtইঙ্গিত: দুটো ফাইলই এখনো ইনডেক্সে আর শেষ কমিটে আছে। একটা কমান্ড ইনডেক্স থেকে রিস্টোর করে; একটা অপশন অন্য কমিটকে সোর্স বানায়।
সমাধান:
git restore prompts.txt config.py
git status --short
cat config.py
git restore --source=HEAD~1 config.py
cat config.py
git status --shortMODEL = 'large'
MODEL = 'small'
M config.pyকেন কাজ করে: git restore <file> ইনডেক্স (স্টেজিং এরিয়া) থেকে ফাইলটা ওয়ার্কিং ট্রিতে কপি করে আনে, ফলে আনস্টেজড এডিট আর মুছে ফেলা দুটোই বাতিল হয়। --source=HEAD~1 বদলে একটা পুরোনো কমিট থেকে নেয়, আর তখন পরিবর্তনটা একটা সাধারণ মডিফিকেশন হিসেবে দেখা যায়, যা আপনি কমিট করতে বা ফেলে দিতে পারেন। মুছে ফেলাটা স্টেজ করা থাকলে (git rm) আপনাকে --staged --worktree যোগ করতে হতো। ফলো-আপ: "যে ফাইল কখনো কমিট করা হয়নি, Git কি তা ফিরিয়ে আনতে পারে?" না; এ কারণেই ছোট ছোট কমিট বারবার করতে হয়।
G2. কাজ না হারিয়ে শেষ কমিট বাতিল করা (সহজ)
কাজ: আপনি একটা ডিবাগ print-সহ "wip" নামে কমিট করেছেন। কমিটটা বাতিল করুন কিন্তু সব পরিবর্তন রেখে দিন, যাতে ঠিক করে ঠিকভাবে কমিট করতে পারেন।
cd ~/projects/lab/git && git init -q undo && cd undo
echo "def predict(x): return x * 2" > model.py
git add model.py && git commit -qm "Add predict"
echo "print('debug')" >> model.py
echo "TIMEOUT_SECONDS = 10" > settings.py
git add . && git commit -qm "wip"
git log --oneline532fa3f wip
43cb677 Add predictইঙ্গিত: git reset ব্রাঞ্চের পয়েন্টার সরায়; মোড ঠিক করে ইনডেক্স আর ফাইলের কী হবে।
সমাধান:
git reset --soft HEAD~1
git log --oneline
git status --short43cb677 Add predict
M model.py
A settings.pyকেন কাজ করে: --soft main-কে এক কমিট পিছিয়ে নেয় এবং পরিবর্তনগুলো স্টেজড রাখে। ডিফল্ট --mixed সেগুলো আনস্টেজড রাখত; --hard মুছে দিত। এখন ডিবাগ লাইনটা সরান, git add করুন আর একটা ঠিকঠাক মেসেজ দিয়ে কমিট করুন। ফলো-আপ: "আর কমিটটা যদি আগেই পুশ হয়ে গিয়ে থাকে?" তাহলে শেয়ার্ড হিস্ট্রি নতুন করে লিখবেন না: git revert <hash> ব্যবহার করুন, যা একটা নতুন কমিট যোগ করে সেটাকে বাতিল করে দেয়।
G3. মার্জ কনফ্লিক্ট সমাধান করা (মাঝারি)
কাজ: দুটো ব্রাঞ্চ একই টাইমআউট বদলেছে। দুটো মার্জ করুন এবং কনফ্লিক্ট এমনভাবে সমাধান করুন যাতে দুজনেরই উদ্দেশ্য টিকে থাকে।
cd ~/projects/lab/git && git init -q conflict && cd conflict
printf 'TIMEOUT = 10\nRETRIES = 3\n' > settings.py
git add . && git commit -qm "Add settings"
git switch -qc faster-timeouts
printf 'TIMEOUT = 5\nRETRIES = 3\n' > settings.py
git commit -qam "Lower timeout to 5 s for the weather API"
git switch -q main
printf 'TIMEOUT = 30\nRETRIES = 3\n' > settings.py
git commit -qam "Raise timeout to 30 s for slow LLM calls"
git merge faster-timeouts
cat settings.pyAuto-merging settings.py
CONFLICT (content): Merge conflict in settings.py
Automatic merge failed; fix conflicts and then commit the result.
<<<<<<< HEAD
TIMEOUT = 30
=======
TIMEOUT = 5
>>>>>>> faster-timeouts
RETRIES = 3ইঙ্গিত: কনফ্লিক্ট একটা প্রশ্ন, এরর নয়। দুটো কমিট মেসেজই পড়ুন: পরিবর্তন দুটো কি সত্যিই পরস্পরবিরোধী?
সমাধান: না, দুটো পরস্পরবিরোধী নয়। একজন চেয়েছিলেন দ্রুত API-র জন্য ছোট টাইমআউট, অন্যজন LLM কলের জন্য লম্বা টাইমআউট, তাই সঠিক উত্তর হলো দুটো আলাদা সেটিং, কোনো একজনকে জেতানো নয়।
printf 'WEATHER_TIMEOUT = 5\nLLM_TIMEOUT = 30\nRETRIES = 3\n' > settings.py
git add settings.py
git commit -q --no-edit
git log --oneline --graph* f971929 Merge branch 'faster-timeouts'
|\
| * a9baa1c Lower timeout to 5 s for the weather API
* | 1cff844 Raise timeout to 30 s for slow LLM calls
|/
* 58777fa Add settingsকেন কাজ করে: Git দুটো ভার্সনকে <<<<<<<, ======= আর >>>>>>>-এর মাঝখানে চিহ্নিত করে। আপনি চূড়ান্ত কন্টেন্ট লেখেন, প্রতিটা মার্কার সরান, সমাধান হয়েছে বোঝাতে git add করেন এবং কমিট করেন। আবার গোড়া থেকে শুরু করতে চাইলে git merge --abort আপনাকে বের করে আনে। ফলো-আপ: "কনফ্লিক্ট কীভাবে এড়াবেন?" ছোট, অল্প সময়ের ব্রাঞ্চ, ঘনঘন main মার্জ করে আনা, আর অন্য লেখকের সঙ্গে কথা বলা। এখানে একটা সেটিংয়ের নাম বদলানোর মানে যেখানে যেখানে সেটা পড়া হয় সেসব জায়গাও আপডেট করা, আর আপনার কিছু বাদ পড়লে টেস্টের সেটা ধরা উচিত।
G4. ফিচার ব্রাঞ্চ main-এর ওপর rebase করা (মাঝারি)
কাজ: আপনি যখন feature/cache-এ কাজ করছিলেন তখন main এগিয়ে গেছে। আপনার ব্রাঞ্চকে বর্তমান main থেকে শুরু করান, যাতে হিস্ট্রি একটা সরলরেখা হয়।
cd ~/projects/lab/git && git init -q rebase && cd rebase
echo "# Weather proxy" > README.md && git add . && git commit -qm "Initial commit"
git switch -qc feature/cache
echo "CACHE_TTL = 300" > cache.py && git add . && git commit -qm "Add cache settings"
echo "def get(key): ..." >> cache.py && git commit -qam "Add cache get"
git switch -q main
echo "fastapi" > requirements.txt && git add . && git commit -qm "Add requirements"
git log --oneline --graph --all* 1d9df1f Add cache get
* 0166514 Add cache settings
| * 3a97ca8 Add requirements
|/
* a5440c5 Initial commitইঙ্গিত: আগে ফিচার ব্রাঞ্চে সুইচ করুন, তারপর তার কমিটগুলো main-এর ওপরে আবার চালান।
সমাধান:
git switch -q feature/cache
git rebase -q main
git log --oneline --graph --all* 6d67bcf Add cache get
* 4f0f149 Add cache settings
* 3a97ca8 Add requirements
* a5440c5 Initial commitকেন কাজ করে: rebase আপনার দুটো কমিটকে main-এর সর্বশেষ কমিটের ওপরে আবার চালায়। হ্যাশগুলো দেখুন: একই পরিবর্তনসহ এগুলো নতুন কমিট, কারণ একটা কমিটের হ্যাশে তার প্যারেন্টও ঢোকে। এ কারণেই সোনালি নিয়ম হলো: শুধু সেই কমিটগুলোই rebase করুন যা আর কারও কাছে নেই। ব্রাঞ্চটা আগেই পুশ করা থাকলে আপনি পুশ করবেন git push --force-with-lease দিয়ে, যা এর মধ্যে অন্য কেউ পুশ করে থাকলে প্রত্যাখ্যান করে। rebase কনফ্লিক্টে থেমে গেলে ঠিক করুন, git add করুন, তারপর git rebase --continue (কিংবা --abort)। ফলো-আপ: "মার্জ নাকি rebase?" নিজের ব্রাঞ্চকে হালনাগাদ রাখতে rebase করুন; পুল রিকোয়েস্ট main-এ মার্জ (বা স্কোয়াশ-মার্জ) করুন; main নিজে কখনো rebase করবেন না।
G5. reflog দিয়ে "হারিয়ে যাওয়া" কমিট উদ্ধার (মাঝারি)
কাজ: একজন সতীর্থ git reset --hard HEAD~1 চালিয়েছেন, আর মডেল মেট্রিক্স-সহ কমিটটা git log থেকে উধাও। সেটা ফিরিয়ে আনুন।
cd ~/projects/lab/git && git init -q reflog && cd reflog
echo "v1" > model_card.md && git add . && git commit -qm "Model card v1"
echo "v2: accuracy 0.91 on the test set" > model_card.md && git commit -qam "Model card v2 with metrics"
git reset -q --hard HEAD~1
git log --oneline
cat model_card.mdc8c6d84 Model card v1
v1ইঙ্গিত: Git HEAD-এর প্রতিটা সরে যাওয়া একটা লোকাল জার্নালে লিখে রাখে, এমনকি যেগুলো কমিট "হারিয়ে ফেলে" সেগুলোও।
সমাধান:
git reflog -3
git branch rescue 'HEAD@{1}'
git reset -q --hard rescue
git log --oneline
cat model_card.mdc8c6d84 HEAD@{0}: reset: moving to HEAD~1
1144601 HEAD@{1}: commit: Model card v2 with metrics
c8c6d84 HEAD@{2}: commit (initial): Model card v1
1144601 Model card v2 with metrics
c8c6d84 Model card v1
v2: accuracy 0.91 on the test setকেন কাজ করে: কমিটটা কখনো মোছা হয়নি, শুধু কোনো রেফারেন্স তার দিকে ছিল না। git reflog দেখায় HEAD কোথায় ছিল; HEAD@{1} মানে "এক সরানো আগে"। সেখানে একটা ব্রাঞ্চ বসিয়ে দিলে কমিটটা আবার নিরাপদ। (কোটেশন চিহ্ন দেওয়া হয় যাতে PowerShell-এর মতো কিছু শেল ব্রেসগুলোকে বিশেষভাবে না দেখে।) ফলো-আপ: "সতীর্থের কমিটের বেলায়ও কি এটা কাজ করে?" না: reflog লোকাল এবং মেয়াদ শেষ হয়ে যায় (ডিফল্টে পৌঁছানো যায় না এমন এন্ট্রি ৩০ দিন পর, বাকিগুলো ৯০ দিন পর)। যেসব কমিট পুশ হয়েছে, সেগুলোর জন্য রিমোটে বা সতীর্থের ক্লোনে খুঁজুন।
G6. হিস্ট্রিতে সিক্রেট খুঁজে বের করা (কঠিন)
কাজ: কেউ একটা API কি কমিট করেছে আর পরের কমিটে সেটা "ঠিক" করেছে। প্রমাণ করুন কি-টা এখনো হিস্ট্রিতে আছে, যেসব কমিটে সেটা আছে সবগুলো খুঁজে বের করুন, এবং পুরো প্রতিক্রিয়ার ধাপগুলো বর্ণনা করুন।
cd ~/projects/lab/git && git init -q secrets && cd secrets
echo "import os" > app.py && git add . && git commit -qm "Start app"
echo 'LLM_API_KEY = "bb-live-9f8e7d6c5b4a"' >> app.py && git commit -qam "Call the LLM"
printf 'import os\nLLM_API_KEY = os.environ["LLM_API_KEY"]\n' > app.py && git commit -qam "Read the key from the environment"
grep -c "bb-live-" app.py0ইঙ্গিত: grep শুধু ওয়ার্কিং ট্রি দেখে। git log -S এমন কমিট খুঁজে দেয় যা কোনো স্ট্রিং যোগ বা মুছেছে; git grep যেকোনো কমিটের তালিকায় খুঁজতে পারে।
সমাধান:
git log --oneline -S "bb-live-"
git grep -n "bb-live-" $(git rev-list --all)57718d6 Read the key from the environment
3b7c6f7 Call the LLM
3b7c6f72d9e545b3f90afd4b8a371bd2217e3a9e:app.py:2:LLM_API_KEY = "bb-live-9f8e7d6c5b4a"কি-টা হিস্ট্রিতে আছে, আর ক্লোন আছে এমন যে কেউ সেটা পাবে। প্রতিক্রিয়া এই ক্রমে:
- আগে প্রোভাইডারের কাছে কি-টা বাতিল (revoke) বা বদল (rotate) করুন। পুশ হয়ে যাওয়া সিক্রেটকে চুরি হয়েছে ধরে নিতে হবে; বট কয়েক মিনিটের মধ্যে পাবলিক GitHub স্ক্যান করে। অপব্যবহার হয়েছে কি না প্রোভাইডারের ইউসেজ লগে দেখুন।
- নতুন কি শুধু এনভায়রনমেন্টে বা সিক্রেট ম্যানেজারে রাখুন (কোড ইতিমধ্যে
os.environপড়ে) আর.envযে.gitignore-এ আছে তা নিশ্চিত করুন। - রিপোজিটরি শেয়ার্ড বা পাবলিক হলে হিস্ট্রি নতুন করে লিখুন, একটা নতুন ক্লোনে, তারপর ফোর্স-পুশ করুন এবং সবাইকে আবার ক্লোন করতে বলুন:
pip install git-filter-repo
echo 'bb-live-9f8e7d6c5b4a==>REMOVED' > ../replacements.txt
git filter-repo --replace-text ../replacements.txt
git push --force-with-lease --all৪. পরেরটা ঠেকান: GitHub সিক্রেট স্ক্যানিং ও পুশ প্রটেকশন চালু রাখুন, আর pre-commit ও CI-তে gitleaks-এর মতো একটা স্ক্যানার যোগ করুন।
ফলো-আপ: "হিস্ট্রি যদি আবার লিখবেনই, তাহলে আগে rotate কেন?" কারণ ফর্ক, ক্লোন, ক্যাশ আর CI লগে পুরোনো হিস্ট্রি আগেই চলে গেছে; হিস্ট্রি নতুন করে লেখা শুধু নতুন কপি হওয়া ঠেকায়। ধাপ ৩ গোছানোর কাজ, ধাপ ১-ই আসল সমাধান। (AI প্রজেক্টে Git: ডেটা, মডেল, LFS, DVC ও ফাঁস হওয়া সিক্রেট পাতায় এটা গভীরভাবে আছে।)
G7. পুল রিকোয়েস্টের ডিফ রিভিউ করা (মাঝারি)
কাজ: একজন সতীর্থ আপনাকে feature/forecast রিভিউ করতে বলেছেন। main-এর তুলনায় ব্রাঞ্চটা ঠিক কী বদলেছে তা দেখুন, এবং রিভিউটা লিখুন।
cd ~/projects/lab/git && git init -q review && cd review
cat > client.py <<'EOF'
import requests
def get_weather(city):
r = requests.get("https://api.example.com/v1/weather", params={"city": city}, timeout=5)
r.raise_for_status()
return r.json()
EOF
git add . && git commit -qm "Add weather client"
git switch -qc feature/forecast
cat > client.py <<'EOF'
import requests
API_KEY = "bb-live-1a2b3c4d"
def get_weather(city):
url = "https://api.example.com/v1/weather?city=" + city
r = requests.get(url, headers={"X-API-Key": API_KEY})
print("weather response:", r.text, API_KEY)
return r.json()
EOF
git commit -qam "Add API key and logging"
git switch -q mainইঙ্গিত: git diff main...feature/forecast (তিনটা ডট) শুধু দেখায় ব্রাঞ্চটা main থেকে আলাদা হওয়ার পর কী যোগ করেছে, পুল রিকোয়েস্ট পাতায় ঠিক এটাই দেখা যায়।
সমাধান:
git diff --stat main...feature/forecast
git diff main...feature/forecast client.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/client.py b/client.py
index e7c432b..f22a4b1 100644
--- a/client.py
+++ b/client.py
@@ -1,7 +1,10 @@
import requests
+API_KEY = "bb-live-1a2b3c4d"
+
def get_weather(city):
- r = requests.get("https://api.example.com/v1/weather", params={"city": city}, timeout=5)
- r.raise_for_status()
+ url = "https://api.example.com/v1/weather?city=" + city
+ r = requests.get(url, headers={"X-API-Key": API_KEY})
+ print("weather response:", r.text, API_KEY)
return r.json()| লাইন | মন্তব্য | গুরুত্ব |
|---|---|---|
API_KEY = "bb-live-…" | সোর্সে একটা লাইভ সিক্রেট। এখনই rotate করুন, তারপর সেটিংস/এনভায়রনমেন্ট থেকে লোড করুন। | ব্লকার |
print(… API_KEY) | সিক্রেট আর পুরো রেসপন্স বডি লগে ফেলছে। logging ব্যবহার করুন, শুধু স্ট্যাটাস আর সময় লগ করুন। | ব্লকার |
requests.get(url, …) | টাইমআউট সরানো হয়েছে: একটা ধীর আপস্ট্রিম কল একটা ওয়ার্কারকে চিরকালের জন্য ঝুলিয়ে রাখতে পারে। timeout=5 ফিরিয়ে আনুন। | ব্লকার |
"…?city=" + city | কোয়েরি হাতে জোড়া লাগানো হয়েছে: "Cox's Bazar" বা & থাকলে ভেঙে যায়, আর ব্যবহারকারীরা বাড়তি প্যারামিটার ঢুকিয়ে দিতে পারে। params= ব্যবহার করুন। | ঠিক করতেই হবে |
সরানো raise_for_status() | 401 বা 500 এখন r.json()-এ পৌঁছায় এবং বিভ্রান্তিকরভাবে ব্যর্থ হয়, নয়তো এরর বডিকেই ডেটা হিসেবে ফেরত দেয়। | ঠিক করতেই হবে |
| কমিট মেসেজ | "Add API key and logging" কীভাবে করা হয়েছে তা বলে, কেন করা হয়েছে তা নয়; কোনো টেস্টও নেই। | ছোটখাটো |
ফলো-আপ: "রিভিউ কীভাবে সদয় ও কাজের রাখবেন?" প্রতিটা মন্তব্যে কেন তা ব্যাখ্যা করুন, ব্লকার আর ছোটখাটো বিষয় আলাদা করুন, ফিক্স সাজেস্ট করুন, আর যা ভালো হয়েছে তার প্রশংসা করুন। ব্লকারগুলো ঠিক হলে এবং CI সবুজ হলেই অ্যাপ্রুভ করুন।
পার্ট ২: HTTP ও API চ্যালেঞ্জ
H1. মেথড ও স্ট্যাটাস কোড বেছে নেওয়া (সহজ)
কাজ: একটা চ্যাট প্রোডাক্টের প্রতিটা অপারেশনের জন্য HTTP মেথড, একটা পাথ আর সফল হলে স্ট্যাটাস কোড বেছে নিন।
ইঙ্গিত: প্রতিটার জন্য দুটো প্রশ্ন করুন: এটা কি স্টেট বদলায় (সেফ কি না)? আবার চালালে কি ফল আবার বদলায় (ইডেমপোটেন্ট কি না)?
সমাধান:
| অপারেশন | মেথড ও পাথ | সফল হলে | নোট |
|---|---|---|---|
| একটা কনভারসেশনের মেসেজ পড়া | GET /v1/conversations/{id}/messages | 200 | সেফ, ক্যাশযোগ্য |
| নতুন কনভারসেশন শুরু করা | POST /v1/conversations | 201 + Location | ইডেমপোটেন্ট নয়: একটা Idempotency-Key যোগ করুন |
| কনভারসেশনের নাম বদলানো | PATCH /v1/conversations/{id} | 200 | আংশিক আপডেট |
| মডেলের পুরো কনফিগ বদলে দেওয়া | PUT /v1/models/{name}/config | 200 (তৈরি হলে 201) | ইডেমপোটেন্ট: একই বডি, একই ফল |
| আপলোড করা ডকুমেন্ট মোছা | DELETE /v1/documents/{id} | 204 | ফলাফলের দিক থেকে ইডেমপোটেন্ট; আবার করলে 404 আসতে পারে |
| ১০,০০০ ডকুমেন্ট এমবেড করা | POST /v1/embedding-jobs | 202 + স্ট্যাটাস URL | দীর্ঘ সময় লাগে: পোল করুন বা ওয়েবহুক ব্যবহার করুন |
| বড় ফিল্টার দিয়ে সার্চ করা | POST /v1/documents/search | 200 | বডি বড় বলে POST; ডিফল্টে ক্যাশযোগ্য নয় |
| কোনো রিসোর্স বদলেছে কি না দেখা | If-None-Match-সহ GET | 304 | না বদলালে বডি থাকে না |
Practice API-র echo এন্ডপয়েন্ট দিয়ে আপনি দেখতে পারেন একটা মেথড সার্ভারে কীভাবে পৌঁছায়:
curl -s -X PATCH http://localhost:8800/v1/echo -H "Content-Type: application/json" -d '{"title": "Exam prep"}'{"method":"PATCH","query":{},"headers":{"user-agent":"curl/8.5.0","accept":"*/*","content-type":"application/json"},"json":{"title":"Exam prep"}}H2. স্ট্যাটাস কোড ব্যাখ্যা করা (সহজ)
কাজ: এই রিকোয়েস্টগুলো চালান। প্রতিটা স্ট্যাটাসের জন্য বলুন তার মানে কী, দোষটা কার, এবং ক্লায়েন্টের রিট্রাই করা উচিত কি না।
cd ~/projects/lab
for path in /v1/courses/3 /v1/courses/99 /v1/weather /v1/me /v1/status/503; do
printf '%-16s %s\n' "$path" "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:8800$path")"
done
curl -s -o /dev/null -w 'POST no key %{http_code}\n' -X POST http://localhost:8800/v1/reviews \
-H "Content-Type: application/json" -d '{"course_id": 2, "rating": 5, "comment": "Clear and useful"}'
curl -s -o /dev/null -w 'POST wrong key %{http_code}\n' -X POST http://localhost:8800/v1/reviews \
-H "X-API-Key: wrong" -H "Content-Type: application/json" -d '{"course_id": 2, "rating": 5, "comment": "Clear and useful"}'
for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' -H "X-Client-Id: lab-h2" http://localhost:8800/v1/limited; done; echo/v1/courses/3 200
/v1/courses/99 404
/v1/weather 422
/v1/me 401
/v1/status/503 503
POST no key 401
POST wrong key 403
200 200 200 429ইঙ্গিত: 4xx মানে "ক্লায়েন্টকে কিছু বদলাতে হবে"; 5xx মানে "সার্ভার ব্যর্থ হয়েছে, একই রিকোয়েস্ট পরে কাজ করতে পারে"।
সমাধান:
| স্ট্যাটাস | এখানে মানে | দোষ | রিট্রাই? |
|---|---|---|---|
| 200 | কোর্স ৩ পাওয়া গেছে | — | — |
| 404 | কোর্স ৯৯ নেই | ক্লায়েন্ট | না |
| 422 | city কোয়েরি প্যারামিটার নেই (ভ্যালিডেশন) | ক্লায়েন্ট | না: রিকোয়েস্ট ঠিক করুন |
| 401 | কোনো ক্রেডেনশিয়াল নেই (বেয়ারার টোকেন নেই, API কি নেই) | ক্লায়েন্ট | শুধু টোকেন পাওয়ার পর |
| 403 | ক্রেডেনশিয়াল বোঝা গেছে কিন্তু অনুমতি নেই (ভুল কি) | ক্লায়েন্ট | না |
| 429 | রেট লিমিট: ১০ সেকেন্ডের মধ্যে চতুর্থ কল | ক্লায়েন্ট (বেশি দ্রুত) | হ্যাঁ, Retry-After-এর পর |
| 503 | সার্ভিস সাময়িকভাবে অনুপলব্ধ | সার্ভার | হ্যাঁ, ব্যাকঅফসহ |
ফলো-আপ: "401 নাকি 403?" 401 মানে "আপনি কে?" (এবং WWW-Authenticate থাকা উচিত); 403 মানে "আমি জানি আপনি কে, আর আপনার জন্য উত্তর: না"। অন্য ব্যবহারকারীর প্রাইভেট রিসোর্সের বেলায় অনেক API 404 ফেরত দেয়, যাতে সেটা যে আছে তা জানা না যায়।
H3. ভাঙা JSON রিকোয়েস্ট ডিবাগ করা (সহজ)
কাজ: এই তিনটা রিকোয়েস্ট একটা রিভিউ তৈরি করতে চায় এবং তিনটাই ব্যর্থ হয়। প্রতিটা এরর পড়ুন আর রিকোয়েস্ট ঠিক করুন।
curl -s -X POST http://localhost:8800/v1/reviews -H "X-API-Key: practice-key-123" \
-H "Content-Type: application/json" -d '{"course_id": 2, "rating": 5, "comment": "Clear and useful",}'
echo
curl -s -X POST http://localhost:8800/v1/reviews -H "X-API-Key: practice-key-123" \
-d '{"course_id": 2, "rating": 5, "comment": "Clear and useful"}'
echo
curl -s -X POST http://localhost:8800/v1/reviews -H "X-API-Key: practice-key-123" \
-H "Content-Type: application/json" -d '{"course_id": "two", "rating": 6, "comment": "Clear and useful"}'
echo{"detail":[{"type":"json_invalid","loc":["body",60],"msg":"JSON decode error","input":{},"ctx":{"error":"Expecting property name enclosed in double quotes"}}]}
{"detail":[{"type":"model_attributes_type","loc":["body"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"{\"course_id\": 2, \"rating\": 5, \"comment\": \"Clear and useful\"}"}]}
{"detail":[{"type":"int_parsing","loc":["body","course_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"two"},{"type":"less_than_equal","loc":["body","rating"],"msg":"Input should be less than or equal to 5","input":6,"ctx":{"le":5}}]}ইঙ্গিত: প্রতিটা এররের type আর loc দেখুন: loc-এর মধ্যে একটা সংখ্যা মানে বডির একটা ক্যারেক্টার পজিশন।
সমাধান: (১) ৬০তম ক্যারেক্টারে json_invalid: শেষে একটা বাড়তি কমা, যা JSON অনুমতি দেয় না। (২) model_attributes_type, যেখানে পুরো বডি একটা স্ট্রিং: Content-Type: application/json নেই, তাই curl এটাকে ফর্ম হিসেবে পাঠিয়েছে এবং সার্ভার JSON হিসেবে পার্স করেনি। (৩) JSON বৈধ, কিন্তু মানগুলো স্কিমা ভাঙছে: course_id ইন্টিজার নয় আর rating ৫-এর বেশি। ঠিক করার উপায় হলো বৈধ JSON, হেডার আর বৈধ মান; curl-এর --json অপশন আপনার জন্য Content-Type আর Accept দুটোই সেট করে দেয়:
curl -s -X POST http://localhost:8800/v1/reviews -H "X-API-Key: practice-key-123" \
--json '{"course_id": 2, "rating": 5, "comment": "Clear and useful"}'{"id":1,"course_id":2,"rating":5,"comment":"Clear and useful"}ফলো-আপ: "bash-এ কাজ করে কিন্তু PowerShell-এ করে না।" PowerShell কোটেশন আলাদাভাবে সামলায়; JSON একটা ফাইলে রাখুন এবং --json @review.json দিয়ে পাঠান, অথবা Python-এ json= দিয়ে রিকোয়েস্ট বানান, যা কখনো অবৈধ JSON তৈরি করতে পারে না।
H4. পেজিনেশন সামলানো (মাঝারি)
কাজ: httpx দিয়ে /v1/courses থেকে সব কোর্স সংগ্রহ করুন, প্রতি পাতায় চারটা করে। পাতার সংখ্যা হার্ড-কোড করবেন না, আর সার্ভার গোলমাল করলে অনন্তকাল লুপও চালাবেন না।
ইঙ্গিত: next_page null না হওয়া পর্যন্ত সেটা অনুসরণ করুন, আর পাতার সংখ্যার একটা সীমা দিন।
সমাধান:
import httpx
def fetch_all_courses(client: httpx.Client, level: str | None = None, max_pages: int = 50) -> list[dict]:
items, page = [], 1
for _ in range(max_pages):
params = {"page": page, "per_page": 4}
if level:
params["level"] = level
r = client.get("/v1/courses", params=params)
r.raise_for_status()
data = r.json()
items.extend(data["items"])
if data["next_page"] is None:
return items
page = data["next_page"]
raise RuntimeError(f"more than {max_pages} pages: is the server looping?")
with httpx.Client(base_url="http://localhost:8800", timeout=5) as client:
courses = fetch_all_courses(client)
advanced = fetch_all_courses(client, level="advanced")
print(len(courses), [c["id"] for c in courses])
print([c["title"] for c in advanced])10 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
['RAG Systems', 'AI Agents', 'LLMOps']কেন কাজ করে: লুপটা পাতা হিসাব করার বদলে সার্ভারের next_page-কে বিশ্বাস করে, একটাই ক্লায়েন্ট (একটা কানেকশন পুল) ব্যবহার করে, প্রতিটা স্ট্যাটাস যাচাই করে এবং একটা নিরাপদ সর্বোচ্চ সংখ্যার পরে থামে। ফলো-আপ: "পেজ করার সময় নতুন সারি ঢুকলে কী হয়?" অফসেট পেজিনেশনে (page/per_page) আইটেম বাদ পড়তে বা দুবার আসতে পারে, কারণ পজিশন সরে যায়। কার্সার পেজিনেশন (?after=<last id>) স্থিতিশীল, আর এ কারণেই বেশিরভাগ বড় API এটা ব্যবহার করে (মজবুত API ডিজাইন: পেজিনেশন, রেট লিমিট, রিট্রাই ও ভার্সনিং)।
H5. 429 Too Many Requests থেকে সামলে ওঠা (মাঝারি)
কাজ: /v1/limited প্রতি ক্লায়েন্টকে ১০ সেকেন্ডে ৩টা রিকোয়েস্টের অনুমতি দেয়। পরপর চারটা কল করুন যাতে চতুর্থটা সফল হয়, সার্ভারকে ক্রমাগত আঘাত না করে।
ইঙ্গিত: 429 রেসপন্স আপনাকে বলে কতক্ষণ অপেক্ষা করতে হবে।
সমাধান:
import time
import httpx
def get_respecting_limits(client: httpx.Client, path: str, max_attempts: int = 3, max_wait: int = 30) -> httpx.Response:
for attempt in range(1, max_attempts + 1):
r = client.get(path)
if r.status_code != 429:
return r
wait = min(int(r.headers.get("Retry-After", "1")), max_wait)
print(f" attempt {attempt}: 429, waiting {wait} s as Retry-After says")
time.sleep(wait)
r.raise_for_status()
return r
with httpx.Client(base_url="http://localhost:8800", headers={"X-Client-Id": "lab-h5"}, timeout=5) as client:
for call in range(1, 5):
r = get_respecting_limits(client, "/v1/limited")
print(f"call {call}: {r.status_code} {r.json()}")call 1: 200 {'ok': True, 'remaining': 2}
call 2: 200 {'ok': True, 'remaining': 1}
call 3: 200 {'ok': True, 'remaining': 0}
attempt 1: 429, waiting 10 s as Retry-After says
call 4: 200 {'ok': True, 'remaining': 2}কেন কাজ করে: ক্লায়েন্ট 429-কে এরর নয়, "ধীরে চলো" হিসেবে নেয়, সার্ভার যতক্ষণ বলে ঠিক ততক্ষণ অপেক্ষা করে, অপেক্ষার ওপর একটা সীমা বসায় যাতে খারাপ হেডার তাকে এক ঘণ্টা আটকে রাখতে না পারে, এবং কয়েকবার চেষ্টার পর হাল ছেড়ে দেয়। Retry-After একটা HTTP তারিখও হতে পারে; মজবুত ক্লায়েন্ট দুটোই সামলায় (এটাই শেষের মাঝারি অনুশীলন)। ফলো-আপ: "গোড়াতেই 429 কীভাবে এড়াবেন?" ক্লায়েন্টেই থ্রটল করুন (টোকেন বাকেট বা সেমাফোর), রিকোয়েস্ট ব্যাচ করুন, রেসপন্স ক্যাশ করুন, আর দেয়ালে ধাক্কা খাওয়ার আগেই প্রোভাইডারের রেট-লিমিট হেডার পড়ুন।
H6. টাইমআউট আর ব্যাকঅফসহ রিট্রাই (মাঝারি)
কাজ: এমন একটা GET হেল্পার লিখুন যা 503 আর নেটওয়ার্ক এররে এক্সপোনেনশিয়াল ব্যাকঅফ ও জিটারসহ রিট্রাই করে, আর সার্ভার খুব ধীর হলে পরিষ্কারভাবে হাল ছেড়ে দেয়।
ইঙ্গিত: টাইমআউট ক্লায়েন্টে সেট করুন; httpx.TransportError ধরুন (টাইমআউট এর সাবক্লাস); শুধু সেটাই রিট্রাই করুন যা পরে সফল হতে পারে।
সমাধান:
import random
import time
import httpx
RETRY_STATUSES = {429, 502, 503, 504}
def get_with_retries(client: httpx.Client, path: str, attempts: int = 4, base_delay: float = 0.2) -> httpx.Response:
for attempt in range(1, attempts + 1):
try:
r = client.get(path)
if r.status_code not in RETRY_STATUSES:
return r
reason = str(r.status_code)
except httpx.TransportError as exc:
reason = type(exc).__name__
if attempt == attempts:
raise RuntimeError(f"gave up after {attempts} attempts (last: {reason})")
print(f" attempt {attempt} failed ({reason}), retrying")
time.sleep(base_delay * 2 ** (attempt - 1) * random.uniform(0.5, 1.0))
timeout = httpx.Timeout(5.0, read=0.5)
with httpx.Client(base_url="http://localhost:8800", timeout=timeout) as client:
r = get_with_retries(client, "/v1/flaky?key=lab-h6&fail_times=2")
print(r.status_code, r.json())
try:
get_with_retries(client, "/v1/slow?seconds=2", attempts=2)
except RuntimeError as exc:
print(exc) attempt 1 failed (503), retrying
attempt 2 failed (503), retrying
200 {'ok': True, 'attempt': 3}
attempt 1 failed (ReadTimeout), retrying
gave up after 2 attempts (last: ReadTimeout)কেন কাজ করে: প্রতিবার দেরি দ্বিগুণ হয় (০.২, ০.৪, ০.৮ সেকেন্ড…) আর এলোমেলো গুণক (জিটার) হাজার হাজার ক্লায়েন্টকে একসঙ্গে তালে তালে রিট্রাই করা থেকে ঠেকায়। ০.৫ সেকেন্ডের রিড টাইমআউট ঝুলে থাকা সার্ভারকে দ্রুত, স্পষ্ট ব্যর্থতায় পরিণত করে। ফলো-আপ: "পেমেন্ট তৈরি করে এমন POST কি রিট্রাই করবেন?" শুধু ইডেমপোটেন্সি কি নিয়ে (পরের চ্যালেঞ্জ); নইলে টাইমআউটের পর রিট্রাই করলে দুবার চার্জ হতে পারে, কারণ প্রথম রিকোয়েস্ট সার্ভারে সফল হয়ে থাকতে পারে।
H7. ইডেমপোটেন্ট create এন্ডপয়েন্ট ডিজাইন করা (কঠিন)
কাজ: ক্লায়েন্ট টাইমআউটের পর রিট্রাই করলেও POST /v1/payments যেন কখনো দুবার চার্জ না করে। একটা Idempotency-Key হেডার বানান এবং টেস্ট দিয়ে প্রমাণ করুন।
ইঙ্গিত: কি-টা রিকোয়েস্ট বডির একটা ফিঙ্গারপ্রিন্ট আর আপনার পাঠানো রেসপন্সসহ সংরক্ষণ করুন। একই কি ও একই বডি: রিপ্লে করুন। একই কি কিন্তু আলাদা বডি: প্রত্যাখ্যান করুন।
সমাধান:
import hashlib
import json
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException, Response
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class PaymentIn(BaseModel):
user_id: int
amount_bdt: Annotated[int, Field(gt=0)]
app = FastAPI()
PAYMENTS: list[dict] = []
IDEMPOTENCY: dict[str, tuple[str, dict]] = {} # key -> (body fingerprint, response); a DB table or Redis in production
@app.post("/v1/payments", status_code=201)
def create_payment(payment: PaymentIn, response: Response,
idempotency_key: Annotated[str | None, Header()] = None) -> dict:
if not idempotency_key:
raise HTTPException(400, "Idempotency-Key header is required")
fingerprint = hashlib.sha256(json.dumps(payment.model_dump(), sort_keys=True).encode()).hexdigest()
if idempotency_key in IDEMPOTENCY:
saved_fingerprint, saved_response = IDEMPOTENCY[idempotency_key]
if saved_fingerprint != fingerprint:
raise HTTPException(422, "Idempotency-Key was already used with a different body")
response.headers["Idempotent-Replayed"] = "true"
return saved_response
result = {"id": len(PAYMENTS) + 1, **payment.model_dump(), "status": "charged"}
PAYMENTS.append(result)
IDEMPOTENCY[idempotency_key] = (fingerprint, result)
return result
client = TestClient(app)
body = {"user_id": 7, "amount_bdt": 500}
first = client.post("/v1/payments", json=body, headers={"Idempotency-Key": "pay-7-0001"})
retry = client.post("/v1/payments", json=body, headers={"Idempotency-Key": "pay-7-0001"})
misuse = client.post("/v1/payments", json={"user_id": 7, "amount_bdt": 900}, headers={"Idempotency-Key": "pay-7-0001"})
missing = client.post("/v1/payments", json=body)
print(first.status_code, first.json())
print(retry.status_code, retry.json(), retry.headers.get("idempotent-replayed"))
print(misuse.status_code, misuse.json())
print(missing.status_code, missing.json())
print("payments stored:", len(PAYMENTS))201 {'id': 1, 'user_id': 7, 'amount_bdt': 500, 'status': 'charged'}
201 {'id': 1, 'user_id': 7, 'amount_bdt': 500, 'status': 'charged'} true
422 {'detail': 'Idempotency-Key was already used with a different body'}
400 {'detail': 'Idempotency-Key header is required'}
payments stored: 1কেন কাজ করে: রিট্রাই একই 201 আর একই পেমেন্ট ID পেয়েছে, এবং মাত্র একটা পেমেন্ট আছে। আলাদা রিকোয়েস্টে একই কি ব্যবহার করা ক্লায়েন্টের বাগ, তাই সেটা 422 পায় (IETF Idempotency-Key ড্রাফট এই স্ট্যাটাসই সুপারিশ করে)। প্রোডাকশনে: কি-গুলো ডেটাবেসে রাখুন পেমেন্টের একই ট্রানজ্যাকশনে, কি-এর ওপর ইউনিক কনস্ট্রেইন্টসহ (অথবা Redis SET key value NX EX 86400), একদিনের মতো পরে মেয়াদ শেষ করুন, প্রতি ব্যবহারকারীর স্কোপে রাখুন, এবং ওই কি-এর প্রথম রিকোয়েস্ট চলতে থাকা অবস্থায় 409 ফেরত দিন। ফলো-আপ: "ডুপ্লিকেট পেমেন্ট আছে কি না শুধু তা-ই কেন যাচাই করবেন না?" দুটো একই পেমেন্ট বৈধ হতে পারে (দুই কাপ কফি); রিকোয়েস্টটা রিট্রাই কি না তা শুধু ক্লায়েন্ট জানে, আর কি সেটাই জানিয়ে দেয়।
H8. ওয়েবহুক সিগনেচার যাচাই করা (মাঝারি)
কাজ: একটা পেমেন্ট প্রোভাইডার X-Webhook-Timestamp আর X-Webhook-Signature: sha256=<hex> হেডারসহ ওয়েবহুক পাঠায়, যেখানে hex হলো একটা শেয়ার্ড সিক্রেট দিয়ে "<timestamp>." + raw body-এর HMAC-SHA256। এই ভেরিফায়ার বৈধ ওয়েবহুকও প্রত্যাখ্যান করছে। বাগটা খুঁজে বের করুন, তারপর একটা সঠিক ভেরিফায়ার লিখুন।
import hashlib
import hmac
import json
SECRET = b"whsec_lab_secret"
def sign(body: bytes, timestamp: int, secret: bytes = SECRET) -> str:
message = str(timestamp).encode() + b"." + body
return "sha256=" + hmac.new(secret, message, hashlib.sha256).hexdigest()
def verify_buggy(body: bytes, timestamp: str, signature: str) -> bool:
data = json.loads(body)
expected = sign(json.dumps(data).encode(), int(timestamp))
return signature == expected
raw = b'{"event":"payment.succeeded","id":"evt_1001"}'
sig = sign(raw, 1768449600)
print(sig)
print("buggy verifier accepts a valid webhook:", verify_buggy(raw, "1768449600", sig))sha256=84f25107ca3bcdbc7ce6aea379019edcb0454083e86fd619b3c96a34b30d563f
buggy verifier accepts a valid webhook: Falseইঙ্গিত: json.dumps(json.loads(raw)) প্রিন্ট করে raw-এর সঙ্গে বাইটে বাইটে মেলান।
সমাধান: বাগি ভার্সন JSON আবার সিরিয়ালাইজ করে, যা : আর ,-এর পরে স্পেস যোগ করে, ফলে বাইট আলাদা হয়ে যায়, HMAC-ও আলাদা হয়। সবসময় কাঁচা বাইট ঠিক যেভাবে এসেছে সেভাবেই সাইন ও ভেরিফাই করুন। ভেরিফায়ারটা == দিয়েও তুলনা করে, যা টাইমিং তথ্য ফাঁস করতে পারে, আর টাইমস্ট্যাম্প কখনো যাচাই করে না, ফলে আক্রমণকারী একটা পুরোনো ওয়েবহুক চিরকাল রিপ্লে করতে পারে।
import time
def verify(body: bytes, timestamp: str, signature: str, *, now: float | None = None,
tolerance: int = 300, secret: bytes = SECRET) -> bool:
try:
ts = int(timestamp)
except ValueError:
return False
now = time.time() if now is None else now
if abs(now - ts) > tolerance:
return False
expected = sign(body, ts, secret)
return hmac.compare_digest(expected, signature)
now = 1768449700 # 100 seconds after the webhook was signed
print("valid: ", verify(raw, "1768449600", sig, now=now))
print("tampered body: ", verify(raw.replace(b"1001", b"1002"), "1768449600", sig, now=now))
print("wrong secret: ", verify(raw, "1768449600", sign(raw, 1768449600, b"other"), now=now))
print("too old: ", verify(raw, "1768449600", sig, now=now + 3600))
print("bad timestamp: ", verify(raw, "yesterday", sig, now=now))valid: True
tampered body: False
wrong secret: False
too old: False
bad timestamp: Falseফলো-আপ: "সাইন করা মেসেজে টাইমস্ট্যাম্প কেন রাখেন?" যাতে আক্রমণকারী পুরোনো সিগনেচারে নতুন টাইমস্ট্যাম্প জুড়তে না পারে: সেটা বদলালে HMAC ভেঙে যায়। সময়ের উইন্ডো পুরোনো রিপ্লে ঠেকায়; ইভেন্ট ID দিয়ে ডিডুপ্লিকেট করা (চ্যালেঞ্জ S7) উইন্ডোর ভেতরের রিপ্লে ঠেকায়।
H9. ভাঙা SSE স্ট্রিম ডিবাগ করা (কঠিন)
কাজ: ব্রাউজারের EventSource এই স্ট্রিমিং এন্ডপয়েন্ট থেকে কিছুই দেখাচ্ছে না, আর Server-Sent Events পার্স করা একটা Python ক্লায়েন্ট শূন্য ইভেন্ট পাচ্ছে। কেন তা খুঁজে বের করুন এবং ঠিক করুন।
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
def parse_sse(text: str) -> list[str]:
"""A minimal SSE parser: an event is one or more data: lines ended by a blank line."""
events, data = [], []
for line in text.splitlines():
if line.startswith("data:"):
data.append(line[5:].strip())
elif line == "" and data:
events.append("\n".join(data))
data = []
return events
broken = FastAPI()
@broken.get("/stream")
async def stream_broken():
async def words():
for word in ["Hello", "from", "BitByte"]:
yield f"data: {word}\n"
return StreamingResponse(words(), media_type="text/plain")
r = TestClient(broken).get("/stream")
print(r.headers["content-type"])
print(repr(r.text))
print("events:", parse_sse(r.text))text/plain; charset=utf-8
'data: Hello\ndata: from\ndata: BitByte\n'
events: []ইঙ্গিত: বাইটগুলো SSE ফরম্যাটের সঙ্গে মেলান: একটা ইভেন্ট কী দিয়ে শেষ হয়, আর EventSource কোন কন্টেন্ট টাইপ চায়?
সমাধান: দুটো বাগ। প্রতিটা ইভেন্টের শেষে থাকতে হবে একটা ফাঁকা লাইন (\n\n), কিন্তু এখানে প্রতিটা লাইন শেষ হয় একটা \n দিয়ে, ফলে কোনো সম্পূর্ণ ইভেন্ট তৈরিই হয় না। আর কন্টেন্ট টাইপ হতে হবে text/event-stream; ব্রাউজার অন্য কিছু মানে না। FastAPI-র বিল্ট-ইন SSE সাপোর্ট দুটোই ঠিক করে, ডেটা JSON হিসেবে এনকোড করে এবং এমন হেডার যোগ করে যা প্রক্সিকে বাফার করা থেকে ঠেকায়:
from collections.abc import AsyncIterable
from fastapi.sse import EventSourceResponse, ServerSentEvent
fixed = FastAPI()
@fixed.get("/stream", response_class=EventSourceResponse)
async def stream_fixed() -> AsyncIterable[dict | ServerSentEvent]:
for word in ["Hello", "from", "BitByte"]:
yield {"delta": word}
yield ServerSentEvent(data={"done": True}, event="done")
r = TestClient(fixed).get("/stream")
print(r.headers["content-type"], "|", r.headers["cache-control"], "|", r.headers["x-accel-buffering"])
print("events:", parse_sse(r.text))text/event-stream; charset=utf-8 | no-cache | no
events: ['{"delta":"Hello"}', '{"delta":"from"}', '{"delta":"BitByte"}', '{"done": true}']ক্লায়েন্ট দিকটাও গুরুত্বপূর্ণ। পুরো রেসপন্সের জন্য অপেক্ষা না করে স্ট্রিম করুন, আর লাইন ধরে ধরে পার্স করুন। এখানে Practice API-র নকল LLM-এর বিপরীতে:
import json
import httpx
with httpx.Client(base_url="http://localhost:8800", timeout=httpx.Timeout(5.0, read=30.0)) as client:
token = client.post("/v1/token", json={"username": "nadia", "password": "s3cret-pass"}).json()["access_token"]
request = {"messages": [{"role": "user", "content": "What is an idempotency key?"}], "stream": True}
parts = []
with client.stream("POST", "/v1/chat", json=request, headers={"Authorization": f"Bearer {token}"}) as r:
r.raise_for_status()
for line in r.iter_lines():
if not line.startswith("data:"):
continue
event = json.loads(line[5:])
if event.get("done"):
usage = event["usage"]
break
parts.append(event["delta"])
print("".join(parts).strip())
print(usage)This is a practice answer about: What is an idempotency key. A real model would say more.
{'prompt_tokens': 5, 'completion_tokens': 17, 'total_tokens': 22}ফলো-আপ: "লোকালে কাজ করে কিন্তু প্রোডাকশনে সব একসঙ্গে আসে।" একটা প্রক্সি বাফার করছে: X-Accel-Buffering: no পাঠান (FastAPI পাঠায়), প্রক্সিতে ওই রুটের জন্য বাফারিং বা কমপ্রেশন বন্ধ করুন, আর নিশ্চিত করুন কিছুই (যেমন পুরো বডি পড়ে ফেলা কোনো মিডলওয়্যার) স্ট্রিমটা জমা করছে না। ক্লায়েন্টকে একটা লম্বা রিড টাইমআউটও দিন, এবং শেষ ইভেন্ট ছাড়াই শেষ হয়ে যাওয়া স্ট্রিম সামলান।
পার্ট ৩: FastAPI ও Python চ্যালেঞ্জ
F1. নেস্টেড JSON ভ্যালিডেট করা (সহজ)
কাজ: একটা চ্যাট এন্ডপয়েন্ট মেসেজের একটা তালিকা নেয়। অজানা রোল, খালি কন্টেন্ট, ৫০টার বেশি মেসেজ, ০–২-এর বাইরের টেম্পারেচার আর অপ্রত্যাশিত ফিল্ড প্রত্যাখ্যান করুন, এমন এরর দিয়ে যা ঠিক ফিল্ডটাকে নির্দেশ করে।
ইঙ্গিত: এক মডেলের ভেতরে আরেকটা নেস্ট করুন, নির্দিষ্ট পছন্দের জন্য Literal, সীমার জন্য Field, আর অজানা ফিল্ডের জন্য extra="forbid" ব্যবহার করুন।
সমাধান:
from typing import Annotated, Literal
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field
class Message(BaseModel):
role: Literal["system", "user", "assistant"]
content: Annotated[str, Field(min_length=1, max_length=4000)]
class ChatRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
model: str = "practice-llm-1"
messages: Annotated[list[Message], Field(min_length=1, max_length=50)]
temperature: Annotated[float, Field(ge=0, le=2)] = 0.7
app = FastAPI()
@app.post("/v1/chat")
def chat(request: ChatRequest) -> dict:
return {"messages": len(request.messages), "temperature": request.temperature}
client = TestClient(app)
good = {"messages": [{"role": "system", "content": "Be brief."}, {"role": "user", "content": "Hi"}]}
bad = {"messages": [{"role": "user", "content": ""}, {"role": "robot", "content": "Hi"}],
"temperature": 3, "stream_mode": "fast"}
print(client.post("/v1/chat", json=good).json())
r = client.post("/v1/chat", json=bad)
print(r.status_code)
for error in r.json()["detail"]:
print(error["loc"], error["type"]){'messages': 2, 'temperature': 0.7}
422
['body', 'messages', 0, 'content'] string_too_short
['body', 'messages', 1, 'role'] literal_error
['body', 'temperature'] less_than_equal
['body', 'stream_mode'] extra_forbiddenকেন কাজ করে: Pydantic পুরো ট্রি ভ্যালিডেট করে এবং প্রতিটা সমস্যা তার পাথসহ জানায়: ["body", "messages", 1, "role"] মানে "দ্বিতীয় মেসেজের রোল"। ক্লায়েন্ট সেটা ঠিক ফিল্ডের পাশে দেখাতে পারে। ফলো-আপ: "তালিকা আর স্ট্রিংয়ের ওপর সীমা কেন?" প্রতিটা মেসেজে টোকেন আর টাকা খরচ হয়; সীমা হলো বিশাল বা অপব্যবহারমূলক রিকোয়েস্টের বিরুদ্ধে আপনার প্রথম প্রতিরক্ষা (চ্যালেঞ্জ S5)।
F2. ভুল এরর রেসপন্স ঠিক করা (মাঝারি)
কাজ: এই API কোর্স না থাকলে 200 দেয়, বডি ভুল হলে 500 দেয়, আর ডুপ্লিকেট তৈরি হলে 200 দেয়। এমনভাবে ঠিক করুন যাতে প্রতিটা ফলাফলের সঠিক স্ট্যাটাস কোড ও স্পষ্ট মেসেজ থাকে।
from fastapi import FastAPI
from fastapi.testclient import TestClient
bad_app = FastAPI()
COURSES = {1: {"id": 1, "title": "Python for AI"}}
@bad_app.get("/courses/{course_id}")
def get_course(course_id: int):
try:
return COURSES[course_id]
except Exception as e:
return {"error": str(e)}
@bad_app.post("/courses")
def create_course(course: dict):
if any(c["title"] == course["title"] for c in COURSES.values()):
return {"error": "exists"}
new = {"id": max(COURSES) + 1, "title": course["title"]}
COURSES[new["id"]] = new
return new
def report(app):
client = TestClient(app, raise_server_exceptions=False)
for method, path, body in [("GET", "/courses/1", None), ("GET", "/courses/42", None),
("POST", "/courses", {"name": "typo"}),
("POST", "/courses", {"title": "Python for AI"}),
("POST", "/courses", {"title": "LLM APIs"})]:
r = client.request(method, path, json=body)
print(f"{method:<4} {path:<12} {str(body):<28} -> {r.status_code} {r.text}")
report(bad_app)GET /courses/1 None -> 200 {"id":1,"title":"Python for AI"}
GET /courses/42 None -> 200 {"error":"42"}
POST /courses {'name': 'typo'} -> 500 Internal Server Error
POST /courses {'title': 'Python for AI'} -> 200 {"error":"exists"}
POST /courses {'title': 'LLM APIs'} -> 200 {"id":2,"title":"LLM APIs"}ইঙ্গিত: বডি Pydantic-কে ভ্যালিডেট করতে দিন, "পাওয়া যায়নি" আর "কনফ্লিক্ট"-এর জন্য HTTPException রেইজ করুন, আর তৈরির জন্য 201 সেট করুন। এক্সেপশনের টেক্সট কখনো ক্লায়েন্টকে পাঠাবেন না।
সমাধান:
from typing import Annotated
from fastapi import HTTPException
from pydantic import BaseModel, Field
class CourseIn(BaseModel):
title: Annotated[str, Field(min_length=3, max_length=100)]
class Course(CourseIn):
id: int
good_app = FastAPI()
COURSES = {1: {"id": 1, "title": "Python for AI"}}
@good_app.get("/courses/{course_id}")
def get_course_fixed(course_id: int) -> Course:
course = COURSES.get(course_id)
if course is None:
raise HTTPException(404, f"Course {course_id} not found")
return course
@good_app.post("/courses", status_code=201)
def create_course_fixed(course: CourseIn) -> Course:
if any(c["title"].lower() == course.title.lower() for c in COURSES.values()):
raise HTTPException(409, f"A course titled {course.title!r} already exists")
new = {"id": max(COURSES) + 1, "title": course.title}
COURSES[new["id"]] = new
return new
report(good_app)GET /courses/1 None -> 200 {"title":"Python for AI","id":1}
GET /courses/42 None -> 404 {"detail":"Course 42 not found"}
POST /courses {'name': 'typo'} -> 422 {"detail":[{"type":"missing","loc":["body","title"],"msg":"Field required","input":{"name":"typo"}}]}
POST /courses {'title': 'Python for AI'} -> 409 {"detail":"A course titled 'Python for AI' already exists"}
POST /courses {'title': 'LLM APIs'} -> 201 {"title":"LLM APIs","id":2}কেন কাজ করে: ক্লায়েন্ট (বা মনিটরিং ড্যাশবোর্ড) আগে স্ট্যাটাস কোড দেখেই সিদ্ধান্ত নেয় কী করবে। পুরোনো "error"-সহ 200 সফলতার মতো দেখাত; পুরোনো 500 ক্লায়েন্টের ভুলের দোষ সার্ভারকে দিত; str(e) ভেতরের তথ্য ফাঁস করত (এখানে শুধু '42', অন্য জায়গায় হয়তো SQL কোয়েরি বা ফাইলের পাথ)। ফলো-আপ: "প্রতিটা এরর দেখতে একই রকম কীভাবে করবেন?" এক্সেপশন হ্যান্ডলার যোগ করুন যা HTTPException আর ভ্যালিডেশন এররকে একটাই এনভেলপে বদলে দেয়, যেমন {"error": {"code": "not_found", "message": "…", "request_id": "…"}} (FastAPI আর্কিটেকচার: রাউটার, স্কিমা ও এরর হ্যান্ডলিং)।
F3. async রুটে ব্লকিং কল (মাঝারি)
কাজ: লোডের মধ্যে, যে এন্ডপয়েন্ট "শুধু এক সেকেন্ড অপেক্ষা করে" সেটা পুরো সার্ভারকে ধীর করে দেয়। তিনটা কনকারেন্ট রিকোয়েস্ট দিয়ে এটা রিপ্রোডিউস করুন এবং দুভাবে ঠিক করুন।
# file: blocking_app.py
import asyncio
import time
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/bad")
async def bad():
time.sleep(1) # blocks the event loop: no other request can run meanwhile
return {"route": "bad"}
@app.get("/fixed-async")
async def fixed_async():
await asyncio.sleep(1) # gives control back to the event loop while waiting
return {"route": "fixed-async"}
@app.get("/fixed-def")
def fixed_def():
time.sleep(1) # a plain def route runs in FastAPI's threadpool
return {"route": "fixed-def"}cd ~/projects/lab
fastapi run blocking_app.py --port 8000 > server.log 2>&1 &
echo $! > server.pid
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health{"status":"ok"}import asyncio
import time
import httpx
async def three_at_once(path: str) -> float:
async with httpx.AsyncClient(base_url="http://localhost:8000", timeout=10) as client:
start = time.perf_counter()
await asyncio.gather(*(client.get(path) for _ in range(3)))
return time.perf_counter() - start
for path in ["/bad", "/fixed-async", "/fixed-def"]:
print(f"{path:<12} 3 concurrent requests took about {round(asyncio.run(three_at_once(path)))} s")/bad 3 concurrent requests took about 3 s
/fixed-async 3 concurrent requests took about 1 s
/fixed-def 3 concurrent requests took about 1 skill $(cat server.pid)ইঙ্গিত: একটা async def রুট ইভেন্ট লুপের ওপর চলে। time.sleep-এর সময় লুপের কী হয়?
সমাধান: /bad-এ তিনটা রিকোয়েস্ট একটার পর একটা চলেছে (৩ সেকেন্ড), কারণ time.sleep একমাত্র ইভেন্ট লুপকে আটকে দিয়েছে; ঠিক করা রুটগুলো ওভারল্যাপ করেছে (১ সেকেন্ড)। নিয়মটা: async def-এর ভেতরে শুধু নন-ব্লকিং কাজ await করুন (httpx.AsyncClient, async DB ড্রাইভার, asyncio.sleep)। ব্লকিং কোড (requests, sync DB ড্রাইভার, বড় মডেলে model.predict) ডাকতেই হলে রুটটা সাধারণ def দিয়ে ডিক্লেয়ার করুন, অথবা fastapi.concurrency থেকে await run_in_threadpool(fn, …) দিয়ে কলটাকে একটা থ্রেডে পাঠান। ফলো-আপ: "আর CPU-ভারী ইনফারেন্স?" CPU-বাউন্ড Python-এ থ্রেড খুব একটা কাজে আসে না; প্রসেস পুল, আলাদা মডেল সার্ভার বা জব কিউ ব্যবহার করুন (একটা ML মডেল সার্ভ করা)।
F4. ডেটাবেস সেশন লিক (কঠিন)
কাজ: API প্রথম রিকোয়েস্টে কাজ করে, তারপর একটু বিরতির পর প্রতিটা রিকোয়েস্ট ব্যর্থ হয়। পুলটা ইচ্ছা করেই খুব ছোট (১টা কানেকশন, ১ সেকেন্ড টাইমআউট) যাতে বাগটা সঙ্গে সঙ্গে ধরা পড়ে।
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
engine = create_engine("sqlite:///lab.db", pool_size=1, max_overflow=0, pool_timeout=1)
SessionLocal = sessionmaker(engine)
leaked = [] # stands in for wherever a real leak keeps the session alive
def get_db_leaky():
session = SessionLocal()
leaked.append(session)
return session
leaky = FastAPI()
@leaky.get("/ping-db")
def ping_db(db: Annotated[Session, Depends(get_db_leaky)]):
return {"db": db.execute(text("SELECT 1")).scalar_one()}
client = TestClient(leaky, raise_server_exceptions=False)
for i in range(1, 4):
r = client.get("/ping-db")
note = f", connections checked out: {engine.pool.checkedout()}" if i == 1 else ""
print(f"request {i}: {r.status_code}{note}")request 1: 200, connections checked out: 1
request 2: 500
request 3: 500ইঙ্গিত: সেশন কে বন্ধ করে, আর কখন?
সমাধান: কেউ না। প্রতিটা সেশন বন্ধ না হওয়া পর্যন্ত তার কানেকশন পুল থেকে চেক-আউট করে রাখে, তাই দ্বিতীয় রিকোয়েস্ট এমন একটা কানেকশনের জন্য pool_timeout সেকেন্ড অপেক্ষা করে যা আর ফেরত আসে না, এবং SQLAlchemy-র "QueuePool limit … reached" এররে ব্যর্থ হয়। (leaked তালিকাটা বাস্তব লিকে সেশনকে বাঁচিয়ে রাখা জিনিসটার ভূমিকা পালন করছে, যেমন একটা ক্যাশ বা গ্লোবাল। এটা না থাকলে Python-এর গার্বেজ কালেক্টর কোনো অনির্দিষ্ট মুহূর্তে পরিত্যক্ত সেশন বন্ধ করে দিতে পারত; সেটা ভাগ্য, সমাধান নয়।) yield-সহ একটা ডিপেন্ডেন্সি রেসপন্সের পরে সেশন বন্ধ করে, রুট এক্সেপশন দিলেও:
engine = create_engine("sqlite:///lab.db", pool_size=1, max_overflow=0, pool_timeout=1)
SessionLocal = sessionmaker(engine)
def get_db():
with SessionLocal() as session: # closes the session (and returns the connection) at the end
yield session
SessionDep = Annotated[Session, Depends(get_db)]
fixed = FastAPI()
@fixed.get("/ping-db")
def ping_db_fixed(db: SessionDep):
return {"db": db.execute(text("SELECT 1")).scalar_one()}
client = TestClient(fixed, raise_server_exceptions=False)
for i in range(1, 4):
r = client.get("/ping-db")
note = f", connections checked out: {engine.pool.checkedout()}" if i == 1 else ""
print(f"request {i}: {r.status_code}{note}")request 1: 200, connections checked out: 0
request 2: 200
request 3: 200কেন কাজ করে: রিকোয়েস্ট শেষ হলে FastAPI yield-এর পরের কোড চালায়, আর with ব্লক close()-এর নিশ্চয়তা দেয়। প্রোডাকশনে লক্ষণ একই কিন্তু ধীরগতির: লেটেন্সি বাড়ে, তারপর লোডের মধ্যে টাইমআউট দেখা দেয়। পুল মেট্রিক্স (checkedout, অপেক্ষা) নজরে রাখুন এবং ট্রানজ্যাকশন ছোট রাখুন। ফলো-আপ: "আর কীভাবে লিক হতে পারে?" ব্যাকগ্রাউন্ড টাস্ক বা স্ক্রিপ্টে with ছাড়া বানানো সেশন, রিকোয়েস্টগুলোর মধ্যে শেয়ার করা গ্লোবাল সেশন (এটা সঠিকতারও বাগ), আর যেসব দীর্ঘ স্ট্রিম LLM কথা বলার সময় সেশন খুলে রাখে (SQLAlchemy ও Alembic দিয়ে ডেটাবেস)।
F5. টেস্টে বাইরের API মক করা (মাঝারি)
কাজ: এই এন্ডপয়েন্ট একটা আপস্ট্রিম আবহাওয়া API ডাকে। কোনো আসল নেটওয়ার্ক কল ছাড়াই সফলতা, অজানা শহর, আপস্ট্রিম ব্যর্থতা আর আপস্ট্রিম টাইমআউট টেস্ট করুন।
# file: weather_app.py
import os
import httpx
from fastapi import FastAPI, HTTPException
UPSTREAM = os.environ.get("WEATHER_UPSTREAM", "https://weather.example.com")
app = FastAPI()
upstream = httpx.Client(base_url=UPSTREAM, timeout=5)
@app.get("/weather/{city}")
def weather(city: str) -> dict:
try:
r = upstream.get("/v1/weather", params={"city": city})
except httpx.TransportError:
raise HTTPException(502, "Weather service unreachable")
if r.status_code == 404:
raise HTTPException(404, f"Unknown city: {city}")
if r.status_code >= 400:
raise HTTPException(502, "Weather service failed")
data = r.json()
return {"city": data["city"], "summary": f"{data['condition']}, {data['temp_c']} °C"}ইঙ্গিত: respx httpx রিকোয়েস্ট আটকায় এবং আপনাকে রেসপন্স ও এরর স্ক্রিপ্ট করতে দেয়। ডিফল্টে আপনি মক করেননি এমন যেকোনো রিকোয়েস্ট এটা ব্যর্থ করে, যা প্রমাণ করে নেটওয়ার্কে কিছুই লিক হচ্ছে না।
সমাধান:
# file: test_weather_app.py
import httpx
import respx
from fastapi.testclient import TestClient
from weather_app import app
client = TestClient(app)
WEATHER = "https://weather.example.com/v1/weather"
@respx.mock
def test_weather_ok():
route = respx.get(WEATHER, params={"city": "Sylhet"}).mock(return_value=httpx.Response(
200, json={"city": "Sylhet", "temp_c": 27.4, "humidity": 85, "condition": "Rain"}))
r = client.get("/weather/Sylhet")
assert r.status_code == 200
assert r.json() == {"city": "Sylhet", "summary": "Rain, 27.4 °C"}
assert route.call_count == 1
@respx.mock
def test_unknown_city_is_404():
respx.get(WEATHER).mock(return_value=httpx.Response(404, json={"detail": "No weather data"}))
assert client.get("/weather/Narnia").status_code == 404
@respx.mock
def test_upstream_error_is_502():
respx.get(WEATHER).mock(return_value=httpx.Response(503))
r = client.get("/weather/Dhaka")
assert r.status_code == 502
assert r.json() == {"detail": "Weather service failed"}
@respx.mock
def test_upstream_timeout_is_502():
respx.get(WEATHER).mock(side_effect=httpx.ConnectTimeout("timed out"))
assert client.get("/weather/Dhaka").json() == {"detail": "Weather service unreachable"}python -m pytest -q test_weather_app.py.... [100%]
4 passed in 0.54sকেন কাজ করে: টেস্টগুলো দ্রুত, ডিটারমিনিস্টিক ও অফলাইন, আর যেসব ব্যর্থতার পথ আসল সার্ভিসে চাইলেই ঘটানো যায় না সেগুলোও কভার করে। এখানে TestClient নিজে httpx2 ব্যবহার করে, তাই respx (যা httpx প্যাচ করে) শুধু আপনার অ্যাপের বাইরে যাওয়া কলই আটকায়। ফলো-আপ: "মক নাকি ডিপেন্ডেন্সি ওভাররাইড?" HTTP মক করলে আপনার আসল ক্লায়েন্ট কোড টেস্ট হয়; ক্লায়েন্ট ডিপেন্ডেন্সি হলে app.dependency_overrides দিয়ে নকল ক্লায়েন্ট ঢোকানো সহজ। LLM প্রোভাইডারের জন্য একই ইন্টারফেসের পেছনে একটা নকল প্রোভাইডার ক্লাস সাধারণত সবচেয়ে ভালো (পেশাদার API টেস্ট করা)।
F6. অথেন্টিকেটেড এন্ডপয়েন্ট টেস্ট করা (মাঝারি)
কাজ: JWT-সুরক্ষিত একটা এন্ডপয়েন্টের টেস্ট লিখুন: টোকেন নেই, আবোল-তাবোল টোকেন, মেয়াদোত্তীর্ণ টোকেন, ভুল কি দিয়ে সাইন করা টোকেন, মালিক, অন্য ব্যবহারকারী, আর অথ এড়িয়ে যায় এমন একটা ইউনিট টেস্ট।
# file: notes_app.py
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
SECRET = "lab-only-secret-0123456789abcdef0123" # from settings in a real app
ALGORITHM = "HS256"
NOTES = {1: {"id": 1, "owner": "nadia", "text": "Rotate the API key"},
2: {"id": 2, "owner": "rafi", "text": "Rafi's private note"}}
app = FastAPI()
bearer = HTTPBearer(auto_error=False)
def create_token(username: str, minutes: int = 15, secret: str = SECRET) -> str:
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return jwt.encode({"sub": username, "exp": expires}, secret, algorithm=ALGORITHM)
def current_user(creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> str:
if creds is None:
raise HTTPException(401, "Not authenticated", headers={"WWW-Authenticate": "Bearer"})
try:
payload = jwt.decode(creds.credentials, SECRET, algorithms=[ALGORITHM])
except jwt.InvalidTokenError:
raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"})
return payload["sub"]
CurrentUser = Annotated[str, Depends(current_user)]
@app.get("/notes/{note_id}")
def read_note(note_id: int, user: CurrentUser) -> dict:
note = NOTES.get(note_id)
if note is None or note["owner"] != user:
raise HTTPException(404, "Note not found")
return noteইঙ্গিত: টেস্টের ভেতরে অ্যাপের নিজস্ব হেল্পার দিয়ে আসল টোকেন বানান; pytest.mark.parametrize একটা টেস্টেই সব খারাপ-টোকেনের কেস কভার করে।
সমাধান:
# file: test_notes_app.py
import pytest
from fastapi.testclient import TestClient
from notes_app import app, create_token, current_user
client = TestClient(app)
def auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def test_no_token_is_401():
r = client.get("/notes/1")
assert r.status_code == 401
assert r.headers["www-authenticate"] == "Bearer"
@pytest.mark.parametrize("token", [
"not-a-jwt",
create_token("nadia", minutes=-1),
create_token("nadia", secret="attacker-secret-0123456789abcdef0123"),
])
def test_bad_tokens_are_401(token):
assert client.get("/notes/1", headers=auth(token)).status_code == 401
def test_owner_can_read_own_note():
r = client.get("/notes/1", headers=auth(create_token("nadia")))
assert r.status_code == 200
assert r.json()["owner"] == "nadia"
def test_other_users_note_is_404():
assert client.get("/notes/2", headers=auth(create_token("nadia"))).status_code == 404
def test_override_auth_in_unit_tests():
app.dependency_overrides[current_user] = lambda: "rafi"
try:
assert client.get("/notes/2").status_code == 200
finally:
app.dependency_overrides.clear()python -m pytest -q test_notes_app.py....... [100%]
7 passed in 0.50sকেন কাজ করে: টেস্টগুলো বাইরে থেকে অথ লেয়ার কভার করে (আসল ডিপেন্ডেন্সির মধ্য দিয়ে আসল টোকেন) এবং সবচেয়ে গুরুত্বপূর্ণ নেগেটিভ টেস্টটাও রাখে: একজন বৈধ ব্যবহারকারী অন্য কারও ডেটা পড়ছে। ওভাররাইড টেস্ট দেখায় অন্য ইউনিট টেস্ট কীভাবে অথ এড়িয়ে যেতে পারে, আর finally ওভাররাইডটাকে পরের টেস্টে লিক হতে দেয় না। ফলো-আপ: "আর কী যোগ করতেন?" ক্লেইম না থাকার টেস্ট, none অ্যালগরিদম (PyJWT এটা প্রত্যাখ্যান করে কারণ আপনি algorithms=[…] দেন), বাতিল করা টোকেন, আর রোল (অথেন্টিকেশন ও অথরাইজেশন)।
পার্ট ৪: নিরাপত্তা চ্যালেঞ্জ
S1. ফাঁস হওয়া API কি (সহজ)
কাজ: একজন সতীর্থ এমন একটা ওয়েব পেজ শিপ করেছেন যা JavaScript-এ কোম্পানির কি রেখে ব্রাউজার থেকেই সরাসরি LLM প্রোভাইডারকে ডাকে। আপনি ক্রমানুসারে কী করবেন, আর বদলে কি-টা কীভাবে সামলানো উচিত?
ইঙ্গিত: ব্রাউজার বা মোবাইল অ্যাপে যা-ই পাঠানো হয় তা পাবলিক, মিনিফাই করা হোক বা না হোক।
সমাধান: (১) এখনই কি-টা revoke বা rotate করুন এবং অপব্যবহারের জন্য প্রোভাইডারের ইউসেজ ও বিলিং দেখুন; খরচের সীমা বেঁধে দিন। (২) কলটা আপনার ব্যাকএন্ডে সরান: ব্রাউজার ব্যবহারকারীর সেশন নিয়ে আপনার API ডাকে, আপনার API অথ আর প্রতি-ব্যবহারকারী সীমা যাচাই করে, তারপর শুধু সার্ভার জানে এমন একটা কি দিয়ে প্রোভাইডারকে ডাকে। (৩) কি-টা এনভায়রনমেন্ট থেকে লোড করুন এমন একটা সেটিংস অবজেক্টে যা ভুলে প্রিন্ট করবে না:
import os
from pydantic import SecretStr
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
llm_api_key: SecretStr
llm_model: str = "practice-llm-1"
os.environ["LLM_API_KEY"] = "bb-live-rotated-5e6f" # in real life: .env file, platform secrets or a secret manager
settings = Settings()
print(settings)
print(f"key loaded: {len(settings.llm_api_key.get_secret_value())} characters")llm_api_key=SecretStr('**********') llm_model='practice-llm-1'
key loaded: 20 characters(৪) কোড থেকে সরিয়ে দিন, আর কমিট হয়ে গিয়ে থাকলে G6 অনুসরণ করুন। ফলো-আপ: "ফ্রন্টএন্ডের উত্তর স্ট্রিম করা দরকার।" তখন ব্যাকএন্ড প্রোভাইডার থেকে স্ট্রিম নেওয়ার সময়ই SSE দিয়ে ব্রাউজারে স্ট্রিম করে (H9); কি কখনো সার্ভারের বাইরে যায় না।
S2. ভাঙা অ্যাক্সেস চেক: BOLA (মাঝারি)
কাজ: লগ-ইন করা যেকোনো ব্যবহারকারী URL-এর ID বদলে যেকোনো ইনভয়েস পড়তে পারে। এটাই Broken Object Level Authorization, OWASP API Security Top 10 (2023)-এর এক নম্বর। এটা দেখান, ঠিক করুন, প্রমাণ করুন।
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
TOKENS = {"token-nadia": "nadia", "token-rafi": "rafi"}
INVOICES = {101: {"id": 101, "owner": "nadia", "amount_bdt": 1200},
102: {"id": 102, "owner": "rafi", "amount_bdt": 54000}}
def current_user(authorization: Annotated[str, Header()] = "") -> str:
user = TOKENS.get(authorization.removeprefix("Bearer "))
if user is None:
raise HTTPException(401, "Not authenticated", headers={"WWW-Authenticate": "Bearer"})
return user
app = FastAPI()
@app.get("/v1/invoices/{invoice_id}")
def get_invoice_vulnerable(invoice_id: int, user: Annotated[str, Depends(current_user)]):
invoice = INVOICES.get(invoice_id) # checks that you are logged in, never that it is yours
if invoice is None:
raise HTTPException(404, "Invoice not found")
return invoice
@app.get("/v2/invoices/{invoice_id}")
def get_invoice_fixed(invoice_id: int, user: Annotated[str, Depends(current_user)]):
invoice = INVOICES.get(invoice_id)
if invoice is None or invoice["owner"] != user:
raise HTTPException(404, "Invoice not found")
return invoice
client = TestClient(app)
nadia = {"Authorization": "Bearer token-nadia"}
for version in ("v1", "v2"):
own = client.get(f"/{version}/invoices/101", headers=nadia)
other = client.get(f"/{version}/invoices/102", headers=nadia)
print(f"{version}: own invoice {own.status_code}, Rafi's invoice {other.status_code} {other.json()}")v1: own invoice 200, Rafi's invoice 200 {'id': 102, 'owner': 'rafi', 'amount_bdt': 54000}
v2: own invoice 200, Rafi's invoice 404 {'detail': 'Invoice not found'}ইঙ্গিত: অথেন্টিকেশন উত্তর দেয় "আপনি কে?"; প্রতিটা অবজেক্ট অ্যাক্সেসে আরও লাগে "এটা কি আপনার?"
ফিক্স কেন কাজ করে: মালিকানা যাচাই প্রতিটা অবজেক্টে হয়, আর অন্যের অবজেক্ট ঠিক না-থাকা অবজেক্টের মতোই দেখায় (404), তাই ID যাচাই করে দেখা যায় না। ডেটাবেস থাকলে মালিককে কোয়েরির মধ্যেই রাখুন (WHERE id = :id AND owner_id = :user_id), যাতে কোনো রুটে চেকটা ভুলে না যাওয়া যায়; র্যান্ডম ID (UUID) সাহায্য করে কিন্তু সমাধান নয়। ফলো-আপ: "অ্যাডমিনদের কী হবে?" রোলগুলো স্পষ্ট করুন ও টেস্ট করুন: ফাংশন-লেভেল চেক (BFLA) আর অবজেক্ট-লেভেল চেক (BOLA) আলাদা টেস্ট (API নিরাপত্তা ও OWASP API Top 10)।
S3. SQL ইনজেকশন (সহজ)
কাজ: ইমেইল দিয়ে খোঁজার একটা ফাংশন f-string দিয়ে SQL বানায়। আক্রমণটা দেখান এবং ফিক্স করুন।
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, plan TEXT)")
con.executemany("INSERT INTO users (email, plan) VALUES (?, ?)",
[("nadia@example.com", "student"), ("rafi@example.com", "pro"), ("admin@example.com", "admin")])
def find_user_unsafe(email: str):
return con.execute(f"SELECT id, email, plan FROM users WHERE email = '{email}'").fetchall()
def find_user_safe(email: str):
return con.execute("SELECT id, email, plan FROM users WHERE email = ?", (email,)).fetchall()
attack = "x' OR '1'='1"
print("unsafe:", find_user_unsafe(attack))
print("safe: ", find_user_safe(attack))
print("safe: ", find_user_safe("rafi@example.com"))unsafe: [(1, 'nadia@example.com', 'student'), (2, 'rafi@example.com', 'pro'), (3, 'admin@example.com', 'admin')]
safe: []
safe: [(2, 'rafi@example.com', 'pro')]ইঙ্গিত: অনিরাপদ ফাংশনটা যে চূড়ান্ত SQL স্ট্রিং বানায় সেটা লিখে ফেলুন।
সমাধান: অনিরাপদ কোয়েরি হয়ে যায় WHERE email = 'x' OR '1'='1', যা প্রতিটা সারির জন্য সত্য, ফলে আক্রমণকারী সব ব্যবহারকারীকে পায়। প্লেসহোল্ডার ব্যবহার করলে ড্রাইভার মানটা SQL টেক্সট থেকে আলাদা করে পাঠায়, তাই সেটা শুধুই ডেটা। প্যারামিটার কাজ করে মানের জন্য, আইডেন্টিফায়ারের জন্য নয়: ব্যবহারকারীর বেছে নেওয়া সর্ট কলামের জন্য অ্যালাউ-লিস্টের মধ্য দিয়ে ম্যাপ করুন ({"newest": "created_at DESC"})। একই কথা খাটে SQLAlchemy-র প্যারামিটার ডিক্টসহ text("… WHERE email = :email")-এ, এবং SQL লেখা LLM-এর ক্ষেত্রেও: তার কোয়েরি চালান রিড-অনলি রোল, টেবিলের অ্যালাউ-লিস্ট আর সারির সীমাসহ। ফলো-আপ: "ORM কি আপনাকে নিরাপদ করে?" বেশিরভাগ ক্ষেত্রে, যতক্ষণ না কেউ f-string-সহ কাঁচা text() ব্যবহার করে।
S4. অনিরাপদ CORS (মাঝারি)
কাজ: API কুকি সেশন ব্যবহার করে এবং "ফ্রন্টএন্ড চালানোর জন্য" এই CORS কনফিগারেশন রেখেছে। একটা দুষ্ট ওয়েবসাইট কী করতে পারে?
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.testclient import TestClient
def make_app(origins: list[str]) -> FastAPI:
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=origins, allow_credentials=True,
allow_methods=["*"], allow_headers=["*"])
@app.get("/v1/me")
def me():
return {"user": "nadia", "plan": "student"}
return app
def cors_headers(app: FastAPI, origin: str) -> tuple:
r = TestClient(app).get("/v1/me", headers={"Origin": origin, "Cookie": "session=abc"})
return r.headers.get("access-control-allow-origin"), r.headers.get("access-control-allow-credentials")
unsafe = make_app(["*"])
print("unsafe, evil site:", cors_headers(unsafe, "https://evil.example"))unsafe, evil site: ('https://evil.example', 'true')ইঙ্গিত: ব্রাউজার ক্রেডেনশিয়ালসহ Access-Control-Allow-Origin: * নিষিদ্ধ করে। সার্ভার বদলে কী পাঠায় তা দেখুন।
সমাধান: "*" আর ক্রেডেনশিয়াল থাকলে Starlette যে-ই অরিজিন চায় সেটাকেই ফেরত পাঠায়, সঙ্গে allow-credentials: true। ফলে ব্যবহারকারী যেকোনো ওয়েবসাইটে ঢুকলেই সেটা ব্যবহারকারীর কুকি দিয়ে API ডাকতে এবং উত্তর পড়তে পারে। যে অরিজিনগুলোকে বিশ্বাস করেন সেগুলো হুবহু তালিকাভুক্ত করুন:
safe = make_app(["https://app.bitbyte.example", "http://localhost:5173"])
print("safe, evil site: ", cors_headers(safe, "https://evil.example"))
print("safe, our app: ", cors_headers(safe, "https://app.bitbyte.example"))safe, evil site: (None, 'true')
safe, our app: ('https://app.bitbyte.example', 'true')কেন কাজ করে: allow-origin হেডার না থাকলে ব্রাউজার দুষ্ট পেজ থেকে রেসপন্স লুকিয়ে রাখে। মনে রাখুন CORS আসলে কী: কোন পেজ রেসপন্স পড়তে পারবে সে বিষয়ে ব্রাউজারের একটা নিয়ম। এটা অথেন্টিকেশন নয়; curl আর স্ক্রিপ্ট এটা উপেক্ষা করে, তাই প্রতিটা এন্ডপয়েন্টে এখনো আসল অথ লাগবে। কুকি থাকলে স্টেট বদলানো রিকোয়েস্টের জন্য SameSite আর CSRF সুরক্ষাও ব্যবহার করুন। অনুমোদিত অরিজিনগুলো প্রতিটি এনভায়রনমেন্টের জন্য সেটিংস থেকে লোড করুন।
S5. রিকোয়েস্টের সাইজ লিমিট (মাঝারি)
কাজ: কেউ আপনার LLM এন্ডপয়েন্টে ২ MB-র একটা বডি আর ৫০০ মেসেজের একটা চ্যাট পোস্ট করেছে। দুটোই আগেভাগে সঠিক স্ট্যাটাস কোডসহ প্রত্যাখ্যান করুন।
ইঙ্গিত: দুটো স্তর আছে: বডির কাঁচা সাইজ, আর ভেতরের ডেটার আকার।
সমাধান:
from typing import Annotated
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
MAX_BODY_BYTES = 64_000
class Msg(BaseModel):
role: str
content: Annotated[str, Field(max_length=4000)]
class Chat(BaseModel):
messages: Annotated[list[Msg], Field(min_length=1, max_length=50)]
app = FastAPI()
@app.middleware("http")
async def limit_body_size(request: Request, call_next):
length = request.headers.get("content-length")
if length is not None and int(length) > MAX_BODY_BYTES:
return JSONResponse({"detail": f"Body larger than {MAX_BODY_BYTES} bytes"}, status_code=413)
return await call_next(request)
@app.post("/v1/chat")
def chat(body: Chat) -> dict:
return {"messages": len(body.messages)}
client = TestClient(app)
small = {"messages": [{"role": "user", "content": "Hi"}]}
huge = {"messages": [{"role": "user", "content": "x" * 2_000_000}]}
many = {"messages": [{"role": "user", "content": "Hi"}] * 500}
long_text = {"messages": [{"role": "user", "content": "y" * 5000}]}
for name, body in [("small", small), ("huge", huge), ("many", many), ("long_text", long_text)]:
r = client.post("/v1/chat", json=body)
print(f"{name:<10} {r.status_code} {r.json()['detail'][0]['type'] if r.status_code == 422 else r.json()}")small 200 {'messages': 1}
huge 413 {'detail': 'Body larger than 64000 bytes'}
many 422 too_long
long_text 422 string_too_longকেন কাজ করে: মিডলওয়্যার কোনো কিছু পার্স করার আগেই বড় বডি 413 দিয়ে প্রত্যাখ্যান করে; Pydantic মেসেজের সংখ্যা ও দৈর্ঘ্য সীমিত করে (422)। বডির সাইজ অ্যাপের সামনেও জোর করে মানানো সবচেয়ে ভালো (প্রক্সি বা প্ল্যাটফর্মে: যেমন nginx-এর client_max_body_size), কারণ ক্লায়েন্ট Content-Length ছাড়া চাঙ্কড বডি পাঠাতে পারে, যা এই সরল চেক দেখতে পায় না। প্রতি-ব্যবহারকারী রেট লিমিট আর টোকেন বাজেট যোগ করুন: OWASP একে বলে "unrestricted resource consumption", আর LLM-এর বেলায় এতে আসল টাকা খরচ হয়। ফলো-আপ: "ফাইল আপলোড?" সাইজ সীমাসহ স্টোরেজে স্ট্রিম করুন, টাইপ যাচাই করুন কন্টেন্ট দেখে, ফাইলের নাম দেখে নয়।
S6. SSRF: ব্যবহারকারীর দেওয়া URL ফেচ করা (কঠিন)
কাজ: আপনার "এই ওয়েব পেজের সারাংশ করুন" ফিচার ব্যবহারকারীর পাঠানো যেকোনো URL ডাউনলোড করে। আক্রমণকারী পাঠায় http://169.254.169.254/latest/meta-data/ (ক্লাউড মেটাডেটা সার্ভিস, যা ক্রেডেনশিয়াল দিয়ে দিতে পারে) বা http://localhost:8800/… (আপনার অভ্যন্তরীণ সার্ভিস)। প্রতিটা ফেচের আগে চলবে এমন চেকটা লিখুন।
ইঙ্গিত: স্কিম যাচাই করুন, তারপর হোস্ট নামটা রিজলভ করুন এবং সেটা যতগুলো অ্যাড্রেসে রিজলভ হয় প্রতিটা যাচাই করুন; নিরীহ দেখতে একটা নাম 127.0.0.1-কে নির্দেশ করতে পারে।
সমাধান:
import ipaddress
import socket
from urllib.parse import urlsplit
def resolve_all(host: str) -> list[str]:
return [info[4][0] for info in socket.getaddrinfo(host, None)]
def check_url(url: str, resolve=resolve_all) -> str:
parts = urlsplit(url)
if parts.scheme not in ("http", "https"):
return f"blocked: scheme {parts.scheme!r}"
if not parts.hostname:
return "blocked: no host"
try:
addresses = resolve(parts.hostname)
except (socket.gaierror, KeyError):
return "blocked: host does not resolve"
for address in addresses:
ip = ipaddress.ip_address(address)
if not ip.is_global:
return f"blocked: {parts.hostname} -> {ip} is not a public address"
return "allowed"
FAKE_DNS = {"docs.example.org": ["93.184.215.14"], "evil.example": ["127.0.0.1"], "localhost": ["127.0.0.1"]} # stands in for DNS
for url in ["https://docs.example.org/guide", "http://169.254.169.254/latest/meta-data/",
"http://localhost/admin", "http://10.0.0.5/admin", "http://[::1]:6379/",
"file:///etc/passwd", "http://evil.example/page"]:
print(f"{url:<42} {check_url(url, resolve=lambda h: FAKE_DNS.get(h) or resolve_all(h))}")https://docs.example.org/guide allowed
http://169.254.169.254/latest/meta-data/ blocked: 169.254.169.254 -> 169.254.169.254 is not a public address
http://localhost/admin blocked: localhost -> 127.0.0.1 is not a public address
http://10.0.0.5/admin blocked: 10.0.0.5 -> 10.0.0.5 is not a public address
http://[::1]:6379/ blocked: ::1 -> ::1 is not a public address
file:///etc/passwd blocked: scheme 'file'
http://evil.example/page blocked: evil.example -> 127.0.0.1 is not a public addressকেন কাজ করে: ip.is_global লুপব্যাক, প্রাইভেট, লিংক-লোকাল (মেটাডেটা সার্ভিস) এবং অন্য সংরক্ষিত রেঞ্জের জন্য false, IPv4 ও IPv6 দুটোতেই। আরও দুটো নিয়ম এটাকে সম্পূর্ণ করে: রিডাইরেক্ট নিজে থেকে অনুসরণ করবেন না (একটা পাবলিক URL 127.0.0.1-এ রিডাইরেক্ট করতে পারে; প্রতিটা ধাপ আবার যাচাই করুন), আর যে অ্যাড্রেস যাচাই করেছেন তাতেই কানেক্ট করুন, নতুন করে লুকআপ করে নয় (DNS রিবাইন্ডিং: নামটা এক মুহূর্ত পরে অন্যভাবে রিজলভ হতে পারে)। সবচেয়ে শক্ত প্রতিরক্ষা হলো ডোমেইনের অ্যালাউ-লিস্ট, সঙ্গে ফেচারটাকে এমন জায়গায় চালানো যেখান থেকে অভ্যন্তরীণ নেটওয়ার্কে পৌঁছানো যায় না। রেসপন্সের সাইজ ও সময়েরও সীমা দিন। ফলো-আপ: "AI অ্যাপে SSRF কোথায় লুকিয়ে থাকে?" URL থেকে RAG ইনজেশন, "fetch" টুলসহ এজেন্ট, ভিশন মডেলের জন্য ইমেজ URL, আর ব্যবহারকারীর বেছে নেওয়া টার্গেট URL-সহ ওয়েবহুক।
S7. শুরু থেকে শেষ পর্যন্ত ওয়েবহুক নিরাপত্তা (মাঝারি)
কাজ: H8-এর ওয়েবহুকগুলোর জন্য রিসিভিং এন্ডপয়েন্ট বানান: খারাপ সিগনেচার ও পুরোনো টাইমস্ট্যাম্প প্রত্যাখ্যান করুন, আর প্রোভাইডার একই ইভেন্ট দুবার পাঠালেও প্রতিটা ইভেন্ট একবারই প্রসেস করুন।
ইঙ্গিত: পার্স করার আগে await request.body() দিয়ে র বডি পড়ুন; যেসব ইভেন্ট ID প্রসেস করেছেন তা মনে রাখুন।
সমাধান (H8 থেকে sign আর verify আবার ব্যবহার করে):
import json
import time
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.testclient import TestClient
app = FastAPI()
PROCESSED: set[str] = set() # a table with a unique event_id column in production
@app.post("/webhooks/payments")
async def payment_webhook(request: Request,
x_webhook_timestamp: Annotated[str, Header()],
x_webhook_signature: Annotated[str, Header()]) -> dict:
raw = await request.body()
if not verify(raw, x_webhook_timestamp, x_webhook_signature):
raise HTTPException(401, "Invalid signature or timestamp")
event = json.loads(raw)
if event["id"] in PROCESSED:
return {"status": "duplicate ignored"}
PROCESSED.add(event["id"])
# enqueue the real work here and return quickly
return {"status": "accepted"}
def deliver(body: bytes, timestamp: int, secret: bytes = SECRET):
headers = {"X-Webhook-Timestamp": str(timestamp), "X-Webhook-Signature": sign(body, timestamp, secret),
"Content-Type": "application/json"}
r = client.post("/webhooks/payments", content=body, headers=headers)
return r.status_code, r.json()
client = TestClient(app)
event = b'{"event":"payment.succeeded","id":"evt_2001"}'
now = int(time.time())
print("first delivery: ", deliver(event, now))
print("redelivery: ", deliver(event, now))
print("forged: ", deliver(event, now, secret=b"guess"))
print("replayed later: ", deliver(event, now - 3600))first delivery: (200, {'status': 'accepted'})
redelivery: (200, {'status': 'duplicate ignored'})
forged: (401, {'detail': 'Invalid signature or timestamp'})
replayed later: (401, {'detail': 'Invalid signature or timestamp'})কেন কাজ করে: সিগনেচার আর টাইমস্ট্যাম্প প্রমাণ করে কে পাঠিয়েছে আর কখন; প্রসেস করা ID-র সেট ডেলিভারিকে ইডেমপোটেন্ট করে, যা জরুরি কারণ প্রোভাইডার 2xx না পাওয়া পর্যন্ত রিট্রাই করে। দ্রুত উত্তর দিন আর ধীর কাজ একটা কিউতে করুন, নইলে প্রোভাইডারের টাইমআউট হয়ে রিট্রাই করবে। HTTPS ব্যবহার করুন, সিক্রেট সেটিংসে রাখুন, rotation-এর সময় দুটো সিক্রেট সমর্থন করুন, আর ইভেন্ট ID লগ করুন (পেলোড নয়)। ফলো-আপ: "আপনার এন্ডপয়েন্ট এক ঘণ্টা বন্ধ থাকলে?" প্রোভাইডাররা কিছুক্ষণ ব্যাকঅফসহ রিট্রাই করে; যেসব ইভেন্ট মিস হয়েছে তার জন্য প্রোভাইডারের API পোল করে মিলিয়েও নিন।
S8. লগে সংবেদনশীল ডেটা (সহজ)
কাজ: লগে বেয়ারার টোকেন, API কি আর ইমেইল আছে, এবং লগগুলো একটা থার্ড-পার্টি লগ সার্ভিসে পাঠানো হয়। প্রসেস ছেড়ে যাওয়ার আগেই সেগুলো রিড্যাক্ট করুন।
ইঙ্গিত: একটা logging.Filter হ্যান্ডলার লেখার আগে প্রতিটা রেকর্ড দেখতে পায়।
সমাধান:
import logging
import re
import sys
class RedactFilter(logging.Filter):
RULES = [
(re.compile(r"(?i)(bearer\s+)[\w.~+/-]+=*"), r"\1[REDACTED]"),
(re.compile(r"bb-live-\w+"), "[REDACTED_KEY]"),
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "[EMAIL]"),
]
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
for pattern, replacement in self.RULES:
message = pattern.sub(replacement, message)
record.msg, record.args = message, None
return True
logging.basicConfig(format="%(levelname)s %(name)s %(message)s", stream=sys.stdout, force=True)
for handler in logging.getLogger().handlers:
handler.addFilter(RedactFilter())
log = logging.getLogger("lab.s8")
log.setLevel(logging.INFO)
log.info("login ok for %s", "nadia@example.com")
log.info("calling provider with header Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc.def")
log.warning("provider rejected key %s", "bb-live-9f8e7d6c5b4a")
log.info("chat request id=%s tokens=%d", "req-42", 118)INFO lab.s8 login ok for [EMAIL]
INFO lab.s8 calling provider with header Authorization: Bearer [REDACTED]
WARNING lab.s8 provider rejected key [REDACTED_KEY]
INFO lab.s8 chat request id=req-42 tokens=118কেন কাজ করে: ফিল্টার চূড়ান্ত মেসেজ নতুন করে লেখে, তাই আর্গুমেন্ট হিসেবে পাঠানো মানও ধরা পড়ে। কিন্তু রিড্যাকশন নিরাপত্তা জাল, আসল পরিকল্পনা নয়: গোড়াতেই সিক্রেট বা পুরো বডি লগ করবেন না। ID, স্ট্যাটাস কোড, সময় আর টোকেন সংখ্যা লগ করুন; প্রম্পট ও মডেলের আউটপুট (যাতে প্রায়ই ব্যক্তিগত তথ্য থাকে) সাধারণ লগের বাইরে রাখুন, অথবা অ্যাক্সেস কন্ট্রোল ও সংরক্ষণের মেয়াদসহ আলাদা করে রাখুন। ফলো-আপ: "আর কোথায় সিক্রেট ফাঁস হয়?" লোকাল ভেরিয়েবলসহ এক্সেপশন ট্রেসব্যাক, ক্লায়েন্টকে ফেরত দেওয়া এরর মেসেজ, CI লগ (set -x), আর কোয়েরি স্ট্রিংয়ে কি-সহ URL, যা প্রক্সি লগে গিয়ে ঠেকে।
পার্ট ৫: সিস্টেম-ডিজাইন মিনি ইন্টারভিউ
একটা মিনি ইন্টারভিউ ১৫–২০ মিনিটের। ইন্টারভিউয়াররা একটা সঠিক উত্তর আশা করেন না; তাঁরা দেখেন আপনি আলোচনাটা কীভাবে সাজান। প্রতিবার একই পাঁচ ধাপ ব্যবহার করুন: প্রয়োজনীয়তা (ফাংশনাল ও নন-ফাংশনাল: ব্যবহারকারী, ট্রাফিক, লেটেন্সি, খরচ), API, ডেটা, আর্কিটেকচার (এঁকে দেখান), ব্যর্থতার ধরন ও ট্রেড-অফ। নিচের উত্তরগুলো অনুশীলনের জন্য আদর্শ উত্তর।
D1. আবহাওয়া API প্রক্সি (সহজ)
প্রম্পট: "আমাদের অ্যাপ বাংলাদেশি শহরগুলোর আবহাওয়া দেখায়। আপস্ট্রিম প্রোভাইডার দিনে ১,০০০ কল অনুমতি দেয় এবং কখনো কখনো ধীর। আমাদের ব্যাকএন্ড ডিজাইন করুন।"
ইঙ্গিত: আবহাওয়া ধীরে বদলায়; বেশিরভাগ ব্যবহারকারী একই কয়েকটা শহর নিয়ে জিজ্ঞেস করে।
mobile app ──▶ GET /v1/weather/{city} ──▶ FastAPI ──▶ Redis cache (TTL 10 min) ──miss──▶ provider
│ (timeout 3 s,
└── on provider error: serve stale value retry once)
(up to 1 h old) with "stale": true| সিদ্ধান্ত | পছন্দ ও কারণ |
|---|---|
| ক্যাশিং | নরমালাইজ করা শহর ধরে ক্যাশ (weather:dhaka), TTL ১০ মিনিট: ৫টা শহর × ঘণ্টায় ৬ × ২৪ = দিনে ৭২০ কল, ট্রাফিক যা-ই হোক কোটার নিচে |
| স্ট্যাম্পিড | একটা রিকোয়েস্ট কি রিফ্রেশ করে, বাকিরা অপেক্ষা করে বা পুরোনো মান পায় (একটা ছোট Redis লক) |
| ব্যর্থতা | ৩ সেকেন্ড টাইমআউট, জিটারসহ একবার রিট্রাই, তারপর "stale": true চিহ্নিত পুরোনো ডেটা দেওয়া; না থাকলে Retry-After-সহ 503 |
| ভ্যালিডেশন | শহরের অ্যালাউ-লিস্ট (ক্যাশ-কি অপব্যবহারও ঠেকায়); কেস ও বানান নরমালাইজ করা |
| সুরক্ষা | প্রতি-ক্লায়েন্ট রেট লিমিট; প্রোভাইডারের কি সার্ভারেই থাকে |
| অবজার্ভেবিলিটি | ক্যাশ হিট রেট, আপস্ট্রিম লেটেন্সি ও এরর, বাকি কোটা |
ফলো-আপ: "ট্রাফিক ১০০ গুণ বাড়লে?" আপস্ট্রিমে কিছুই বদলায় না (ক্যাশ সবটা শুষে নেয়); API অনুভূমিকভাবে স্কেল করুন, কারণ সব স্টেট Redis-এ।
D2. একটা অথেন্টিকেশন API (মাঝারি)
প্রম্পট: "আমাদের AI স্টাডি অ্যাপের জন্য সাইন-আপ আর লগইন ডিজাইন করুন: ওয়েব ও মোবাইল ক্লায়েন্ট, প্রায় ৫০,০০০ ব্যবহারকারী।"
ইঙ্গিত: পরিচয়ের সমস্যা (আপনি কে) আর সেশনের সমস্যা (লগ-ইন অবস্থায় থাকেন কীভাবে) আলাদা করুন।
| ক্ষেত্র | আদর্শ উত্তর |
|---|---|
| এন্ডপয়েন্ট | POST /auth/register, POST /auth/login, POST /auth/refresh, POST /auth/logout, GET /me; সঙ্গে পাসওয়ার্ড রিসেট ও ইমেইল ভেরিফিকেশন |
| পাসওয়ার্ড | Argon2 হ্যাশ (pwdlib), কখনো প্লেন টেক্সটে সংরক্ষণ বা লগ নয়; ন্যূনতম দৈর্ঘ্য এবং ফাঁস হওয়া পাসওয়ার্ডের বিরুদ্ধে চেক |
| টোকেন | স্বল্পমেয়াদি JWT অ্যাক্সেস টোকেন (প্রায় ১৫ মিনিট), যাতে sub, exp, রোল থাকে; সার্ভার-সাইডে (হ্যাশ করে) রাখা রিফ্রেশ টোকেন, যা প্রতিবার ব্যবহারে বদলে দেওয়া হয়, আর পুনর্ব্যবহার শনাক্ত হলে পুরো পরিবার revoke হয় |
| ক্লায়েন্ট | ব্রাউজার: HttpOnly, Secure, SameSite কুকিতে রিফ্রেশ টোকেন, সঙ্গে CSRF সুরক্ষা; মোবাইল: OS-এর নিরাপদ স্টোরেজ |
| অপব্যবহার | প্রতি অ্যাকাউন্ট ও প্রতি IP-তে লগইনের রেট লিমিট, সাধারণ এরর মেসেজ ("ইমেইল বা পাসওয়ার্ড ভুল"), ঐচ্ছিক MFA, অস্বাভাবিক লগইনে অ্যালার্ট |
| ডেটা | users(id, email unique, password_hash, created_at), refresh_tokens(id, user_id, token_hash, family_id, expires_at, revoked_at) |
| বানাবেন নাকি কিনবেন | "Sign in with Google" বা হোস্টেড আইডেন্টিটি প্রোভাইডার OAuth 2.0/OIDC-র authorization code flow ও PKCE ব্যবহার করে; নিজে বানানোর চেয়ে প্রায়ই এটাই ভালো পছন্দ |
ফলো-আপ: "JWT কীভাবে লগ-আউট করাবেন?" ইস্যু হয়ে যাওয়া টোকেন বদলানো যায় না; অ্যাক্সেস টোকেন ছোট মেয়াদের রাখুন, রিফ্রেশ টোকেন revoke করুন, আর জরুরি অবস্থার জন্য Redis-এ টোকেন ID-র (jti) একটা ছোট ডিনাইলিস্ট রাখুন।
D3. একটা ML প্রেডিকশন এন্ডপয়েন্ট (মাঝারি)
প্রম্পট: "আমরা একটা scikit-learn মডেল ট্রেন করেছি যা ভবিষ্যদ্বাণী করে কোনো শিক্ষার্থী কোর্স ছেড়ে দেবে কি না। এটা আমাদের ওয়েব অ্যাপে সার্ভ করুন।"
client ──▶ POST /v1/predictions ──▶ FastAPI (validate features with Pydantic)
│
├── model loaded once at startup (lifespan), version "dropout-2026-10-01"
├── predict in a threadpool (CPU work off the event loop)
└── log: request id, model version, latency, prediction (no personal data)
│
▼
monitoring: latency, error rate, input drift- API: টাইপ ও রেঞ্জ যাচাই করা ফিচারসহ রিকোয়েস্ট; রেসপন্স
{"prediction": 1, "probability": 0.82, "model_version": "…"}; সর্বোচ্চ N সারির জন্য একটা ব্যাচ এন্ডপয়েন্ট। - মডেলের জীবনচক্র: মডেল ফাইলের ভার্সন রাখা হয় (রেজিস্ট্রি বা অবজেক্ট স্টোরেজে), স্টার্টআপে ওয়ার্ম-আপ প্রেডিকশনসহ লোড হয়; লোড না হওয়া পর্যন্ত রেডিনেস চেক ব্যর্থ হয়; নতুন ভার্সন পুরোনোটার পাশে রোল আউট করে তুলনা করুন।
- পারফরম্যান্স: ছোট মডেল মিলিসেকেন্ডে প্রেডিক্ট করে; আরও প্রসেস বা কন্টেইনার দিয়ে স্কেল করুন; মডেলের দরকার হলে তবেই GPU।
- সঠিকতা: ট্রেনিংয়ের মতো একই প্রিপ্রসেসিং পাইপলাইন (শুধু এস্টিমেটর নয়, পুরো
Pipelineসেভ করুন); ছোট একটা মডেল দিয়ে টেস্ট।
ফলো-আপ: "সময়ের সঙ্গে প্রেডিকশন খারাপ হয়ে গেছে।" ইনপুটের বণ্টন মনিটর করুন, আর লেবেল আসার পর অ্যাকুরেসি; সময়সূচি ধরে বা ড্রিফট দেখে রিট্রেন করুন (একটা ML মডেল সার্ভ করা)।
D4. LLM স্ট্রিমিং চ্যাট API (কঠিন)
প্রম্পট: "ChatGPT-স্টাইল টিউটরের ব্যাকএন্ড ডিজাইন করুন: স্ট্রিম করা উত্তর, কনভারসেশন হিস্ট্রি, একাধিক মডেল প্রোভাইডার।"
browser ──POST /v1/chat (SSE)──▶ FastAPI ──▶ auth + per-user limits (Redis)
│
├──▶ load history (PostgreSQL), build prompt (versioned template)
├──▶ provider adapter ──▶ OpenAI Responses / Anthropic Messages / local model
│ (timeout, retry only before the first token, fallback model)
├──◀ deltas ──▶ re-emitted to the browser as SSE events
└──▶ on finish: save answer, tokens, cost, model, prompt version| বিষয় | আদর্শ উত্তর |
|---|---|
| স্ট্রিমিং | সার্ভার থেকে ব্রাউজারে SSE (text/event-stream): সরল, HTTP-র ওপর চলে, অটো-রিকানেক্ট করে; দুমুখী রিয়েল-টাইম লাগলেই কেবল WebSocket |
| প্রোভাইডার-নিরপেক্ষতা | একটা অ্যাডাপ্টার ইন্টারফেস (complete(), টেক্সট ডেল্টা yield করা stream()); প্রোভাইডার-নির্দিষ্ট ইভেন্টগুলোকে আপনার নিজস্ব delta/done/error ইভেন্টে ম্যাপ করা |
| ব্যর্থতা | প্রথম টোকেন পাঠানোর আগেই শুধু রিট্রাই; স্ট্রিমের মাঝের এরর error ইভেন্ট হয়ে যায়; ক্লায়েন্ট ডিসকানেক্ট করলে খরচ বাঁচাতে আপস্ট্রিম কল বাতিল হয় |
| খরচ ও সীমা | কলের আগে প্রতি-ব্যবহারকারী টোকেন বাজেট যাচাই, কলের পরে প্রকৃত ব্যবহার রেকর্ড (D5); ইনপুটের দৈর্ঘ্য ও max_output_tokens-এ সীমা |
| ডেটা | conversations, messages(role, content, tokens, model, prompt_version); কনটেক্সট উইন্ডোতে আঁটাতে হিস্ট্রি ছেঁটে বা সারাংশ করুন |
| নিরাপত্তা | মডেলের আউটপুটকে অবিশ্বস্ত ধরুন (HTML-এ এস্কেপ করুন, কখনো এক্সিকিউট করবেন না); টুল ও রিট্রিভ করা ডকুমেন্টে প্রম্পট ইনজেকশনের বিরুদ্ধে প্রতিরক্ষা; লগ থেকে সিক্রেট রিড্যাক্ট করুন |
| স্কেল | দীর্ঘস্থায়ী কানেকশন: async ওয়ার্কার, উদার প্রক্সি টাইমআউট, বাফারিং বন্ধ; অনুভূমিক স্কেলিং, কারণ স্টেট PostgreSQL আর Redis-এ |
ফলো-আপ: "উত্তর যে ভালো তা কীভাবে জানবেন?" প্রতিটা উত্তরের সঙ্গে প্রম্পট ও মডেলের ভার্সন লগ করুন, একটা ছোট মূল্যায়ন সেট বানান, আর পাল্টানোর আগে ভার্সনগুলোর তুলনা করুন (স্ট্রিমিংসহ LLM ব্যাকএন্ড বানানো)।
D5. প্রতি-ব্যবহারকারী AI ইউসেজ লিমিট (কঠিন)
প্রম্পট: "ফ্রি ব্যবহারকারীরা দিনে ২০,০০০ টোকেন পায়, পেইড ব্যবহারকারীরা ৫,০০,০০০। ১০টা API সার্ভারজুড়ে এটা কার্যকর করুন।"
ইঙ্গিত: উত্তর শেষ না হওয়া পর্যন্ত আউটপুট টোকেন কত হবে তা আপনি জানেন না।
- কাউন্টার কোথায় থাকে: Redis-এ, সব সার্ভারের শেয়ার করা, কি
usage:{user_id}:{yyyy-mm-dd}, কয়েক দিনের TTL সহ।INCRBYদিয়ে বাড়ানো অ্যাটমিক, তাই দশটা সার্ভার রেস করতে পারে না। - আগে রিজার্ভ, পরে মিটমাট: কলের আগে অনুমান করুন (ইনপুট টোকেন +
max_output_tokens) এবং ওই পরিমাণ অ্যাটমিকভাবে রিজার্ভ করুন (একটা ছোট Lua স্ক্রিপ্ট: বাকি বাজেট দেখুন, ধরলে বাড়ান)। কলের পরে প্রোভাইডারের জানানো আসল ব্যবহার দিয়ে সমন্বয় করুন। রিজার্ভেশন ছাড়া ৫০টা সমান্তরাল রিকোয়েস্ট সবই চেক পেরিয়ে যেতে পারত। - রেসপন্স: স্পষ্ট মেসেজ ও
Retry-After(দিন রিসেট হতে কত সেকেন্ড)-সহ 429, সঙ্গেX-RateLimit-Remaining-এর মতো হেডার; UI-এর জন্য একটাGET /v1/usageএন্ডপয়েন্ট। - দুটো আলাদা সীমা: রিকোয়েস্ট রেট লিমিট (প্রতি মিনিটে রিকোয়েস্ট, টোকেন বাকেট) সার্ভিসকে রক্ষা করে; টোকেন বাজেট খরচ নিয়ন্ত্রণ করে। সঙ্গে পুরো কোম্পানির জন্য একটা গ্লোবাল বাজেট অ্যালার্ম।
- সত্যের উৎস: বিলিং ও অ্যানালিটিক্সের জন্য প্রতিটা কলের ব্যবহার PostgreSQL-এ লিখুন; Redis হলো দ্রুত, আনুমানিক গেট। Redis বন্ধ থাকলে ইচ্ছা করে সিদ্ধান্ত নিন: ফ্রি ব্যবহারকারীদের জন্য fail closed, পেইডদের জন্য নিচু সীমাসহ fail open।
ফলো-আপ: "রোলিং ২৪ ঘণ্টা না করে দিন ধরে কেন?" ব্যাখ্যা করা আর রিসেট করা সহজ; স্লাইডিং উইন্ডো আরও ন্যায্য কিন্তু বেশি স্টোরেজ লাগে। কোনটা বেছেছেন ও কেন, তা বলুন।
D6. ব্যাকগ্রাউন্ড প্রসেসিং (মাঝারি)
প্রম্পট: "ব্যবহারকারীরা ২০০ পাতার একটা PDF আপলোড করে; আমরা টেক্সট বের করি, চাঙ্ক করি আর এমবেডিং বানাই। এতে দুই মিনিট লাগে।"
POST /v1/documents ──▶ save file to object storage ──▶ insert job (status=queued) ──▶ 202 Accepted
Location: /v1/jobs/42
queue (Redis/RabbitMQ/SQS) ──▶ worker(s)
│ extract → chunk → embed
GET /v1/jobs/42 ◀── {"status": "running", "progress": 0.6} ◀── job table ◀──────────┘ updates status
(or a webhook / SSE notification when done)- কেন
BackgroundTasksনয়? এটা ওয়েব প্রসেসের ভেতরে চলে: রিস্টার্ট বা ডিপ্লয়ে হারিয়ে যায়, রিট্রাই নেই, দৃশ্যমানতা নেই, আর CPU নিয়ে রিকোয়েস্টের সঙ্গে প্রতিযোগিতা করে। ধীর বা গুরুত্বপূর্ণ যেকোনো কাজের জন্য আসল কিউ আর ওয়ার্কার ব্যবহার করুন (Celery, RQ, Dramatiq, arq, বা ক্লাউড কিউ)। - নির্ভরযোগ্যতা: জব ইডেমপোটেন্ট (একটা ধাপ আবার চালালে চাঙ্ক ডুপ্লিকেট হয় না: ডকুমেন্ট ID ও চাঙ্ক নম্বর ধরে upsert), ব্যাকঅফসহ রিট্রাই, N বার ব্যর্থ হলে ডেড-লেটার কিউতে সরানো; প্রতি-জব টাইমআউট।
- সীমা: ফাইল সাইজ ও পাতার সীমা, প্রতি-ব্যবহারকারী কনকারেন্ট জবের সীমা, আর এমবেডিং প্রোভাইডারের রেট লিমিট অনুযায়ী ওয়ার্কারের কনকারেন্সি।
ফলো-আপ: "কাজ শেষ হয়েছে তা ব্যবহারকারী কীভাবে জানবে?" স্ট্যাটাস URL পোল করা সবচেয়ে সরল; ভালো অভিজ্ঞতার জন্য SSE বা ওয়েবহুক যোগ করুন।
D7. নির্ভরযোগ্য থার্ড-পার্টি ইন্টিগ্রেশন (কঠিন)
প্রম্পট: "কোনো শিক্ষার্থী পেমেন্ট করলে আমাদের তাকে কোর্সে এনরোল করতে এবং রসিদের ইমেইল পাঠাতে হবে। পেমেন্ট প্রোভাইডার ওয়েবহুক পাঠায়; ইমেইল সার্ভিস কখনো কখনো বন্ধ থাকে।"
- ইনবাউন্ড: ওয়েবহুক সিগনেচার যাচাই করুন, ইভেন্ট ID ধরে ডিডুপ্লিকেট করুন, দ্রুত 2xx দিন (S7); প্রোভাইডারের রিট্রাই আর আপনার ডিডুপ্লিকেশন মিলে দেয় exactly-once প্রভাব।
- অ্যাটমিক স্টেট পরিবর্তন: একটা ডেটাবেস ট্রানজ্যাকশনে পেমেন্টকে পরিশোধিত চিহ্নিত করুন, এনরোলমেন্ট তৈরি করুন, আর একটা আউটবক্স টেবিলে "পাঠানোর ইমেইল" সারি ঢোকান। হয় সবকটা ঘটে, নয়তো একটাও নয়।
- আউটবাউন্ড: একটা ওয়ার্কার আউটবক্স পড়ে, ইডেমপোটেন্সি কি দিয়ে ইমেইল সার্ভিস ডাকে, ব্যাকঅফসহ রিট্রাই করে, সারিটা পাঠানো হয়েছে বলে চিহ্নিত করে; একটা সার্কিট ব্রেকার বন্ধ থাকা সার্ভিসকে বারবার আঘাত করা ঠেকায়।
- নিরাপত্তা জাল: রাতের একটা রিকনসিলিয়েশন জব প্রোভাইডারের পেমেন্টগুলোকে আপনার এনরোলমেন্টের সঙ্গে মেলায় এবং অমিল পেলে অ্যালার্ট দেয়, কারণ ওয়েবহুক মিস হতে পারে।
- অবজার্ভেবিলিটি: ওয়েবহুক থেকে ইমেইল পর্যন্ত প্রতিটা লগ লাইনে একটা কোরিলেশন ID; আউটবক্সের জমে থাকা সারির বয়সে অ্যালার্ট।
ফলো-আপ: "ওয়েবহুক হ্যান্ডলারের ভেতরেই ইমেইল সার্ভিস কেন ডাকবেন না?" সেটা ধীর বা বন্ধ থাকলে হ্যান্ডলার টাইমআউট হয়, প্রোভাইডার রিট্রাই করে, আর ডুপ্লিকেট এনরোলমেন্ট বা হারানো ইমেইলের ঝুঁকি তৈরি হয়। আউটবক্স দুটোকে আলাদা করে দেয়।
D8. একটা CI/CD পাইপলাইন (মাঝারি)
প্রম্পট: "GitHub-এ আমাদের FastAPI + PostgreSQL সার্ভিসের জন্য CI/CD ডিজাইন করুন।"
pull request ──▶ CI: lint (ruff) → tests with PostgreSQL service → dependency + secret scans → docker build
│ required checks must pass, one review, then merge to main
main ──▶ build image once, tag with the commit SHA ──▶ push to GHCR
──▶ deploy to staging ──▶ run migrations ──▶ smoke tests
──▶ manual approval (environment protection) ──▶ deploy the SAME image to production ──▶ health check
└── rollback = redeploy the previous image tag- নিরাপত্তা: সর্বোচ্চ স্তরে
permissions: contents: read, বাড়তি অনুমতি শুধু জব ধরে; সিক্রেট GitHub environment-এ; থার্ড-পার্টি অ্যাকশন পূর্ণ কমিট SHA-তে পিন করা; সিক্রেটসহ কখনো অবিশ্বস্ত ফর্ক কোড চালাবেন না (pull_request_target-এর বিপদ)। - গতি: uv ডাউনলোড ক্যাশ করুন, জব সমান্তরালে চালান, লিন্টে দ্রুত ব্যর্থ হোক।
- মাইগ্রেশন: ব্যাকওয়ার্ড-কম্প্যাটিবল (expand, deploy, তারপর contract), যাতে রোলআউটের সময় পুরোনো ভার্সন কাজ করে আর রোলব্যাক নিরাপদ থাকে।
- রিলিজ: ভার্সনের জন্য ট্যাগ ও রিলিজ নোট; Dependabot অ্যাকশন ও প্যাকেজ হালনাগাদ রাখে।
ফলো-আপ: "একটা ডিপ্লয় প্রোডাকশন ভেঙে দিয়েছে।" আগে রোলব্যাক করুন (আগের ট্যাগ আবার ডিপ্লয়), কারণ খোঁজা পরে, তারপর যে টেস্ট ছিল না সেটা যোগ করুন (GitHub Actions দিয়ে CI/CD, ডিপ্লয়মেন্ট ও প্রোডাকশনের প্রস্তুতি)।
নিজেকে স্কোর করুন
| পার্ট | চ্যালেঞ্জ | পয়েন্ট |
|---|---|---|
| Git | G1 ১, G2 ১, G3 ২, G4 ২, G5 ২, G6 ৩, G7 ২ | ১৩ |
| HTTP ও API | H1 ১, H2 ১, H3 ১, H4 ২, H5 ২, H6 ২, H7 ৩, H8 ২, H9 ৩ | ১৭ |
| FastAPI ও Python | F1 ১, F2 ২, F3 ২, F4 ৩, F5 ২, F6 ২ | ১২ |
| নিরাপত্তা | S1 ১, S2 ২, S3 ১, S4 ২, S5 ২, S6 ৩, S7 ২, S8 ১ | ১৪ |
| সিস্টেম ডিজাইন | D1 ১, D2 ২, D3 ২, D4 ৩, D5 ৩, D6 ২, D7 ৩, D8 ২ | ১৮ |
| মোট | ৭৪ |
৫৫ বা তার বেশি: জুনিয়র থেকে মিড-লেভেল ব্যাকএন্ড ও AI-ইঞ্জিনিয়ারিং ইন্টারভিউয়ের জন্য প্রস্তুত; ক্যাপস্টোনটা করুন। ৩৫–৫৪: ভালো; যে চ্যালেঞ্জগুলো মিস করেছেন তার পেছনের পাতাগুলো আবার দেখুন এবং এক সপ্তাহ পরে আবার করুন। ৩৫-এর নিচে: পার্ট ১–৩ ধীরে ধীরে আবার করুন, প্রতিটা কমান্ড নিজে টাইপ করে।
সাধারণ ভুল
- সরাসরি ফিক্সে ঝাঁপ দেওয়া। ইন্টারভিউতে এবং ইনসিডেন্টে, রিপ্রোডিউস না করা বাগ একটা অনুমান। সমাধান: আগে রিপ্রোডিউস করুন (একটা কমান্ড বা ব্যর্থ টেস্ট), তারপর একটা জিনিস বদলান।
- প্রমাণ নষ্ট করা। দেখার আগেই
git reset --hard, লগ মুছে ফেলা বা রিস্টার্ট করা। সমাধান: যা দরকার কপি করুন,git stashবা একটা রেসকিউ ব্রাঞ্চ ব্যবহার করুন, আর reflog পড়ুন। - সবকিছু রিট্রাই করা। 400 রিট্রাই করা, কিংবা ইডেমপোটেন্সি কি ছাড়া পেমেন্ট POST রিট্রাই করা। সমাধান: শুধু টাইমআউট, 429 আর 5xx রিট্রাই করুন, ব্যাকঅফ, জিটার ও সীমাসহ, আর শুধু ইডেমপোটেন্ট রিকোয়েস্ট।
- নিরাপত্তাকে শেষ মুহূর্তের ভাবনা বানানো। "অথ চেক পরে যোগ করব" এভাবেই BOLA আর ফাঁস হওয়া কি ঘটে। সমাধান: প্রতিটা এন্ডপয়েন্টে একটা মালিকানা টেস্ট থাকবে, আর সিক্রেট কখনো কোডে ছোঁবে না।
- সংখ্যা বা ট্রেড-অফ ছাড়া ডিজাইনের উত্তর। "Redis আর Kubernetes ব্যবহার করুন" কিছুই বলে না। সমাধান: ট্রাফিক ও খরচের হিসাব করুন, কী বেছেছেন, কী ছেড়েছেন, আর ১০ গুণ স্কেলে কী বদলাতেন তা বলুন।
নিজে চেষ্টা করুন
- সহজ: G3-এর
conflictরিপোজিটরিতে একটা Git কমান্ড দিয়ে খুঁজে বের করুন কোন কমিটTIMEOUT = 30টেক্সটটা এনেছিল। - মাঝারি:
parse_retry_after(value, now)লিখুন যাRetry-After-এর দুটো রূপই নেয় (সেকেন্ড, বাThu, 15 Jan 2026 04:00:30 GMT-এর মতো HTTP তারিখ), অপেক্ষার সেকেন্ড ফেরত দেয় যা কখনো ঋণাত্মক নয় এবং সর্বোচ্চ ৬০, আর আবোল-তাবোল মানের জন্য ১ ফেরত দেয়। - কঠিন: FastAPI ডিপেন্ডেন্সি হিসেবে একটা ফিক্সড-উইন্ডো রেট লিমিটার লিখুন: প্রতি
X-Client-Id-এ ১০ সেকেন্ডে ৩টা রিকোয়েস্ট,Retry-After-সহ 429 দিয়ে উত্তর। ঘড়িটা ইনজেক্ট করুন যাতে টেস্ট স্লিপ ছাড়াই সময় এগিয়ে নিতে পারে, এবংTestClientদিয়ে প্রমাণ করুন।
উত্তর
cd ~/projects/lab/git/conflict
git log --oneline -S "TIMEOUT = 30"1cff844 Raise timeout to 30 s for slow LLM callsfrom datetime import datetime, timezone
from email.utils import parsedate_to_datetime
def parse_retry_after(value: str | None, now: datetime, cap: int = 60) -> int:
if not value:
return 1
value = value.strip()
if value.isdigit():
seconds = int(value)
else:
try:
seconds = int((parsedate_to_datetime(value) - now).total_seconds())
except (TypeError, ValueError):
return 1
return max(0, min(seconds, cap))
now = datetime(2026, 1, 15, 4, 0, 0, tzinfo=timezone.utc)
for value in ["7", "120", "Thu, 15 Jan 2026 04:00:30 GMT", "Thu, 15 Jan 2026 03:59:00 GMT", "soon", None]:
print(repr(value), "->", parse_retry_after(value, now))'7' -> 7
'120' -> 60
'Thu, 15 Jan 2026 04:00:30 GMT' -> 30
'Thu, 15 Jan 2026 03:59:00 GMT' -> 0
'soon' -> 1
None -> 1from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
class FixedWindowLimiter:
def __init__(self, limit: int, window: int, clock):
self.limit, self.window, self.clock = limit, window, clock
self.counts: dict[tuple[str, int], int] = {}
def hit(self, client_id: str) -> int | None:
"""Count one request. Returns None if allowed, else the seconds until the window resets."""
now = self.clock()
window_start = int(now // self.window) * self.window
key = (client_id, window_start)
self.counts[key] = self.counts.get(key, 0) + 1
if self.counts[key] > self.limit:
return max(1, int(window_start + self.window - now))
return None
fake_now = [1000.0]
limiter = FixedWindowLimiter(limit=3, window=10, clock=lambda: fake_now[0])
def rate_limit(x_client_id: Annotated[str, Header()] = "anonymous") -> None:
retry_after = limiter.hit(x_client_id)
if retry_after is not None:
raise HTTPException(429, "Too many requests", headers={"Retry-After": str(retry_after)})
app = FastAPI()
@app.get("/v1/answer", dependencies=[Depends(rate_limit)])
def answer() -> dict:
return {"ok": True}
client = TestClient(app)
codes = [client.get("/v1/answer", headers={"X-Client-Id": "a"}).status_code for _ in range(4)]
blocked = client.get("/v1/answer", headers={"X-Client-Id": "a"})
other = client.get("/v1/answer", headers={"X-Client-Id": "b"}).status_code
fake_now[0] += 10
after = client.get("/v1/answer", headers={"X-Client-Id": "a"}).status_code
print(codes, blocked.status_code, blocked.headers["retry-after"], other, after)[200, 200, 200, 429] 429 10 200 200শেষ উত্তরে লক্ষ করুন, ব্লক হওয়া রিকোয়েস্টের কাউন্টারও বাড়তে থাকে, আর ফিক্সড উইন্ডো একটা উইন্ডোর সীমানার দুই পাশে মিলিয়ে ৬টা পর্যন্ত রিকোয়েস্টের বার্স্ট অনুমতি দেয়; স্লাইডিং উইন্ডো বা টোকেন বাকেট সেটা মসৃণ করে, আর Redis সেটাকে সার্ভারজুড়ে কাজ করায় (Redis দিয়ে ক্যাশিং, রেট লিমিটিং ও নির্ভরযোগ্যতা)।
সারসংক্ষেপ
- প্রতিটা সমস্যা একইভাবে সামলান: স্পষ্ট করুন, রিপ্রোডিউস করুন, সবচেয়ে ছোট জিনিসটা ঠিক করুন, প্রমাণ করুন, ট্রেড-অফ ব্যাখ্যা করুন।
- Git উদ্ধার: ফাইলের জন্য
restore, কমিট নতুন করে করতেreset --soft, শেয়ার্ড হিস্ট্রির জন্যrevert, "হারানো" কমিটের জন্য reflog, আর ফাঁস হওয়া সিক্রেটের জন্য আগে rotate। - নির্ভরযোগ্য API ক্লায়েন্ট নিরাপদে পেজ করে,
Retry-Afterমানে, টাইমআউট সেট করে, শুধু যা নিরাপদ তা ব্যাকঅফ ও জিটারসহ রিট্রাই করে, আর রাইটের জন্য ইডেমপোটেন্সি কি ব্যবহার করে। - প্রোডাকশনে গুরুত্বপূর্ণ FastAPI বাগ:
async def-এ ব্লকিং কল, কখনো বন্ধ না হওয়া সেশন, ভুল স্ট্যাটাস কোড, আর ব্যর্থতার পথ ও অন্য ব্যবহারকারীদের ডেটার টেস্ট না থাকা। - যে নিরাপত্তা চেক না ভেবেই করা উচিত: প্রতিটা অবজেক্টে মালিকানা, SQL-এ প্যারামিটার, হুবহু CORS অরিজিন, সাইজ লিমিট, SSRF চেক, সাইন করা ও ডিডুপ্লিকেট করা ওয়েবহুক, পরিষ্কার লগ।
- সিস্টেম-ডিজাইনের উত্তর চলে প্রয়োজনীয়তা → API → ডেটা → আর্কিটেকচার → ব্যর্থতার ধরন ক্রমে, সংখ্যা ও ট্রেড-অফসহ।
এরপর: ক্যাপস্টোন প্রজেক্ট, চিট শিট ও এরপর কী পাতা এই সবকিছু একসঙ্গে জুড়ে আপনার পোর্টফোলিওর জন্য একটা প্রোডাকশন-রেডি AI ব্যাকএন্ড বানায়, সঙ্গে পুরো টিউটোরিয়ালের একটা চিট শিট।