Rate limits and retries
Start runs from your own code so that a retry after a 429, a 503 or a dropped connection never starts a second run, and let a step wait out another API's rate limit.
What you'll build
A function that starts a run of refund-check and retries what is worth retrying, with an Idempotency-Key so a retry can't start a second run. Then a workflow step that calls an API with its own rate limit and retries a 429 from it.
Read the answer
What a refused call tells you, and whether to send it again.
429 {"error": "rate limited"}Too many requests a minute or per 10 seconds. Wait
Retry-Afterseconds and send it again. See Request rates.429 {"error": "quota exceeded", "limit", "max"}A plan limit, such as runs per 24 hours or runs at once.
Retry-Aftersays when to try again, which can be hours away. See Limits by plan.429 {"error": "spending limit reached"}or{"error": "payment failed"}Pro past its spending limit, or with a failing payment, with
Retry-After: 3600. Past the limit, new runs start again when an admin raises it or a new billing month starts; with a failing payment, when the payment goes through. See Plans and billing.503 {"error": "temporarily unavailable"}shiftlabs couldn't reach the workspace's storage for a moment.
Retry-Afteris 1. A change may have been saved, and the answer'shintsays so.503 {"error": "runs paused"}shiftlabs has paused new runs across the platform. Wait
Retry-After.- No answer
A network error or a timeout. The run may have started. Send it again with the same
Idempotency-Key.
Start runs that retry safely
Send an Idempotency-Key with every run you start: an id from your own system, such as an order or charge id. The same key within 24 hours returns the first run, with the Idempotent-Replayed: true header, instead of starting another. So every retry can send the same request.
const API = "https://api.shiftlabs.org/v1";
const headers = {
authorization: `Bearer ${process.env.SHIFTLABS_API_KEY}`,
"content-type": "application/json",
};
// The same Idempotency-Key on every attempt: a retry never starts a second
// run.
async function startRun(workflow: string, payload: unknown, key: string) {
for (let attempt = 1; attempt <= 5; attempt++) {
const r = await fetch(`${API}/workflows/${workflow}/runs`, {
method: "POST",
headers: { ...headers, "idempotency-key": key },
body: JSON.stringify(payload),
}).catch(() => null);
if (r?.ok) return (await r.json()) as { runId: string };
if (r && r.status !== 429 && r.status !== 503) {
throw new Error(`${r.status} ${await r.text()}`);
}
const wait = Number(r?.headers.get("retry-after") ?? 1);
// A quota or the spending limit: an answer for a person, not a retry.
if (wait > 60) throw new Error(`${r?.status} ${await r?.text()}`);
// Clients sharing a budget spread out by adding up to a second.
await new Promise((ok) => setTimeout(ok, (wait + Math.random()) * 1000));
}
throw new Error(`${workflow}: still refused after 5 attempts`);
}
const { runId } = await startRun("refund-check", { id: "ch_123" }, "ch_123");
console.log(runId);
import json
import os
import random
import time
import urllib.error
import urllib.request
API = "https://api.shiftlabs.org/v1"
KEY = os.environ["SHIFTLABS_API_KEY"]
def start_run(workflow: str, payload: object, key: str) -> dict:
"""The same Idempotency-Key on every attempt: a retry never starts a
second run."""
for _ in range(5):
request = urllib.request.Request(
f"{API}/workflows/{workflow}/runs",
data=json.dumps(payload).encode(),
headers={
"authorization": f"Bearer {KEY}",
"content-type": "application/json",
"idempotency-key": key,
},
)
try:
with urllib.request.urlopen(request, timeout=30) as r:
return json.load(r)
except urllib.error.HTTPError as err:
if err.code not in (429, 503):
raise
wait = int(err.headers.get("retry-after", "1"))
# A quota or the spending limit: an answer for a person.
if wait > 60:
raise
except (urllib.error.URLError, TimeoutError):
wait = 1
# Clients sharing a budget spread out by adding up to a second.
time.sleep(wait + random.random())
raise RuntimeError(f"{workflow}: still refused after 5 attempts")
print(start_run("refund-check", {"id": "ch_123"}, "ch_123")["runId"])
- A key is 1 to 255 visible ASCII characters. Another value gets
400. - A run started with a key the store still holds is answered even once the day's runs are used up, so a retry learns the run it started.
- The
shiftlabsclient takesidempotencyKey(idempotency_keyin Python), and sends the call again by itself after a network error or a short503, up to twice. - No answer carries
RateLimitorX-RateLimitheaders.Retry-Afteris the signal.
Retry another API from a step
A step that throws is tried again after its delay, with the same version of your code. Throw an Error for answers worth retrying, such as 429 and 5xx, and a FatalError for the rest, which fails the run at once. Send step.idempotencyKey(name) to an API that takes one, so it does the work once however often the step runs.
import { FatalError, workflow } from "shiftlabs";
export default workflow<{ email: string }>(
async ({ payload, step, secrets }) => {
return step.run(
"add contact",
{ retries: { limit: 8, delay: "30 seconds", backoff: "exponential" } },
async () => {
const r = await fetch("https://api.example.com/contacts", {
method: "POST",
headers: {
authorization: `Bearer ${secrets.CRM_KEY}`,
"content-type": "application/json",
"idempotency-key": step.idempotencyKey("add contact"),
},
body: JSON.stringify({ email: payload.email }),
});
// Worth another try after the step's delay.
if (r.status === 429 || r.status >= 500) {
throw new Error(`contacts answered ${r.status}`);
}
// Won't change on a retry.
if (!r.ok) throw new FatalError(`contacts answered ${r.status}`);
return r.json();
},
);
},
);
import json
from workers import fetch
from shiftlabs import FatalError, workflow
@workflow
async def add_contact(ctx):
async def attempt():
r = await fetch(
"https://api.example.com/contacts",
method="POST",
headers={
"authorization": f"Bearer {ctx.secrets.CRM_KEY}",
"content-type": "application/json",
"idempotency-key": ctx.step.idempotency_key("add contact"),
},
body=json.dumps({"email": ctx.payload["email"]}),
)
# Worth another try after the step's delay.
if r.status == 429 or r.status >= 500:
raise Exception(f"contacts answered {r.status}")
# Won't change on a retry.
if not r.ok:
raise FatalError(f"contacts answered {r.status}")
return await r.json()
return await ctx.step.run(
"add contact",
attempt,
retries={"limit": 8, "delay": "30 seconds", "backoff": "exponential"},
)
- Exponential backoff doubles the delay each time: here 30 seconds, then 1, 2, 4 minutes and on. Without
retries, a step is tried 5 more times, 10 seconds apart and doubling. - Retries don't count toward the run's steps, and waiting between them uses no CPU time. The whole run still has to end within its lifetime: 168 hours on Free and Pro.
- Your workflow's own requests have limits too: 30 a minute to one host, and outbound requests per 24 hours. Past either, the gateway answers your
fetchwith429itself. The rate for one host frees up within a minute, so the retry above rides it out. See At the gateway.
Check it worked
# Twice with the same key: both answer 202 with the same runId, and the
# second has the header Idempotent-Replayed: true.
for i in 1 2; do
curl -si -X POST https://api.shiftlabs.org/v1/workflows/refund-check/runs \
-H "authorization: Bearer $SHIFTLABS_API_KEY" \
-H "content-type: application/json" \
-H "idempotency-key: ch_123" \
-d '{"id": "ch_123"}'
done
# One run, not two.
curl "https://api.shiftlabs.org/v1/runs?workflow=refund-check&limit=5" \
-H "authorization: Bearer $SHIFTLABS_API_KEY"
For add-contact, the run's timeline shows each failed attempt of add contact with its error, and the attempt that succeeded. Follow it at GET /v1/runs/:id/timeline.
Reference
- Rate limits
Every request rate with its
Retry-After, and every429and503answer. - Limits
Every limit on each plan, and the request rates.
- Durable steps
retries,timeoutandFatalError. - Idempotency keys
step.idempotencyKeyfor the APIs your steps call.