Skip to main content

status-code-is-not-a-reason-code

An endpoint that refused for exactly one reason gets a client that keys its message off the HTTP status. Add a second refusal reason and that mapping shows the WRONG actionable hint — plausible, silent, and reported by nobody. Before adding a refusal, open the existing client mapping; ship the reason as a code in the body, and take any number in the copy from the response.

الانتقال إلى التثبيت

معلومات المصدر

المستودع
blas1n/claude-skills
آخر نشاط في المصدر
٣ سبتمبر ٢٠٢٦ في ٠٨:٠٩
لغة SKILL.md المكتشفة
الإنجليزية
النجوم
٢
التفرعات
٠

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
name
status-code-is-not-a-reason-code
description
An endpoint that refused for exactly one reason gets a client that keys its message off the HTTP status. Add a second refusal reason and that mapping shows the WRONG actionable hint — plausible, silent, and reported by nobody. Before adding a refusal, open the existing client mapping; ship the reason as a code in the body, and take any number in the copy from the response.
version
1.0.0
category
trap
# The status code stops being an identifier at the second refusal reason ## When to use You are about to make an existing endpoint refuse for a **new** reason — a quota, a plan limit, a feature flag, a state precondition — on a surface that already refuses for something else. Trigger words: "also reject when…", "add a 400 for…", "cap this", "gate this behind the plan", "return 409 if…". ## Problem While an endpoint had exactly one refusal cause, keying on the status was correct, and it reads as correct forever: ```tsx // 400 here can only mean "this workspace has no products" setError(err.status === 400 ? t("errorNoProduct") : t("errorSend")); ``` Add a second cause with the same status and the branch answers confidently and **wrongly**: a founder with a dozen products is told to create one. What makes this worse than an ordinary bug: * **No error, no red, no log.** The user sees a fluent, localized, plausible sentence. It is simply about the wrong thing. * **The new code looks fine.** The defect is in a file the PR never opens — a client written months earlier under an assumption the PR just invalidated. * **Tests stay green.** The old mapping's test still passes (its cause still maps correctly); the new refusal's test asserts the status, which is right. Nothing asserts *which sentence a user sees for the new cause*. ## Recognizing it Grep the client for branches on a bare status before adding the refusal: ```bash rg "status === (400|403|409|422)" --type ts rg "\.status_code == (400|403|409)" # server-side callers too ``` Any hit whose comment or copy names **one specific cause** is a landmine the moment a second cause shares that status. ## The fix 1. **Open the existing mapping in the same PR.** This is the whole discipline. The grep above takes seconds; skipping it is how the defect ships. 2. **Send a reason code, not just a status.** FastAPI's `detail` accepts an object: ```python raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail={"code": "run_cap_reached", "limit": limit, "held": held}, ) ``` The client branches on `err.reason?.code`, never on the number alone. 3. **Pick a status that is not already spoken for**, as a second line of defence — 429 for a quota, 402 for a plan wall. But do not *rely* on it: the next reason will collide with something. 4. **Any number in the copy comes from the response.** A hard-coded "3" lies for every tenant on a different limit. Pin it: ```tsx it("takes the number from the response, not a hardcoded free-plan 3", ...) // respond limit: 10 → the sentence must say 10 ``` 5. **Do not render the backend's `detail` string verbatim** on a localized surface — that is how English lands on a Korean screen. The server sends the code and the numbers; the client owns the sentence. 6. **Keep a control test for the OLD refusal.** Without it, deleting the original branch leaves every new test green. ## Verification Cut each wire separately and watch only its own tests go red: | cut | expect red | expect still green | |---|---|---| | remove the new reason branch in the client | new-reason tests | the old-cause control | | unwire the refusal on surface A (REST) | A's tests | surface B's tests | | unwire the refusal on surface B (MCP/CLI) | B's tests | A's tests | If one cut reddens both surfaces, they share a guard and one of them is not actually wired — the classic mirrored-surface drift. ## Measured case (BSVibe, 2026-09-03) `POST /api/v1/messages` had refused only for a zero-product workspace (400). Adding a free-plan concurrent-run cap as another 400 would have shown *"Create a product first"* to founders with products. Shipped instead as 429 + `{code, limit, held}`, with the client branching on `code` and the limit interpolated from the response. ## Related * `single-active-resolver-degrades-on-new-account-class` — same shape one layer down: a discriminator that was unique stops being unique when you add a second class of the thing. * `mirrored-surface-drifts-in-the-direction-of-least-testing` — share the RULE, keep the error faces separate, test both.
عرض على GitHub