ClearBlade IoT Enterprise and Edge

Throttling

Throttling

Overview

Throttler Management protects a ClearBlade Platform from excessive or abusive traffic by defining rate and concurrency limits on three fixed categories of activity: HTTP API requests, MQTT connections, and MQTT publishes. Instead of writing custom enforcement logic, throttling is configured via declarative cases. These are rules that match a system, a consumer, and (where applicable) a resource, and apply a limit to anything matching them.

This page is a complete reference for designing, deploying, and operating a throttling strategy: the underlying concepts, exact matching and limiting semantics, the equivalent console workflow, the full REST API, worked examples, and a troubleshooting/FAQ section.


Core concepts

Throttlers vs. cases

There are exactly three throttlers, and they are fixed. The throttlers cannot be created, renamed, or deleted. Configuration is done by creating cases within each throttler:

Throttler

Fires on

Consumer identifies

Resource identifies

HTTP Requests

Every platform API call

email (or device name), ip_address

The request URI / endpoint path

MQTT Connections

Every MQTT CONNECT

email (or device name), ip_address

Not applicable — must be left empty

MQTT Publishes

Every MQTT PUBLISH

email (or device name), ip_address

The MQTT topic path

A case is a rule that says: "for this system key, this consumer, and (if applicable) this resource, apply this limit." Each throttler can hold any number of cases, and every request evaluated against that throttler is checked against the case list to find the one rule that governs it.

Who is subject to throttling

Requests authenticated as a developer's own platform login (a dev-level identity) bypass throttling entirely. Only end-user, device, and anonymous/unauthenticated traffic is subject to enforcement. Hence a developer can always administer or test a system without being blocked by throttles.

What happens when a limit is hit

  • HTTP Requests: the caller receives HTTP 429 Too Many Requests.

  • MQTT Connections: the broker rejects the CONNECT with a "connection rate exceeded" reason code and closes the connection.

  • MQTT Publishes: the publish is rejected.


How case evaluation works

For a given throttler, every case has a priority (a positive integer). When a request comes in:

  1. All cases for that throttler are sorted ascending by priority. Lower number evaluates first.

  2. The platform walks the list in that order and checks each case's system_key, resource (if applicable), and consumer filters against the request.

  3. The first case whose filters all match wins. Its limiter is applied, and evaluation stops immediately.

  4. If no case matches, the request is not throttled at all.

This is a first-match-wins model. If multiple cases would all technically match a request, only the highest-priority (lowest-number) one of them is ever evaluated or enforced.

Priority ordering used for whitelisting

There is no dedicated whitelist, exclusion list, or "bypass" field in a case. These are not needed because evaluation stops on the first match. Whitelisting is achieved simply by giving "allow" cases lower priority numbers than the stricter cases they should override.

For example: if a specific, trusted consumer must be exempt from a strict per-consumer rate limit, then an allow-case should be created scoped to that consumer with max_num=-1 (unlimited) and a priority number smaller than the blocking case's priority. The allow case matches first, wins, and the blocking case is never reached for that consumer. Everyone else falls through to the stricter case as normal. See Allow-list in front of a stricter block below for a complete walkthrough.

Note that the platform's own built-in default cases run at priority=200 (see Built-in default cases below). Any custom case with a priority below 200 will evaluate before the built-in default cases.


Fields reference

Each case is a JSON object with the following fields:

Field

Type

Required

Notes

throttler_name

string

Yes (as URL path segment on create)

One of HTTP Requests, MQTT Connections, MQTT Publishes

case_name

string

Yes

Unique name for this case within its throttler

system_key

string

Yes

Exact system key, ? (each system key gets its own independent bucket), or * (all systems share one bucket)

consumer

string

Yes

See Consumer field below

resource

string

Conditionally

Required for HTTP Requests and MQTT Publishes; must be omitted/empty for MQTT Connections (the API rejects a value here)

limiter_type

string

Yes

frequency or concurrency

max_num

integer

Yes

-1 = unlimited (never throttles this match), 0 = always throttle (hard block), N = the actual limit. 0 is also the value when this field is omitted entirely — an easy way to accidentally ship a case that blocks everything it matches

duration

