# Unexpected blocks (/docs/troubleshooting/unexpected-blocks)



A call raised `TokenPoliceBlockedError` and you didn't expect it. Good news: a block is never
silent or accidental — it takes **two dials both set to enforce plus a rule that matched**. Any
one of those being off means nothing blocks. Here's how to trace it back.

## The block carries its own explanation [#the-block-carries-its-own-explanation]

The error names the rule that stopped the call. Catch it and read the fields:

```python
from token_police import TokenPoliceBlockedError

try:
    answer = client.chat.completions.create(...)
except TokenPoliceBlockedError as e:
    print(e.reason)     # human-readable "why"
    print(e.rule_id)    # the exact rule that fired — look it up in the dashboard
    print(e.kind)       # "budget", or the loop detector that fired
```

```typescript
import { TokenPoliceBlockedError } from "token-police";

try {
  const answer = await client.chat.completions.create({ ... });
} catch (e) {
  if (e instanceof TokenPoliceBlockedError) {
    console.log(e.reason, e.ruleId, e.kind);
  }
}
```

Take `rule_id` to the rules table in your [dashboard](/docs/dashboard) to see exactly what that
rule matches and what it does. Full field reference: [Errors](/docs/sdk/errors).

## Why it fired when you didn't expect it [#why-it-fired-when-you-didnt-expect-it]

<Steps>
  <Step>
    **A rule is scoped to a plan or user you're testing as.** A rule like "IF `paid_plan` is `free`"
    only blocks free-plan traffic. Check the `user_id` / `paid_plan` your code is attaching — if a
    test account maps to a capped plan, the block is correct.
  </Step>

  <Step>
    **The plan string on the call isn't the one you think it is.** Matching is *exact*: case- and
    whitespace-sensitive, with no null tier.

    | What your app sends as `paid_plan` | What a rule "IF `paid_plan` is `free`" does                                                                                |
    | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
    | nothing at all                     | counts as `"free"` — the rule fires, **including for paying users**, and their spend eats the free-tier allowance          |
    | `"Free"`, `"FREE"`, `" free "`     | never matches — that spend is invisible to this rule, and a rule "IF `paid_plan` **is not** `free`" fires for them instead |

    Attach `paid_plan` on every call and normalize it once at the call site
    (`str(user.plan or "free").strip().lower()`), then use that same spelling in every rule. More in
    [Identity](/docs/concepts/identity).
  </Step>

  <Step>
    **The rule has no limit.** A rule created as **Unconditional** has no threshold and blocks *every*
    call its filter matches, from the first one — there is no budget to fill. The rules table shows
    `Unconditional` or `No Limit` in the Limit column. See [Actions](/docs/rules/actions).
  </Step>

  <Step>
    **A budget window already filled up.** Budgets accumulate over their window (per user, per
    tenant, per feature — whatever the rule applies to). An earlier run in the same window may have
    spent the allowance; the `reason` field says so. Note that a sliding window resets only after a
    quiet gap, so a pool that keeps spending never resets.
  </Step>

  <Step>
    **A loop rule tripped.** Loop detection watches one *trace* and stops a later call on that trace
    once it looks stuck — see [Loop detection](/docs/rules/loop-detection).
  </Step>
</Steps>

## The opposite problem: it *should* block but doesn't [#the-opposite-problem-it-should-block-but-doesnt]

Enforcement takes **both dials** — the SDK `firewall` mode **and** the rule's own mode. If you
expected a block and got none, one dial is still on dry-run: check the `firewall` value in your
`init()`, and the **Current Mode** column next to the rule in the rules table. The full truth
table is in [Dry-run vs enforce](/docs/concepts/dry-run-vs-enforce).

A **Notify** rule never blocks anything at all — it has no Dry-run/Enforce dial and can't be
promoted to one. If you wanted a wall, the action has to be Block.

<Callout type="warn">
  To stop *all* blocking immediately without touching your rules, set `firewall="off"` (Python) /
  `firewall: "off"` (Node). Usage keeps flowing; no call can ever be blocked.
</Callout>

## Next [#next]

<Cards>
  <Card title="Dry-run vs enforce" href="/docs/concepts/dry-run-vs-enforce" description="The two dials and the full truth table." />

  <Card title="Errors" href="/docs/sdk/errors" description="Every field on TokenPoliceBlockedError." />
</Cards>
