Skip to content

Retries, 429 and token rotation

Every answer that isn’t a 2xx tells you what to do next. Every error carries retryable, so you don’t need a table of your own — but the status decides how to retry.

Answer Do
401 (TOKEN_INVALID, TOKEN_EXPIRED, TOKEN_REVOKED, COMPANY_SUSPENDED) Stop the job and alert someone. Retrying won’t help, and Slflo never blocks an address over 401s.
429 RATE_LIMITED Wait the Retry-After seconds, then send the same call again. Not a failure of the record.
500, 502, 503, 504, or no answer at all Back off — 1 s, 2 s, 4 s … — and retry; honour Retry-After when a 503 sends one.
409 REQUEST_IN_PROGRESS The first call with this key is still running. Retry shortly with the same key.
Any other 4xx Don’t retry. Fix the call or the data; the message says what. See error codes.

Retrying a write is always safe because every write carries an Idempotency-Key: if the first call landed and only its answer was lost, the retry gets that first answer back instead of applying twice.

All the guides’ samples use this client. It’s short enough to copy into your connector.

slflo.py
"""A small client for the Slflo integration API, with the rules every connector needs.
- 401: the token can't be used any more. Stop the job and alert; retrying won't help.
- 429: wait the Retry-After seconds, then send the same call again.
- 5xx, a lost connection, or 409 REQUEST_IN_PROGRESS: back off (1 s, 2 s, 4 s ...) and retry.
Every write carries an Idempotency-Key, so a retry can never apply twice.
- Any other 4xx: raise ApiError with the code, message and details; fix the data.
Standard library only (Python 3.8+). Settings come from the environment:
SLFLO_TOKEN your token, sfi_live_... (sfi_test_... on a copy of Slflo you run yourself)
SLFLO_BASE default https://api.slflo.com/api/integration/v1
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_BASE = "https://api.slflo.com/api/integration/v1"
class TokenRefused(Exception):
"""401: TOKEN_INVALID, TOKEN_EXPIRED, TOKEN_REVOKED or COMPANY_SUSPENDED."""
def __init__(self, code, message):
super().__init__(f"{code}: {message}")
self.code = code
class ApiError(Exception):
"""A refusal to fix on your side (or a call that kept failing)."""
def __init__(self, status, error):
self.status = status
self.code = error.get("code", "")
self.details = error.get("details", [])
self.retryable = error.get("retryable", False)
self.request_id = error.get("request_id")
super().__init__(f"{status} {self.code}: {error.get('message', '')}")
class Slflo:
def __init__(self, token=None, base=None, max_attempts=6, first_backoff=1.0, timeout=30):
self.token = token or os.environ["SLFLO_TOKEN"]
self.base = (base or os.environ.get("SLFLO_BASE") or DEFAULT_BASE).rstrip("/")
self.max_attempts = max_attempts
self.first_backoff = first_backoff
self.timeout = timeout
def get(self, path, query=None):
return self.call("GET", path, query=query)
def put(self, path, body, key, query=None):
return self.call("PUT", path, query=query, body=body, key=key)
def post(self, path, body, key):
return self.call("POST", path, body=body, key=key)
def call(self, method, path, query=None, body=None, key=None):
url = self.base + path
if query:
url += "?" + urllib.parse.urlencode(query)
headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/json"}
data = None
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
if key is not None:
headers["Idempotency-Key"] = key
backoff = self.first_backoff
for attempt in range(1, self.max_attempts + 1):
last = attempt == self.max_attempts
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return json.loads(response.read() or b"{}")
except urllib.error.HTTPError as refused:
status, error = refused.code, _error_of(refused)
retry_after = refused.headers.get("Retry-After")
if status == 401:
raise TokenRefused(error.get("code", "TOKEN_INVALID"), error.get("message", ""))
if status == 429:
wait = int(retry_after) if retry_after and retry_after.isdigit() else backoff
_log(f"{method} {path}: 429 {error.get('code', '')}, waiting {wait}s (Retry-After)")
if last:
raise ApiError(status, error)
time.sleep(wait)
continue
if status >= 500 or error.get("code") == "REQUEST_IN_PROGRESS":
if last:
raise ApiError(status, error)
wait = int(retry_after) if retry_after and retry_after.isdigit() else backoff
_log(f"{method} {path}: {status} {error.get('code', '')}, backing off {wait}s")
time.sleep(wait)
backoff *= 2
continue
raise ApiError(status, error)
except OSError as lost: # connection refused or reset, timeout
# The answer was lost: the call may or may not have landed. The retry
# carries the same Idempotency-Key, so it can't apply twice.
if last:
raise
_log(f"{method} {path}: {lost}, backing off {backoff}s")
time.sleep(backoff)
backoff *= 2
raise AssertionError("unreachable")
def pages(self, path, query=None, after=0, per_page=100):
"""Every page of a collection by the version cursor, oldest change first."""
while True:
page = self.get(path, {**(query or {}), "filter[version_after]": after, "per_page": per_page})
yield page
if not page["meta"]["has_more"]:
return
after = page["meta"]["next_version_after"]
def _error_of(refused):
try:
return json.loads(refused.read()).get("error", {})
except ValueError:
return {} # a gateway's HTML or plain-text answer
def _log(message):
print(f"slflo: {message}", file=sys.stderr, flush=True)
def report(summary):
"""Each sample ends by printing one line of JSON: what it did."""
print(json.dumps(summary, sort_keys=True), flush=True)

Each client may make 120 requests a minute by default — GET /me shows yours as rate_limit_per_minute. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Over the limit:

GET /orders?filter[version_after]=18342

Terminal window
curl -X GET 'https://api.slflo.com/api/integration/v1/orders?filter[version_after]=18342' \
-H 'Authorization: Bearer $SLFLO_TOKEN'

Response

HTTP 429
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many calls from this client. Wait 7 seconds (Retry-After) and call again.",
"details": [],
"request_id": "req_01J9X4Q7T2M8P3NRT6V5WZ",
"retryable": true
}
}

The answer also carries Retry-After: 7. The client sleeps that long and sends the call again; you’ll see it in the log:

slflo: GET /me: 429 RATE_LIMITED, waiting 7s (Retry-After)

To meet fewer 429s: throttle a little under your limit, and use batches — POST /batch/…, POST /acks and a 500-row price push each count as one request.

Tokens last 12 months, and you should rotate sooner if one may have leaked. A client holds up to two live tokens, so you can switch without a gap:

  1. Issue the new token. An administrator opens the client in Settings → Integrations and chooses Rotate. A second token is shown once; the old one keeps working.

  2. Put it in your connector’s settings and check it before relying on it:

    check_token.py
    def check(api):
    me = api.get("/me")["data"]
    expires_at = me["client"].get("token_expires_at")
    days_left = None
    if expires_at:
    days_left = (date.fromisoformat(expires_at[:10]) - datetime.now(timezone.utc).date()).days
    if days_left is not None and days_left < WARN_DAYS:
    print(f"warning: this token expires in {days_left} days; rotate it", file=sys.stderr)
    return {
    "ok": True,
    "client": me["client"]["name"],
    "token_prefix": me["client"]["token_prefix"],
    "company": me["company"]["code"],
    "scopes": me["scopes"],
    "days_left": days_left,
    "rate_limit_per_minute": me["client"]["rate_limit_per_minute"],
    }
    Terminal window
    SLFLO_TOKEN='sfi_live_…new…' python3 check_token.py
    # {"client": "D365 production", "days_left": 364, "ok": true, "token_prefix": "sfi_live_9QW2E7RT", …}

    The token_prefix is the start of the new token, so you can tell which one the connector uses.

  3. Revoke the old token in the console. It stops at once: the next call with it answers 401 TOKEN_REVOKED, and a job still holding it stops cleanly ({"stopped_by": "TOKEN_REVOKED"}). Any job already on the new token carries on.

The job stops at the first 401. Anything it created in your ERP but didn’t ack yet is found on the next run with the new token — that’s what find-before-create is for — so nothing is booked twice.

What you see Why What to do
401 TOKEN_INVALID on the first call A typo, a missing Bearer , or a token from a copy of Slflo you run yourself sent to api.slflo.com Compare token_prefix from GET /me with what the console shows
401 TOKEN_EXPIRED Twelve months passed Rotate; add a days_left check
403 IP_NOT_ALLOWED The client has an allow-list and you’re calling from elsewhere Call from a listed address, or ask the administrator to add yours
429 on every call Several jobs share one client and its limit Throttle them together, use batches, or ask us to raise the limit
A retry answered 422 IDEMPOTENCY_KEY_REUSED The retry changed the body under the same key Build the body once, then retry that exact body