integer (seconds)

Frequency only

Ignored for concurrency limiters

priority

integer, must be > 0

Yes

Lower number is evaluated first

Consumer field

The consumer field supports two formats.

1. Legacy single-value format: a bare string, matched against whichever identifying attribute (email, device name, or IP) the request actually carries:

  • An exact value (e.g. jane@example.com or 203.0.113.7) matches only that consumer.

  • ? gives each distinct consumer value its own independent bucket — i.e., "limit everyone individually."

  • * pools all consumers into a single shared bucket — i.e., "limit everyone collectively."

2. Typed/slotted format: email=<value|?|*>,ip_address=<value|?|*> (comma-separated slot=filter pairs). Unlike the legacy format, this allows pinning a case to a specific attribute and ignore the other.

Note that the email slot holds whichever identity the requester actually has. It can be a user's email address, OR a device's name. The console's own field label spells this out as Consumer (User, Device Name, or IP).

For example…


* These match purely on source `ip_address` regardless of which `email` (if any) is present:

    * `ip_address=203.0.113.7`
    * `ip_address=203.0.113.7,email=*`
    * `ip_address=?`
    * `ip_address=?,email=*`
    
* These match purely on source `email` (again, this COULD be a device name) regardless of which `ip_address` (if any) is present:

    * `email=jane@example.com`
    * `email=jane@example.com,ip_address=*`
    * `email=device-042`
    * `email=device-042,ip_address=*`
    * `email=?`
    * `email=?,ip_address=*`

Resource field

resource applies only to HTTP Requests (endpoint path) and MQTT Publishes (topic path); it must be left empty for MQTT Connections. It supports three matching styles:

  • An exact string.

  • ? / * wildcards.

  • A route-pattern string (must contain a /), evaluated using the same route-matching engine the platform uses to register its own API routes. This supports {paramName} path-segment placeholders and a trailing catch-all segment.

