On this page
Guides / Errors, idempotency and pagination

Errors, idempotency and pagination

Typed errors, safe retries with idempotency, and how to paginate.

Three things that make any integration robust: understanding the errors, retrying without duplicating, and walking large lists.

Typed errors

Every API error carries a body with type, code, message and a request_id (send it to support if something goes wrong). The SDK maps them to classes:

import { MetRateLimitError, MetAuthError, MetInvalidRequestError } from '@meteor.ia/sdk';

try {
  await met.runs.create({ input: 'x' });
} catch (err) {
  if (err instanceof MetRateLimitError) {
    console.log(`Retry in ${err.retryAfter}s`);
  } else if (err instanceof MetAuthError) {
    console.log('Invalid or revoked key');
  } else if (err instanceof MetInvalidRequestError) {
    console.log(err.code, err.message);
  } else throw err;
}
from meteor_ia import MetRateLimitError, MetAuthError, MetInvalidRequestError

try:
    met.runs.create("x")
except MetRateLimitError as e:
    print(f"Retry in {e.retry_after}s")
except MetAuthError:
    print("Invalid or revoked key")
except MetInvalidRequestError as e:
    print(e.code, e.message)

The taxonomy is a closed set:

typeClassHTTP
authentication_errorMetAuthError401
invalid_request_errorMetInvalidRequestError400
rate_limit_errorMetRateLimitError429
agent_errorMetAgentError5xx from the Met
api_errorMetApiErrorothers

When something fails: look at your own requests

Every error carries a request_id, and that id is searchable. You don't have to guess what happened or open a ticket to find out.

try {
  await met.contacts.create({ name: 'Ada' });
} catch (err) {
  console.error(err.requestId);   // req_01J8… ← save this
}
try:
    met.contacts.create(name="Ada")
except Exception as e:
    print(e.request_id)   # req_01J8… ← save this
curl -i -X POST https://api.met.meteor.com.co/api/v1/contacts \
  -H "Authorization: Bearer $MET_API_KEY" \
  -H "Content-Type: application/json" -d '{}'
# The id comes in the body and in the X-Request-Id header

With that id, go into your Workbench at Settings → Developers → Activity and you get:

tabwhat it tells you
Logsevery request from your workspace, filterable by key, by status class (2xx/4xx/5xx) and by request_id. Each row expands and shows the bodies — the error ones include the detail that caused the 400
Summaryvolume, error distribution and p95 latency, over 24 h / 7 d / 30 d
Healthrecent errors grouped by code, plus the per-key quota alerts (80% / 100%)
Webhooksoutbound deliveries and the attempt log per endpoint

It is the short path: you paste the request_id into Logs and see exactly what you sent and what we answered. If you still need to write to us, send us that id — it is the first thing we are going to ask for.

The Workbench is served with your panel session, never by API key, and only shows your own workspace's traffic.

Retries and idempotency

The SDK retries on its own for 429 and 5xx with exponential backoff (honoring Retry-After). You don't have to reimplement it.

So that retrying a POST doesn't duplicate — for example, creating two runs because of a network blip — the SDK sends an automatic Idempotency-Key on every POST with side effects. If the request is resent with the same key, the API returns the same result without running it again.

// Both calls share automatic idempotency; a retry does not create two runs.
await met.runs.create({ input: 'x' });

With curl, send the header yourself:

-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000"

Pagination

There are two ways to paginate in the API, and it is worth knowing which one applies to you — not because the code changes much, but because using the wrong one doesn't raise an error.

By cursor

Runs, contacts and the items of a collection return { data, has_more } with an opaque cursor. The SDK walks it for you:

for await (const run of met.runs.iterate()) {
  // ...every run, every page
}
for run in met.runs.iterate():
    ...

If you'd rather drive the cursor by hand, list() accepts limit and starting_after:

const page = await met.runs.list({ limit: 20 });
if (page.has_more) {
  const next = await met.runs.list({ limit: 20, starting_after: page.data.at(-1).id });
}

This is the stable mode: even if new records come in while you walk, nothing gets duplicated or skipped.

By offset

Billing, Media library, events, webhook deliveries and item search paginate with limit + offset (search, with limit + page). Each has its own iterator:

for await (const e of met.billing.iterateExecutions()) { /* ... */ }
for await (const a of met.assets.iterate({ mime_prefix: 'image/' })) { /* ... */ }
for await (const it of met.items.iterateSearch('invoice')) { /* ... */ }
for e in met.billing.iterate_executions():
    ...
for a in met.assets.iterate(mime_prefix="image/"):
    ...

What you need to know: sending starting_after to one of these endpoints does not fail. The parameter is ignored, it answers 200 and you get the first page over and over — an infinite loop that looks like it works. If you write the pagination by hand, check in the reference which parameters the endpoint accepts.

And offset is not stable: if something enters or leaves the set while you walk it, a row can show up twice or not at all. That is a limitation of the endpoint. For an exact sweep over data that moves, the cursor one is what you want.