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.
A client that does it for you
Section titled “A client that does it for you”All the guides’ samples use this client. It’s short enough to copy into your connector.
"""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 jsonimport osimport sysimport timeimport urllib.errorimport urllib.parseimport 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)// A small client for the Slflo integration API, with the rules every connector needs:// 401 stops the job; 429 waits Retry-After; 5xx, a lost connection or REQUEST_IN_PROGRESS// backs off and retries with the same Idempotency-Key; any other 4xx is an error to fix.using System.Net;using System.Net.Http.Headers;using System.Text;using System.Text.Json;using System.Text.Json.Nodes;
namespace Slflo.Samples;
/// <summary>401: TOKEN_INVALID, TOKEN_EXPIRED, TOKEN_REVOKED or COMPANY_SUSPENDED. Stop and alert.</summary>public sealed class TokenRefusedException(string code, string message) : Exception($"{code}: {message}"){ public string Code { get; } = code;}
/// <summary>A refusal to fix on your side, or a call that kept failing.</summary>public sealed class ApiErrorException(int status, JsonNode? error) : Exception($"{status} {error?["code"]}: {error?["message"]}"){ public int Status { get; } = status; public string Code { get; } = error?["code"]?.GetValue<string>() ?? ""; public JsonNode? Error { get; } = error;}
public sealed class SlfloClient{ private readonly HttpClient _http; private readonly int _maxAttempts; private readonly TimeSpan _firstBackOff;
public SlfloClient(string token, string baseUrl, int maxAttempts = 6, TimeSpan? firstBackOff = null) { _http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"), Timeout = TimeSpan.FromSeconds(30) }; _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _maxAttempts = maxAttempts; _firstBackOff = firstBackOff ?? TimeSpan.FromSeconds(1); }
/// <summary>From SLFLO_TOKEN and SLFLO_BASE (default https://api.slflo.com/api/integration/v1).</summary> public static SlfloClient FromEnvironment() => new( Environment.GetEnvironmentVariable("SLFLO_TOKEN") ?? throw new InvalidOperationException("Set SLFLO_TOKEN"), Environment.GetEnvironmentVariable("SLFLO_BASE") ?? "https://api.slflo.com/api/integration/v1");
public Task<JsonNode> GetAsync(string path, IDictionary<string, object>? query = null) => SendAsync(HttpMethod.Get, path, query, null, null);
public Task<JsonNode> PostAsync(string path, object body, string idempotencyKey) => SendAsync(HttpMethod.Post, path, null, body, idempotencyKey);
public Task<JsonNode> PutAsync(string path, object body, string idempotencyKey, IDictionary<string, object>? query = null) => SendAsync(HttpMethod.Put, path, query, body, idempotencyKey);
private async Task<JsonNode> SendAsync(HttpMethod method, string path, IDictionary<string, object>? query, object? body, string? key) { var url = path.TrimStart('/') + (query is null ? "" : "?" + string.Join("&", query.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(Convert.ToString(p.Value, System.Globalization.CultureInfo.InvariantCulture)!)}"))); var json = body is null ? null : JsonSerializer.Serialize(body); var backOff = _firstBackOff; for (var attempt = 1; ; attempt++) { var last = attempt == _maxAttempts; using var request = new HttpRequestMessage(method, url); if (json is not null) request.Content = new StringContent(json, Encoding.UTF8, "application/json"); if (key is not null) request.Headers.Add("Idempotency-Key", key);
HttpResponseMessage response; try { response = await _http.SendAsync(request); } catch (Exception lost) when (lost is HttpRequestException or TaskCanceledException && !last) { // The answer was lost; the retry carries the same Idempotency-Key, so it can't apply twice. Console.Error.WriteLine($"slflo: {method} {path}: {lost.Message}, backing off {backOff.TotalSeconds}s"); await Task.Delay(backOff); backOff *= 2; continue; }
using (response) { var text = await response.Content.ReadAsStringAsync(); var status = (int)response.StatusCode; if (response.IsSuccessStatusCode) return JsonNode.Parse(text)!;
var error = TryParse(text)?["error"]; var code = error?["code"]?.GetValue<string>() ?? ""; var retryAfter = response.Headers.RetryAfter?.Delta; if (response.StatusCode == HttpStatusCode.Unauthorized) throw new TokenRefusedException(code, error?["message"]?.GetValue<string>() ?? ""); if (response.StatusCode == HttpStatusCode.TooManyRequests && !last) { var wait = retryAfter ?? backOff; Console.Error.WriteLine($"slflo: {method} {path}: 429 {code}, waiting {wait.TotalSeconds}s (Retry-After)"); await Task.Delay(wait); continue; } if ((status >= 500 || code == "REQUEST_IN_PROGRESS") && !last) { var wait = retryAfter ?? backOff; Console.Error.WriteLine($"slflo: {method} {path}: {status} {code}, backing off {wait.TotalSeconds}s"); await Task.Delay(wait); backOff *= 2; continue; } throw new ApiErrorException(status, error); } } }
private static JsonNode? TryParse(string text) { try { return JsonNode.Parse(text); } catch (JsonException) { return null; } // a gateway's HTML }}Handling 429
Section titled “Handling 429”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
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.
Rotating a token with no downtime
Section titled “Rotating a token with no downtime”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:
-
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.
-
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 = Noneif expires_at:days_left = (date.fromisoformat(expires_at[:10]) - datetime.now(timezone.utc).date()).daysif 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_prefixis the start of the new token, so you can tell which one the connector uses. -
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.
If a run is cut off by a revoked token
Section titled “If a run is cut off by a revoked token”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.
When it goes wrong
Section titled “When it goes wrong”| 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 |