Route patterns are the most useful style for real systems:

  • /api/v/1/code/{systemKey}/registerUser — matches that endpoint exactly, for any system key value in that position (useful when a case should apply irrespective of system_key, or in combination with a system_key=?/* wildcard).

  • /api/v/1/code/{systemKey}/* — a catch-all that covers every code-service endpoint across all systems in one case, instead of enumerating each one.

For MQTT Publishes, the same pattern syntax applies to topic paths, so an entire topic tree (e.g. devices/{deviceId}/*) can be throttled with a single case.


Limiter semantics

There are two limiter types, and they behave quite differently. This distinction matters when deciding what kind of protection is needed.

Concurrency limiter

A concurrency limiter tracks a simple in-flight counter per case-match key (the combination of system, resource, and consumer that the case resolved to). On every request:

  1. The counter increments.

  2. If the new count exceeds max_num, the request is rejected immediately.

Concurrency limiters never queue requests. Hence there is no "wait your turn" behavior. A rejection happens the instant capacity is exceeded, and the caller must retry on its own. max_num=-1 means unlimited concurrent in-flight requests for that match.

duration is ignored for concurrency limiters.

Frequency limiter

Frequency limiters govern rate (number of requests per unit of time). Frequency limiters can be in one of two modes: Default or Global. The mode is set by a server-wide Boolean Configuration parameter: EnableGlobalThrottlers.

Default mode (EnableGlobalThrottlers=false, the default): a per-node token bucket. Picture a small bucket that holds up to max_num tokens, where each token represents one request the case is still willing to let through. Every allowed request removes one token from the bucket; if the bucket is empty, the request is throttled instead. Tokens refill automatically over time — one new token every duration / max_num seconds — up to that same max_num cap, so idle time never lets the bucket bank more than its capacity. The result is a continuously changing rate limit: capacity trickles back a little at a time instead of resetting all at once at a window boundary, so a throttled consumer isn't stuck waiting for some window to fully reset — they simply regain a fraction of their allowance as time passes.

Example — a case with max_num=2, duration=60 (one token refills every 30s), first request at t=0s, then one request every 14s:

Time

Tokens before request

Result

0s

2 (full)

Allowed

14s

1

Allowed

28s

0

Throttled

42s

1 (just refilled)

Allowed

56s

0

Throttled

70s

1 (just refilled)

Allowed

84s

0

Throttled

98s

1 (just refilled)

Allowed

112s

0

Throttled

Notice recovery at 42s comes just one 30-second refill after being throttled at 28s — there's no fixed window the consumer has to wait out.

Global mode (EnableGlobalThrottlers=true, requires Redis as the platform's key-value store): a Redis-backed fixed window, shared cluster-wide. Every node increments the same counter in Redis. That counter's window is created — and its TTL started — by whichever request is the first to arrive after the previous window closed, and every node shares this same counter and TTL. Once the count exceeds max_num within that window, every further request is denied for the remainder of the window; only then does the counter reset to zero and a new window begin. This is the classic "hard block until the window rolls over" behavior, enforced identically no matter which node in the cluster handles the request.

Example — same case (max_num=2, duration=60) and same request pattern (one every 14s, starting at t=0s), for direct comparison with Default mode above:

Time

Window

Count

Result

0s

[0s, 60s) opens

1

Allowed

14s

[0s, 60s)

2

Allowed

28s

[0s, 60s)

3

Throttled

42s

[0s, 60s)

4

Throttled

56s

[0s, 60s)

5

Throttled

70s

[70s, 130s) opens

1

Allowed

84s

[70s, 130s)

2

Allowed

98s

[70s, 130s)

3

Throttled

112s

[70s, 130s)

4

Throttled

Compare the two tables at 42s and 84s: Default mode allows the 42s attempt but Global mode throttles it, and the reverse happens at 84s (the same divergence recurs again at 98s). Same nominal limit, same traffic, genuinely different outcomes — which is why the two modes aren't interchangeable even when configured with identical numbers.

If Redis becomes unreachable while global mode is enabled, each node falls back independently to its own local token-bucket counter. This means during a Redis outage, an N-node cluster can effectively allow up to N times the configured limit in aggregate until Redis recovers. Redis availability should be considered if the Global mode's exactness is required.

For both modes: max_num=0 is a permanent hard block for anything matching the case; max_num=-1 means unlimited.

Choosing between the two modes: pick Global mode when exact, cluster-wide enforcement of a hard limit (e.g. a compliance-driven rate cap) is required. Pick Default mode when low latency and best-effort enforcement is good enough. Default mode offers lower latency by avoiding the Redis round-trip used in the Global mode.

Note: if an environment is hosted by ClearBlade then ClearBlade support personnel should be contacted to find out and/or change the value of EnableGlobalThrottlers.


Built-in default cases

Unless disabled, two default cases are always present and active out of the box:

Case

Throttler

Resource

Type

Limit

Consumer scope

Block brute force auth

HTTP Requests

/api/v/1/user/auth

frequency

3 per 600 seconds

Per system, per consumer (?/?)

Block brute force password reset core api

HTTP Requests

/api/v/1/user/pass

frequency

3 per 600 seconds

Per system, per consumer (?/?)

Both run at priority=200. While these default cases are active, the API rejects any attempt to delete or modify them directly returning an error naming the protected case. If different behavior is needed for these endpoints, add a custom case with a lower priority number so it wins the match first (the same allow-list pattern described above), or disable default throttlers entirely via server configuration.

These defaults are controlled by the server-wide EnableDefaultThrottlers setting (default true), and are automatically disabled whenever the server is running in DevelopmentMode. If these are disabled and replaced with custom, equivalent cases, then those cases should be named using a custom naming prefix in order to prevent naming conflicts with current and future built-in default cases.


Operator settings (server-wide configuration)

These are platform-level settings, not per-case fields. Setting these requires low-level access to the environment’s infrastructure:

Setting

Default

Effect

EnableDefaultThrottlers

true

Enables/disables the two built-in brute-force-protection cases described above

EnableGlobalThrottlers

false

Switches frequency limiters from per-node token-bucket to Redis-backed cluster-wide fixed window. Requires the platform's KV store to be configured as Redis — without Redis, frequency limiters silently fall back to per-node counting even with this set to true

DevelopmentMode

false

When enabled, automatically disables the default throttlers regardless of EnableDefaultThrottlers


Using the console

Most day-to-day case management can be done directly from the console, at Admin Management → Throttling — no API calls required. It's the same underlying data as the REST API described below: a case created via the console shows up in GET /admin/throttlers, and vice versa, so the two are safe to mix — e.g. bulk-load cases via the API and hand-tune one from the console.

Screenshot from 2026-08-27 11-08-25.png
Screenshot from 2026-08-27 11-10-46.png
image-20260827-171723.png
image-20260827-171819.png
image-20260827-171850.png
image-20260827-171933.png


The pencil icon on an existing case opens this same form, pre-filled. This is the console's equivalent of PUT on that case (see REST API reference below). Name is shown grayed out here. In this UI, gray means genuinely not editable, not just visually muted. So cases cannot be renamed this way. They must be deleted and recreated.

The case's Specific resource value is grayed out the same way, so the exact value behind a Specific filter can't be changed post-creation either. Priority, the three-way match toggles, Limiter Type, and Max Number all remain editable (shown in white, not gray).

Each case has a Name (case_name) and Priority (priority) followed by one auto-generated plain-English sentence that encodes the rest of the case's fields (system_key, consumer, resource, limiter_type, max_num, duration) in a single readable line, for example: "When any user, device, or IP makes 3 requests to endpoint '/api/v/1/user/pass' in 10 minutes on any system." Each row has pencil (edit) and trash (delete) icons for that one case, equivalent to PUT/DELETE on /admin/throttlers/{throttlerName}/cases/{caseName} (see REST API reference below).


REST API reference

Everything described in Using the console above can also be managed over HTTP. Most day-to-day changes are easiest from the console. Use this API for:

  • Managing cases as code

  • Script bulk changes

  • Drive throttling configuration from a CI/CD pipeline

All Throttler Management endpoints live under /admin/throttlers and require platform admin-level authentication, i.e. an admin user session or a developer token. Regular end-user tokens and device tokens cannot call these endpoints.

{throttlerName} in any path below must be one of the three exact throttler names: HTTP Requests, MQTT Connections, or MQTT Publishes. It must be URL-encoded (e.g. HTTP%20Requests).

List all throttlers

GET /admin/throttlers

Returns all three throttlers along with every case currently defined on each.

Get a single throttler

GET /admin/throttlers/{throttlerName}

Returns one throttler and its full case list.

Create a case

POST /admin/throttlers/{throttlerName}/cases

Body is a case object (see Fields reference above). Note there is no GET or PUT on the collection path itself. Creation always happens via POST, and updates target a specific case by name (below). Example body for a frequency case:

JSON
{
  "case_name": "limit-registerUser-per-ip",
  "system_key": "?",
  "consumer": "ip_address=?",
  "resource": "/api/v/1/code/{systemKey}/registerUser",
  "limiter_type": "frequency",
  "max_num": 10,
  "duration": 60,
  "priority": 50
}

Update a case

PUT /admin/throttlers/{throttlerName}/cases/{caseName}

Accepts a partial-field update. Only the fields being changed need to be included, NOT the full case object.

Delete a single case

DELETE /admin/throttlers/{throttlerName}/cases/{caseName}

Delete all cases for one throttler

DELETE /admin/throttlers/{throttlerName}

Delete every case across all three throttlers

DELETE /admin/throttlers

Use with caution. It clears the entire throttling configuration across HTTP Requests, MQTT Connections, and MQTT Publishes in one call. The protected built-in default cases, if enabled, are not deletable this way either. See Built-in default cases above.


Examples

Allow-list in front of a stricter block

This is the pattern for rate-limiting all but a small set of trusted consumers using the priority-ordering behavior described earlier.

Goal: limit HTTP Requests to /api/v/1/code/mySystem/heavyOperation to 5 requests per minute per consumer, except for one trusted internal IP address which should be unlimited.

Step 1 — create the allow case first, at a lower priority number:

JSON
{
  "case_name": "trusted-ip-unlimited",
  "system_key": "mySystem",
  "consumer": "ip_address=198.51.100.20",
  "resource": "/api/v/1/code/mySystem/heavyOperation",
  "limiter_type": "frequency",
  "max_num": -1,
  "duration": 60,
  "priority": 10
}

Step 2 — create the general blocking case at a higher priority number (i.e. it is evaluated AFTER the case with the lower priority number):

JSON
{
  "case_name": "heavyOperation-general-limit",
  "system_key": "mySystem",
  "consumer": "ip_address=?",
  "resource": "/api/v/1/code/mySystem/heavyOperation",
  "limiter_type": "frequency",
  "max_num": 5,
  "duration": 60,
  "priority": 100
}

Result: for a request from 198.51.100.20, the platform evaluates trusted-ip-unlimited first (priority 10 < 100), the consumer filter matches, and evaluation stops there. heavyOperation-general-limit is never consulted. For every other IP, trusted-ip-unlimited's consumer filter doesn't match, so evaluation falls through to heavyOperation-general-limit, which enforces 5/minute per distinct IP.

No separate whitelist or exclusion mechanism is needed. Priority ordering plus a max_num=-1 allow case is the native whitelist mechanism. Note that "trusted IP" here means one exact address per allow case; a range of trusted addresses requires one case per address.

Covering a whole service with one case

Rather than writing a separate case for every endpoint in a service, use a catch-all route pattern:

JSON
{
  "case_name": "code-service-per-system-limit",
  "system_key": "?",
  "consumer": "?",
  "resource": "/api/v/1/code/{systemKey}/*",
  "limiter_type": "frequency",
  "max_num": 100,
  "duration": 60,
  "priority": 150
}

system_key: "?" gives every system its own independent bucket, consumer: "?" gives every consumer within a system their own independent bucket, and the trailing * on the resource pattern covers every code-service endpoint. This keeps the case list small and helps performance. See Scope and performance at scale below.

Concurrency cap on MQTT publishes

To cap how many in-flight publishes a single device can have on a topic tree at once:

JSON
{
  "case_name": "device-publish-concurrency-cap",
  "system_key": "mySystem",
  "consumer": "?",
  "resource": "devices/{deviceId}/telemetry/*",
  "limiter_type": "concurrency",
  "max_num": 20,
  "priority": 100
}

Any publish beyond 20 simultaneously in-flight for a given consumer on that topic pattern is rejected immediately. There is no queueing.


Propagation and enforcement timing

Case changes take effect immediately. When a case is created, updated or deleted, the originating node reloads its in-memory case list right away and broadcasts the change to every other node in the cluster. The other nodes reload their in-memory case lists in turn. The whole process completes within roughly one RPC round-trip. There is no restart required and no meaningful propagation delay to design around. Treat case changes as live the moment the API call, or console action, returns successfully.


Observability and auditing

If the platform server has Prometheus metrics enabled, two metrics are emitted:

  • A counter of throttled/denied requests, per throttler type (HTTP Requests, MQTT Connections, MQTT Publishes). These are not broken out per case or per consumer.

  • A histogram of case-evaluation latency.

These give aggregate visibility (e.g. "how often is MQTT Publishes rejecting traffic right now"). To know exactly which case is blocking a specific consumer, temporarily isolate the suspect case. This can be done by narrowing the case’s filters and/or raising its priority (i.e. by lowering its priority NUMBER) so it evaluates alone.

For example…

Let’s say we suspect that THIS case is throttling a specific consumer:

JSON
{
    "case_name": "code-service-per-system-limit",
    "system_key": "?",
    "consumer": "?",
    "resource": "/api/v/1/code/{systemKey}/*",
    "limiter_type": "frequency",
    "max_num": 100,
    "duration": 60,
    "priority": 150
  }

We can change the case to the following which has a higher priority (i.e. lower priority NUMBER) and narrower filters:

JSON
{
    "case_name": "code-service-per-system-limit-DEBUG",
    "system_key": "mySystem",
    "consumer": "email=jane@example.com",
    "resource": "/api/v/1/code/mySystem/heavyOperation",
    "limiter_type": "frequency",
    "max_num": 100,
    "duration": 60,
    "priority": 1
  }

If the consumer’s behavior remains UNCHANGED then we can be confident that the suspected case WAS the one throttling the consumer.

If the consumer’s behavior CHANGES that means the original case was NOT the one throttling the consumer. A different case with a higher priority (i.e. lower priority number) than the original case must have been the one throttling the consumer.


Scope and performance at scale

There is no enforced maximum on the number of cases that can be defined overall or per throttler.

Performance-wise, matching a request against a throttler's case list is a linear scan. Every case is a candidate that gets checked in priority order until one matches. For performance reasons, the minimum number of needed cases should be used.

Recommendation: prefer ?/* wildcards and the typed consumer-slot syntax (email=..., ip_address=...) combined with route-pattern resources over enumerating many near-duplicate cases per exact value. Covering a whole service with one case above shows this pattern. One case with wildcards can replace dozens of per-endpoint, per-system cases while keeping evaluation fast and the ruleset easy to reason about.


Troubleshooting and FAQs

  • My case doesn't seem to be applying — traffic isn't being throttled. Check priority ordering first. If another case with a lower priority number also matches the same traffic, it wins and your case is never reached — this is the single most common cause of "my rule isn't working." List the throttler's cases (API or console) and confirm your case's priority is lower than any case you expect it to override, and higher than any case you expect to defer to.

  • I set up a case but it's blocking traffic I didn't intend to block. Double-check the consumer and resource filters aren't broader than intended — ? and * are easy to mix up (? buckets per distinct value; * pools everyone/everything together). Also remember requests authenticated as a developer login bypass throttling entirely, so if you're testing with your own dev credentials and not seeing expected blocks, that's why — test with an end-user or device identity instead.

  • Why is my brand-new case blocking everything immediately? max_num defaults to 0 (hard block) if you don't set it — the console's Add Case form pre-fills Max Number as 0, and the API defaults to the same value if you omit max_num from the request body entirely. Set it explicitly to your intended limit, or -1 for unlimited.

  • Can I whitelist a range of IP addresses? Not natively — there is no CIDR/range support anywhere in the product. You can whitelist individual exact IP addresses (one case per address); grouping a range under one case is not supported. If your trusted parties authenticate with a stable email/identity, exempting by email instead of ip_address is usually more practical than maintaining a list of individual IPs.

  • Does concurrency limiting queue excess requests? No. Concurrency limiters reject immediately once the in-flight count exceeds max_num — there's no queueing or waiting behavior for either limiter type.

  • Will my rate limit reset all at once, or trickle back? It depends on server configuration. By default (EnableGlobalThrottlers=false), frequency limiters are per-node token buckets that refill continuously — no hard reset moment. If your platform operator has enabled EnableGlobalThrottlers with Redis as the KV store, frequency limiters become a true fixed window shared across the cluster, and a consumer that exceeds the limit is blocked for the rest of that window before resetting to zero. Confirm which mode your deployment runs before relying on one behavior or the other.

  • Can I see which case blocked a specific request or consumer after the fact? Not directly — there's no per-event audit trail today. Prometheus metrics (if enabled) give you aggregate throttle counts per throttler type and evaluation latency, but not per-case or per-consumer breakdowns. See Observability and auditing above.

  • Is there a safe way to test a new limit before it takes effect? There's no true dry-run mode. The best approximation is to deploy the case with a very high max_num first and watch aggregate match volume via metrics, then tighten it once you're confident.

  • Do I need to restart anything after changing a case? No. Changes propagate cluster-wide within about one RPC round-trip and take effect immediately, whether made via the API or the console.

  • Can I throttle by device name instead of email or IP? Yes — the console's Consumer field is explicitly labeled User, Device Name, or IP. The same slot documented as email in Fields reference holds whichever identity the requester actually has: a user's email address, or a device's name for device traffic. It's still just that one identity slot plus ip_address, not a third dedicated device slot, but device names are matched, not excluded.

  • Why can't I delete or edit the brute-force-protection cases? They're protected by design while EnableDefaultThrottlers is active for your system — the API and console both reject the operation and name the protected case. If you need different behavior on those endpoints, add your own case at a lower priority number so it matches first (see Built-in default cases above), or ask your platform operator to disable default throttlers.

  • How many cases can I create before it affects performance? There is no imposed limit. Performance should be monitored as cases are added. Favor wildcards and route patterns over one case per exact value.