Article
Rate Limits, Retry Logic, and Error Handling with Smartsheet API
Link copied
Mastering Smartsheet API series — part 4
This post is part of the Mastering Smartsheet API series, a practical companion to the Smartsheet API documentation.
New learning blogs drop regularly — join the API & Developers group in the Smartsheet Community and click Follow → Include in Email Digest to get each new blogpost delivered straight to your inbox via the weekly digest.
Your integration works in staging. You test it, it passes, you deploy. Two weeks later, a bulk sync job starts failing silently. The logs show a wall of 429s, and because there's no retry logic, every failed request just disappears.
Rate limits, transient failures, and unhandled errors are the most common reasons production integrations degrade quietly. The fix isn't complicated, but it requires deliberate design. This post covers how the Smartsheet API signals problems, how to tell the difference between errors you can recover from automatically and errors that require human intervention, and the patterns (retry, backoff, circuit breaker) that keep your integration running under load.
How Smartsheet signals problems
Every API response includes two layers of error information: the HTTP status code and, for unsuccessful requests, a Smartsheet-specific error code in the response body.
JSON
"message": "Rate limit exceeded."
}
The HTTP status code tells you the category of the problem. The Smartsheet error code tells you exactly what happened. You need both to handle errors correctly.
The status codes that matter in production:
HTTP status | Meaning | Retryable? |
|---|---|---|
200 | Success | — |
400 | Bad request — malformed input | No |
401 | Authorization failed | Depends on error code |
403 | Authorization failed | No |
404 | Resource not found | No |
429 | Rate limit exceeded | Yes |
500 | Internal server error | Yes (with backoff) |
503 | Service unavailable | Yes (with backoff) |
The retryable vs. non-retryable distinction is the most important thing to get right. Retrying a 400 or 403 wastes resources and can mask bugs. Not retrying a 429 or 503 causes unnecessary failures.
Rate limits
The Smartsheet API enforces rate limits per access token per minute. The limits depend on the operation:
Operation | Rate limit |
|---|---|
Most API requests | 300 requests per minute per token |
Posting file attachments | 30 requests per minute per token (counts as 10x) |
Getting cell history | 30 requests per minute per token (counts as 10x) |
The limit is per token, not per application. If you're running parallel workers that share a single token, they all count against the same limit. With five parallel workers making requests at full speed, you'll hit the ceiling in seconds.
File attachments and cell history have their own lower limit. Each of these operations counts as 10 requests against the rate limit, giving them an effective cap of 30 per minute. A tight loop calling cell history will hit the limit far faster than you expect — plan capacity accordingly.
The window is one minute. When you exceed the limit, subsequent requests within that one-minute window return 429 with error code 4003. Smartsheet documentation recommends sleeping for a minimum of 60 seconds before retrying.
Retry logic
When you hit a 429, the safest approach is to sleep for 60 seconds and retry. For transient server errors (500, 503), retry with exponential backoff:
PYTHON
import time
import requests
def api_request_with_retry(url, headers, max_retries=5):
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 429:
# Smartsheet recommends a minimum of 60 seconds on rate limit
wait_seconds = 60
print(
f"Rate limited. Waiting {wait_seconds}s before retry "
f"(attempt {attempt + 1}/{max_retries})."
)
time.sleep(wait_seconds)
continue
if response.status_code in (500,503):
# Transient server error — retry with backoff
wait_seconds = 2 ** attempt
print(
f"Server error {response.status_code}. "
f"Retrying in {wait_seconds}s (attempt {attempt + 1}/{max_retries})."
)
time.sleep(wait_seconds)
continue
# Non-retryable errors — raise immediately
response.raise_for_status()
return response.json()
raise RuntimeError(f"Request failed after {max_retries} retries.")
Exponential backoff
A fixed 60-second sleep handles rate limits reliably. But for transient server errors under sustained load, exponential backoff is more robust. If multiple workers all sleep for the same fixed duration and retry at the same moment, they can cause the same problem on a loop — the "thundering herd" effect.
Exponential backoff spreads retries out over time. Adding jitter, a small random offset, breaks the synchronization between workers:
PYTHON
import time
import random
import requests
def api_request_with_backoff(url, headers, max_retries=5, base_delay=1.0):
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 429:
# Fixed 60s sleep for rate limits, plus jitter to desynchronize workers
wait_seconds = 60 + random.uniform(0,5)
print(f"Rate limited. Waiting {wait_seconds:.1f}s (attempt {attempt + 1}/{max_retries}).")
time.sleep(wait_seconds)
continue
if response.status_code in (500,503):
# Exponential backoff with jitter for transient server errors
wait_seconds = min(base_delay * (2 ** attempt) + random.uniform(0, 1), 60)
print(f"Server error. Waiting {wait_seconds:.1f}s (attempt {attempt + 1}/{max_retries})."
time.sleep(wait_seconds)
continue
response.raise_for_status()
return response.json()
raise RuntimeError(f"Request failed after {max_retries} retries.")
The min(..., 60) cap prevents the wait from growing unbounded on sustained failures. For most integrations, a base_delay of 1 second and 5 retries is a reasonable starting point.
Permanent vs. transient errors
Not every error should be retried. Retrying a permanent error wastes time and can loop indefinitely. The classification is straightforward:
PYTHON
def classify_error(status_code):
"""
Returns (should_retry, reason).
Permanent errors should surface immediately — retrying won't help.
Transient errors can be recovered automatically.
"""
permanent_errors = {
400: "Malformed request — fix the input before retrying.",
401: "Authentication failure — check token validity.",
403: "Authorization failure — check permissions.",
404: "Resource not found — verify the ID.",
}
transient_errors = {
429: "Rate limit exceeded — sleep 60s and retry.",
500: "Internal server error — retry with backoff.",
503: "Service unavailable — retry with backoff.",
}
if status_code in permanent_errors:
return False, permanent_errors[status_code]
elif status_code in transient_errors:
return True, transient_errors[status_code]
else:
return False, f"Unknown status code {status_code} — treat as permanent."
The practical rule: if the same request would fail again for the same reason without any change on your side, don't retry it. 400 (bad input), 403 (wrong permissions), 404 (wrong ID) — these require intervention, not retries.
Circuit breaker
Retry logic handles individual failures. A circuit breaker handles sustained failures — when the API is consistently unavailable and retrying every request is making things worse.
The pattern works like an electrical circuit breaker: after a threshold of consecutive failures, the breaker "opens" and stops sending requests for a cooldown period. After the cooldown, it allows a single test request through. If that succeeds, the breaker "closes" and normal operation resumes.
PYTHON
import time
class CircuitBreaker:
def __init__(self, failure_threshold=5, cooldown_seconds=60):
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self.failure_count = 0
self.last_failure_time = None
self.state = "closed" # closed = normal, open = blocking requests
def call(self, func, *args, **kwargs):
if self.state == "open":
elapsed = time.time() - self.last_failure_time
if elapsed < self.cooldown_seconds:
raise RuntimeError(
f"Circuit breaker open. Retry in "
f"{self.cooldown_seconds - elapsed:.0f}s."
)
else:
# Cooldown expired — allow one test request through
self.state = "half-open"
try:
result = func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure()
raise e
def _on_success(self):
self.failure_count = 0
self.state = "closed"
def _on_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = "open"
print(
f"Circuit breaker opened after {self.failure_count} consecutive failures. "
f"Cooldown: {self.cooldown_seconds}s."
)
Usage:
PYTHON
import requests
breaker = CircuitBreaker(failure_threshold=5, cooldown_seconds=60)
def fetch_sheet(sheet_id, token):
response = requests.get(
f"https://api.smartsheet.com/2.0/sheets/{sheet_id}",
headers={"Authorization": f"Bearer {token}"}
)
response.raise_for_status()
return response.json()
# Wrap the call with the circuit breaker
sheet = breaker.call(fetch_sheet, sheet_id="YOUR_SHEET_ID", token="YOUR_TOKEN")
The circuit breaker is most useful in integrations that run continuously — scheduled sync jobs, webhook processors, or anything where a sustained API outage could cause a cascade of failures.
Putting it together
In production, retry logic, backoff, and circuit breaking work as layers. The key is keeping the concerns separate: the circuit breaker decides whether to attempt a request at all; the retry logic decides whether to attempt it again after a failure; backoff controls the wait between attempts.
PYTHON
import time
import random
import requests
class SmartsheetClient:
def __init__(self, token, max_retries=5, base_delay=1.0, failure_threshold=5):
self.token = token
self.headers = {"Authorization": f"Bearer {token}"}
self.max_retries = max_retries
self.base_delay = base_delay
self.breaker = CircuitBreaker(
failure_threshold=failure_threshold,
cooldown_seconds=60
)
def request(self, method, url, **kwargs):
for attempt in range(self.max_retries):
try:
# Circuit breaker decides whether to send the request at all
response = self.breaker.call(
requests.request, method, url, headers=self.headers, **kwargs
)
except RuntimeError as e:
# Circuit breaker is open — surface immediately, don't retry
raise
# Handle rate limits: fixed 60s sleep, then retry
if response.status_code == 429:
wait_seconds = 60 + random.uniform(0, 5)
print(f"Rate limited. Waiting {wait_seconds:.1f}s (attempt {attempt + 1}/{self.max_retries}).")
time.sleep(wait_seconds)
continue
# Handle transient server errors: exponential backoff, then retry
if response.status_code in (500, 503):
wait_seconds = min(self.base_delay * (2 ** attempt) + random.uniform(0, 1), 60)
print(f"Server error {response.status_code}. Waiting {wait_seconds:.1f}s.")
time.sleep(wait_seconds)
continue
# Permanent errors: raise immediately — retrying won't help
response.raise_for_status()
return response.json()
raise RuntimeError(f"Request to {url} failed after {self.max_retries} retries.")
The production resilience checklist
Before you go to production:
Rate limit handling:
- 429 responses trigger a minimum 60-second sleep before retry
- File attachments and cell history planned at 30 requests/min cap, not 300
- Parallel workers use separate tokens if request volume warrants it
Retry logic:
- Permanent errors (400, 401, 403, 404) are not retried
- Transient errors (429, 500, 503) are retried with appropriate wait
- Maximum retry count is bounded — no infinite retry loops
Backoff:
- 429: fixed 60s sleep with small jitter to desynchronize workers
- 500/503: exponential backoff with jitter, capped at 60s
- Maximum wait capped to prevent excessive delays on sustained failures
Circuit breaker:
- Failure threshold and cooldown configured for your integration's volume
- Circuit breaker state is logged — open/closed transitions are observable
- Half-open state allows recovery without manual intervention
What's next
Part 5 covers bulk operations — how to update hundreds or thousands of rows efficiently without hitting rate limits, and the patterns that make high-volume integrations fast and reliable.
Catch up on the series
- Part 1 — Zero to API: Your First Successful Smartsheet Integration in 30 Minutes
- Part 2 — The Smartsheet Data Model: What Every Developer Needs to Know
Part 3 — Authentication Mastery with Smartsheet API
New learning blogs drop regularly — join the API & Developers group in the Smartsheet Community and click Follow → Include in Email Digest to get each new blogpost delivered straight to your inbox via the weekly digest.
This post is part of the Mastering Smartsheet API series, designed as a practical companion to the Smartsheet API documentation. For questions, visit the Smartsheet Community or the developer forum.
Link copied