shiftlabs Start free
Menu Rate limits and retries

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-After seconds 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-After says 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-After is 1. A change may have been saved, and the answer's hint says 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.

start-run.ts
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);
  • 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 shiftlabs client takes idempotencyKey (idempotency_key in Python), and sends the call again by itself after a network error or a short 503, up to twice.
  • No answer carries RateLimit or X-RateLimit headers. Retry-After is 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.

add-contact.ts
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();
      },
    );
  },
);
  • 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 fetch with 429 itself. The rate for one host frees up within a minute, so the retry above rides it out. See At the gateway.

Check it worked

shellcurl
# 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 every 429 and 503 answer.

  • Limits

    Every limit on each plan, and the request rates.

  • Durable steps

    retries, timeout and FatalError.

  • Idempotency keys

    step.idempotencyKey for the APIs your steps call.