Article

Rate Limits, Retry Logic, and Error Handling with Smartsheet API

Rate Limits, Retry Logic, and Error Handling with Smartsheet API

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

{  
 "errorCode": 4003,  
 "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(01), 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

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.

Related Content