Article API Rate Limiting Causing Incomplete Data Exports

If you're pulling large datasets via the API and noticing your exports are mysteriously truncating — yeah, this one tripped me up too when I first looked at it. The symptoms look like a successful request (you get a 200, maybe even a full-looking JSON payload), but then you realize you're missing thousands of records.

Summary

API rate limiting is causing large data exports to terminate early, returning partial datasets without clear error signaling.

Symptoms

  • Export scripts report "success" but row counts don't match expected totals
  • Last page of paginated results returns fewer records than page_size, with no next URL
  • Response headers show X-RateLimit-Remaining: 0 but status code remains 200
  • Same export run twice produces different record counts

Affected Versions

FieldPulse API v2.0 and v2.1. Particularly impacts endpoints with high data density:

  • GET /work_orders with expanded customer data
  • GET /customers with custom fields included
  • GET /time_entries across date ranges > 90 days

What's Actually Happening

Here's the gotcha: the API has a secondary limit on "complexity points" that isn't surfaced in the standard rate limit headers. When you request heavy expansions (nested customer objects, line items, form responses), you're burning through this budget faster than the standard 1000 req/min limit suggests.

Once you hit the complexity ceiling, the API quietly truncates your response rather than erroring. Not ideal — we're pushing to get this changed to a proper 429 with a Retry-After header, but that's still in review.

Immediate Workaround

Until the fix ships, you'll want to:

# Add explicit throttling between paginated requests
import time

def fetch_all_work_orders():
    results = []
    url = "https://api.fieldpulse.com/v2/work_orders?page_size=50"
    # ^ smaller page size, avoid expansions in initial fetch
    
    while url:
        resp = requests.get(url, headers=auth_headers)
        data = resp.json()
        results.extend(data['results'])
        
        # Force 200ms delay regardless of rate limit headers
        time.sleep(0.2)
        
        # Check for truncation signal: fewer results than page_size
        if len(data['results']) 'next'):
            print(f"Warning: possible truncation at {url}")
            time.sleep(2)  # back off aggressively
            continue  # retry same page
            
        url = data.get('next')
    
    return results

Then fetch related data (customer details, etc.) in a second pass using batch endpoints where possible. Yeah, it's more requests, but each one stays under the complexity radar.

Better Pattern (Recommended)

If you're doing regular large exports, use the API pagination workaround pattern which includes adaptive backoff based on response size. Also consider:

  • Webhook-based sync instead of bulk export for ongoing replication
  • Incremental exports using modified_since parameter
  • Our new bulk export endpoint (beta, /exports/jobs) — async, no rate limit issues, CSV or JSON output

Status

Engineering is tracking this as API-2847. Target behavior: return 429 with Retry-After and a clear error message when complexity limits are hit. No firm release date yet — I'll update this article when I know more.

Hit me up in the comments if you're seeing different behavior or found another pattern that works.

  • Heads up: the async export endpoint Eli mentioned (/exports/jobs) has been working well for us, worth noting it doesn't support custom field expansion yet — you get the job metadata and have to join against customers separately. YMMV depending on your use case.

    Also, we ended up implementing a circuit breaker pattern that watches for that results.length < page_size while next exists condition. Catches the truncation pretty reliably.

  • Thanks for the concrete repro Carlos — mind opening a ticket with that endpoint and your account slug? I want to make sure your org is in the data set when we validate the fix.

    And yeah, totally agree on the 429. The 200-with-truncation thing is a legacy behavior from when the complexity limit was "best effort" rather than hard enforcement. We're fixing the response semantics, just need to be careful not to break anyone who's accidentally relying on the current behavior (even though they shouldn't be).

  • Confirmed. Seeing this on /customers?include_custom_fields=true. 847 customers in DB, 312 returned. No error, just silent truncation.

    curl -v shows:

    X-RateLimit-Limit: 1000
    X-RateLimit-Remaining: 0
    X-RateLimit-Reset: 1741096800

    But status 200 with partial JSON. Should be 429.