Every developer has encountered the dreaded HTTP 429 — "Too Many Requests." It shows up in API documentation, appears in server responses, and lives in the collective anxiety of anyone who has accidentally triggered a rate limit. By now, most engineers understand that 429 means "slow down." But what happens when a modern API delivers a completely different error code — say, 3001 — instead of the familiar HTTP status code?
This is not a bug. It is a deliberate architectural choice.
TickDB uses a unified application-layer error code system with 3001 as its rate-limiting signal. The choice is intentional, more precise than HTTP 429, and far more useful in the context of real-time market data APIs. This article explains why.
1. The Fundamental Distinction: HTTP Status Codes vs. Application Error Codes
To understand why 3001 exists, we need to first understand the difference between two layers of error reporting that often get conflated.
1.1 HTTP Status Codes: Transport-Layer Signals
HTTP status codes are part of the transport protocol. They describe the outcome of the HTTP request itself — did the server receive the request, was it syntactically valid, did the resource exist, and was the server available? The HTTP 429 status code lives at this layer.
HTTP/1.1 429 Too Many Requests
Retry-After: 5
Content-Type: application/json
{"error": "Rate limit exceeded"}
When you receive a 429, the HTTP layer is telling you: "I understood your request. The server is working. But you have sent too many requests in a given time window, so I am refusing to process this one." The protocol is working correctly. You are the problem.
1.2 Application Error Codes: Semantic Layer
Application error codes live one layer above HTTP. They describe the semantic outcome of your request — did the data you requested exist, is your authentication valid, did your request payload conform to the expected schema, and is the business rule satisfied? These are errors that exist regardless of whether the HTTP transport succeeded or failed.
{
"code": 3001,
"message": "Rate limit exceeded. Please retry after 5 seconds.",
"data": null
}
In TickDB's response structure, code: 0 means success. Any non-zero code is an application-level error. The HTTP status is always 200 when the request reaches the TickDB API gateway — meaning the transport layer succeeded. The code field tells you what actually happened at the business-logic level.
This is the core architectural distinction: HTTP tells you whether the request arrived. Application codes tell you what the request means.
2. Why 3001 Instead of 429?
There are three concrete reasons why TickDB uses 3001 for rate limiting rather than returning HTTP 429.
2.1 Reason One: Mixed Error Semantics in a Single HTTP Response
A single HTTP request to a market data API can encounter multiple types of errors in sequence. Consider this scenario:
- The request is syntactically valid (HTTP 200 at the transport layer).
- The API key is valid (no authentication error).
- The symbol exists and is supported for this endpoint.
- The request does not violate the global rate limit.
But the per-IP rate limit is exceeded, and the per-symbol rate limit is also exceeded. Which HTTP status code do you return?
HTTP 429 can only express one concept: "you made too many requests." It cannot distinguish between:
- A global rate limit violation.
- A per-endpoint rate limit violation.
- A per-symbol rate limit violation.
- A burst quota exhaustion.
TickDB's error code system can express all of these separately:
| Code | Meaning | Context |
|---|---|---|
| 3001 | Rate limit exceeded — general | Global request count exceeded |
| 3002 | Rate limit exceeded — endpoint specific | Certain high-cost endpoints have stricter limits |
| 3003 | Rate limit exceeded — burst quota | Short-term burst limit exhausted |
| 3004 | Rate limit exceeded — daily quota | Daily allowance consumed |
Each code carries semantic information. The client can inspect code and respond differently — waiting 5 seconds for a burst quota reset versus waiting until tomorrow for a daily quota reset. HTTP 429 collapses all of this into a single, undifferentiated rejection.
2.2 Reason Two: API Gateway Architecture Requires Separation
Modern API architectures separate concerns across layers. A typical flow looks like this:
Client Request
↓
API Gateway (rate limiting, auth, IP filtering)
↓
Application Layer (business logic, data validation, resource checking)
↓
TickDB Engine (low-latency market data retrieval)
If the API gateway returns HTTP 429, the application layer never receives the request. You lose the ability to:
- Log which symbol was requested when the rate limit was hit.
- Understand which endpoint triggered the limit.
- Provide context-aware retry guidance based on the specific resource that was throttled.
By handling rate limiting at the application layer with code 3001, TickDB can attach rich contextual metadata to every rate-limit error:
{
"code": 3001,
"message": "Rate limit exceeded for symbol NVDA.US",
"data": {
"limit_type": "per_symbol",
"current_usage": 150,
"limit": 100,
"window_seconds": 60,
"retry_after": 12
}
}
This level of detail is impossible to convey through an HTTP 429 response alone.
2.3 Reason Three: Unified Error Schema Across All Response Types
HTTP status codes are a one-dimensional signal. You get a three-digit number and nothing else — unless you also embed a JSON body. Application error codes are embedded in a consistent response schema that never changes, whether the request succeeds or fails.
// Success response
{
"code": 0,
"message": "Success",
"data": { ... }
}
// Error response
{
"code": 3001,
"message": "Rate limit exceeded",
"data": null
}
// Another error response
{
"code": 1001,
"message": "Invalid API key",
"data": null
}
Every response — success or failure — follows the same structure. Your client code can parse the code field and handle it generically. You never need to check http_response.status_code for some errors and json_body.code for others.
3. The Retry-After Standard in Practice
One of the most important aspects of rate-limit handling is the Retry-After header (or field in TickDB's JSON body). The Retry-After value tells you exactly how long to wait before retrying. Getting this wrong causes cascading failures.
3.1 How TickDB Reports Retry-After
In HTTP responses, Retry-After is a header. In TickDB's JSON response, it is included in the data field:
{
"code": 3001,
"message": "Rate limit exceeded",
"data": {
"retry_after": 12,
"window_seconds": 60
}
}
The retry_after value is always in seconds and represents the minimum wait time before the next request will be accepted.
3.2 Correct Retry Implementation
Here is the production-grade retry handler for TickDB rate limits:
import os
import time
import random
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def fetch_with_retry(url: str, headers: dict, params: dict, max_retries: int = 5):
"""
Fetch data from TickDB with rate-limit-aware exponential backoff.
Handles code 3001 (rate limit) with proper Retry-After parsing.
Handles code 1001/1002 (auth) by raising immediately.
"""
session = requests.Session()
adapter = HTTPAdapter(
max_retries=Retry(total=0, respect_retry_after_header=False)
)
session.mount("https://", adapter)
for attempt in range(max_retries):
response = requests.get(
url,
headers=headers,
params=params,
timeout=(3.05, 10)
)
result = response.json()
code = result.get("code", 0)
if code == 0:
return result.get("data")
# Handle rate limit — 3001, 3002, 3003, 3004
if 3001 <= code <= 3004:
retry_data = result.get("data", {})
retry_after = retry_data.get("retry_after", 5)
if attempt == max_retries - 1:
raise RuntimeError(
f"Rate limit exceeded after {max_retries} retries. "
f"Last response: code={code}, retry_after={retry_after}s"
)
# Apply exponential backoff with jitter
base_wait = retry_after
backoff = min(base_wait * (2 ** attempt), 60)
jitter = random.uniform(0, backoff * 0.1)
total_wait = backoff + jitter
print(f"[Attempt {attempt + 1}] Rate limited (code {code}). "
f"Waiting {total_wait:.1f}s (base: {retry_after}s)")
time.sleep(total_wait)
continue
# Handle authentication errors — do not retry
if code in (1001, 1002):
raise ValueError(
f"Authentication failed (code {code}). "
"Check your TICKDB_API_KEY environment variable."
)
# Handle symbol not found — do not retry
if code == 2002:
raise KeyError(
f"Symbol not found (code {code}). "
"Verify the symbol via /v1/symbols/available before retrying."
)
# Unexpected error — fail fast
raise RuntimeError(f"Unexpected error code {code}: {result.get('message')}")
return None
# Usage
API_KEY = os.environ.get("TICKDB_API_KEY")
headers = {"X-API-Key": API_KEY}
url = "https://api.tickdb.ai/v1/market/kline"
data = fetch_with_retry(
url,
headers=headers,
params={"symbol": "AAPL.US", "interval": "1h", "limit": 100}
)
3.3 Common Retry Mistakes to Avoid
| Mistake | Why it fails | Correct approach |
|---|---|---|
| Ignoring Retry-After and using a fixed 1-second wait | Rapidly re-triggers the limit; server may blacklist your IP | Use the retry_after value from the response |
| Not implementing backoff | Stress-tests the server and gets yourself rate-limited harder | Apply exponential backoff with cap at 60 seconds |
| Retrying on authentication errors | Wastes quota and delays the fix | Fail immediately and surface the auth error |
| Retrying on 4xx errors without distinction | 4xx errors (other than rate limits) represent bad requests — retrying will never succeed | Only retry on 5xx and rate-limit (3001–3004) codes |
| Using a sleep without jitter | All clients retry at the same instant (thundering herd) | Add random jitter: wait = base * (2 ** attempt) + random.uniform(0, base * 0.1) |
4. The Full TickDB Error Code Reference
For reference, here is the complete TickDB application error code system:
4.1 Category 1000: Authentication and Authorization
| Code | Meaning | Retry? | Action |
|---|---|---|---|
| 1001 | Invalid API key | No | Check TICKDB_API_KEY environment variable |
| 1002 | API key missing | No | Pass the X-API-Key header |
| 1003 | Insufficient permissions | No | Upgrade plan or check endpoint access |
4.2 Category 2000: Resource and Data Errors
| Code | Meaning | Retry? | Action |
|---|---|---|---|
| 2001 | Invalid symbol format | No | Validate symbol string before sending |
| 2002 | Symbol not found | No | Query /v1/symbols/available to find valid symbols |
| 2003 | Invalid interval | No | Use supported intervals: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w, 1M |
| 2004 | Data not available for requested period | Maybe | Try a different date range |
4.3 Category 3000: Rate Limiting and Quota
| Code | Meaning | Retry? | Action |
|---|---|---|---|
| 3001 | Rate limit exceeded — general | Yes | Wait retry_after seconds |
| 3002 | Rate limit exceeded — endpoint specific | Yes | Wait and reduce request frequency for this endpoint |
| 3003 | Rate limit exceeded — burst quota | Yes | Wait for burst window to reset |
| 3004 | Rate limit exceeded — daily quota | Yes | Wait until the daily reset time |
4.4 Category 4000: Server and System Errors
| Code | Meaning | Retry? | Action |
|---|---|---|---|
| 4001 | Internal server error | Yes | Retry with backoff; contact support if persistent |
| 4002 | Service temporarily unavailable | Yes | Retry after a short delay |
| 4003 | Gateway timeout | Yes | Retry with increased timeout |
5. Developer Experience Benefits of Unified Error Codes
The architectural choice to use 3001 instead of 429 is not just a technical nuance — it translates to concrete developer experience improvements.
5.1 One Parser for All Responses
With a unified error code system, your client library can handle every response through a single code path:
def handle_response(response_json: dict) -> dict:
"""
Unified handler for all TickDB responses.
Success and error paths follow the same schema.
"""
code = response_json.get("code", 0)
message = response_json.get("message", "")
data = response_json.get("data")
if code == 0:
return data # Success
# All errors follow the same handling interface
error = TickDBError(code=code, message=message)
error_context = data # Error-specific metadata lives here
if 3001 <= code <= 3004:
raise RateLimitError(
message=message,
retry_after=error_context.get("retry_after"),
limit_type=error_context.get("limit_type")
)
elif 1000 <= code < 2000:
raise AuthenticationError(message=message)
elif 2000 <= code < 3000:
raise ResourceError(message=message)
else:
raise TickDBError(message=message)
You never need to branch on http_response.status_code for some errors and json_body.code for others. The code field is the single source of truth for every response.
5.2 Structured Error Metadata Enables Intelligent Clients
When rate-limit errors carry structured metadata, clients can make intelligent decisions:
# A smart rate-limit handler that adapts to error context
def smart_retry(error: RateLimitError):
if error.limit_type == "daily":
# Daily quota exhausted — no point retrying until tomorrow
schedule_next_attempt(hour=0, minute=0) # Midnight UTC
elif error.limit_type == "burst":
# Burst quota — wait and retry within the same window
time.sleep(error.retry_after)
else:
# General rate limit — exponential backoff
backoff_with_jitter(error.retry_after)
This level of context-aware error handling is only possible when errors carry structured metadata rather than a single HTTP status code.
5.3 Easier Debugging and Monitoring
When you aggregate error logs across thousands of requests, having a numeric code makes filtering and analysis trivial:
# Logging and monitoring
error_counts = defaultdict(int)
for log_entry in error_logs:
code = log_entry["code"]
error_counts[code] += 1
# Output: {3001: 1523, 2002: 87, 1001: 3}
# Instantly identify that 3001 (rate limits) dominate the error rate
# and investigate whether the client needs quota optimization
A system where errors are scattered across HTTP status codes (200, 400, 401, 403, 429, 500, 502, 504) and application codes creates fragmentation in your monitoring pipeline. A unified code system keeps everything in one dimension.
6. When to Use HTTP 429 vs. Application Code 3001
It is worth clarifying that HTTP 429 is not wrong — it is simply a different tool for a different context. Here is how to think about the choice:
| Scenario | Use HTTP 429 | Use Application Code 3001 |
|---|---|---|
| API Gateway-level blocking (IP-based, before app logic) | Yes | No |
| Per-endpoint, per-symbol, per-plan quota enforcement at application layer | No | Yes |
| Building a client library that handles all errors uniformly | No | Yes |
| Need structured error metadata with context | No | Yes |
| Need to distinguish between burst, daily, and general rate limits | No | Yes |
| Public REST API where HTTP status must convey transport-level outcomes | Yes (or 200 + application code in body) | Optional |
For a market data API like TickDB, where precision, structured error metadata, and client-side intelligence matter, application-layer error codes are the correct choice. HTTP 429 is a blunt instrument. 3001 is a scalpel.
7. Best Practices for Handling TickDB Error Codes
7.1 In Production Systems
import logging
from typing import Optional
logger = logging.getLogger("tickdb_client")
class TickDBClient:
def __init__(self, api_key: str, base_url: str = "https://api.tickdb.ai"):
self.api_key = api_key
self.base_url = base_url
def _make_request(self, endpoint: str, params: dict) -> Optional[dict]:
"""Make a request with full error handling."""
headers = {"X-API-Key": self.api_key}
url = f"{self.base_url}{endpoint}"
try:
response = requests.get(url, headers=headers, params=params, timeout=(3.05, 10))
result = response.json()
code = result.get("code", 0)
if code == 0:
return result.get("data")
# Category 1000: Auth errors — fail immediately
if code in (1001, 1002, 1003):
logger.error(f"Authentication error {code}: {result.get('message')}")
raise AuthenticationError(code, result.get("message"))
# Category 2000: Resource errors — fail immediately
if 2000 <= code < 3000:
logger.warning(f"Resource error {code}: {result.get('message')}")
raise ResourceError(code, result.get("message"), params)
# Category 3000: Rate limit — retry with backoff
if 3001 <= code <= 3004:
retry_data = result.get("data", {})
retry_after = retry_data.get("retry_after", 5)
logger.info(f"Rate limited (code {code}). Retrying in {retry_after}s")
time.sleep(retry_after)
return self._make_request(endpoint, params) # Recursive retry
# Category 4000: Server errors — retry with backoff
if code >= 4000:
logger.warning(f"Server error {code}. Retrying...")
time.sleep(2 ** attempt)
return self._make_request(endpoint, params)
except requests.exceptions.Timeout:
logger.error("Request timed out")
raise
except requests.exceptions.ConnectionError:
logger.error("Connection failed — check network")
raise
7.2 In Testing and Development
import pytest
def test_rate_limit_error_structure():
"""Verify that 3001 errors include retry metadata."""
response = simulate_rate_limit_response()
assert response["code"] == 3001
assert "retry_after" in response["data"]
assert isinstance(response["data"]["retry_after"], int)
assert response["data"]["retry_after"] > 0
def test_auth_error_raises():
"""Verify that 1001 errors are not retried."""
with pytest.raises(AuthenticationError):
client._make_request("/v1/market/kline", {"symbol": "INVALID.SYM"})
8. Summary: Why the Distinction Matters
The choice between HTTP 429 and application code 3001 is not pedantry. It reflects a fundamental architectural decision about where error handling lives and how much context it carries.
| Aspect | HTTP 429 | TickDB 3001 |
|---|---|---|
| Layer | Transport | Application |
| Granularity | Single concept: "too many requests" | Multiple variants: general, endpoint, burst, daily |
| Metadata | Limited to Retry-After header | Rich JSON with limit_type, current_usage, window_seconds |
| Schema | Inconsistent across errors | Uniform {code, message, data} structure |
| Client intelligence | Requires guesswork | Enables context-aware handling |
| Debugging | Scattered across HTTP codes and body content | Single dimension: the code field |
For developers building trading systems, financial analytics pipelines, or real-time monitoring tools, the extra context in a 3001 response is not a luxury — it is the difference between a client that waits blindly for an arbitrary duration and one that knows exactly when the quota window resets.
When you next encounter a 3001 error, do not treat it as an anomaly. Treat it as TickDB giving you a detailed report on the state of your quota — and the information you need to retry intelligently.
Next Steps
If you are building a market data integration and want to handle errors correctly from day one, the best starting point is to review the full TickDB API documentation and test your error handling against the /v1/market/kline endpoint using the free tier.
If you want to see the unified error code system in action, clone the tickdb-python-client repository on GitHub — the production-grade retry handler in this article is a simplified version of the library's built-in error management layer.
If you are debugging a 3001 error right now, check your current usage against your plan limits in the TickDB dashboard. The rate limit you are hitting may be a signal that it is time to upgrade — or simply that your request batching strategy needs optimization.
This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results.