ChangelogEdit pageSTAFFOpen dashboard

Idempotency

Send an Idempotency-Key header with any change, and if you retry after a timeout or a dropped connection, Bubbl replays the first result instead of making the change again.

A request can time out after Bubbl has done the work but before you get the answer. Retrying it blindly might do the work twice. An idempotency key makes a retry safe: Bubbl remembers the result of the first request and returns it again for any retry with the same key.

Where it works#

Every request that changes something accepts an Idempotency-Key header: every POST, PATCH and DELETE, such as creating a campaign, adding a notification, publishing, pausing or importing locations. GET requests don't change anything, so they don't need one.

Send a key#

Make up a unique value for each change, such as a UUID, and send it in the Idempotency-Key header. Keep the same value for every retry of that change. The examples below import locations, but it works the same way for any change.

CURL
curl https://api.bubbl.tech/platform/v1/locations/import \
  -H "Authorization: Bearer $BUBBL_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f1c9a52-8d4e-4b7a-9c61-2e5f0d8a7b34" \
  -d @stores.geojson
JAVASCRIPT
import { randomUUID } from 'node:crypto';

async function importLocations(featureCollection) {
  const key = randomUUID(); // one key per import, reused for its retries

  for (let attempt = 1; attempt <= 5; attempt++) {
    const response = await fetch('https://api.bubbl.tech/platform/v1/locations/import', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.BUBBL_API_TOKEN}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': key,
      },
      body: JSON.stringify(featureCollection),
    }).catch(() => null); // a network error: retry

    if (response && response.status !== 409 && response.status < 500) {
      if (!response.ok) throw new Error(`Import failed: ${response.status} ${await response.text()}`);
      return response.json();
    }
    await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
  }
  throw new Error('Import failed after 5 attempts');
}
PYTHON
import os
import time
import uuid
import requests

def import_locations(feature_collection):
    key = str(uuid.uuid4())  # one key per import, reused for its retries
    for attempt in range(1, 6):
        try:
            response = requests.post(
                "https://api.bubbl.tech/platform/v1/locations/import",
                headers={
                    "Authorization": f"Bearer {os.environ['BUBBL_API_TOKEN']}",
                    "Idempotency-Key": key,
                },
                json=feature_collection,
                timeout=60,
            )
        except (requests.ConnectionError, requests.Timeout):
            response = None
        if response is not None and response.status_code != 409 and response.status_code < 500:
            response.raise_for_status()
            return response.json()
        time.sleep(2 ** attempt)
    raise RuntimeError("Import failed after 5 attempts")

What happens on a retry#

  • The first request runs as normal. If it succeeds, Bubbl stores its response for 24 hours.
  • A retry with the same key within 24 hours gets the stored response back, with the same status (such as 201 for a create) and the header Idempotent-Replayed: true. Nothing is changed again.
  • A retry while the first request is still running waits for it for up to 10 seconds. If it's still running then, the retry gets 409 with the code request_in_progress and Retry-After: 2. Retry again after that.
  • A request that fails, for example with 422 validation_failed, 403 limit_exceeded, 401 or 429, stores nothing. Fix the problem and retry with the same key.
  • Replays still count towards your rate limits.

Keys are kept per workspace and per URL path, so another workspace, or a request to a different path, using the same value doesn't clash with yours. The query string isn't part of the path.

One key, one importBubbl doesn't compare the body or the method of a retry with the first request's. A request to the same path with a key that's already been used gets the first result back, even if its body is different. Use a new key for every new change, including the real run after a dry run: reusing the dry run's key returns the dry run's result and imports nothing.

Retrying without a key#

Without a key, a retried change runs again. Campaign names are unique in a workspace, so a retried campaign create gets 422 validation_failed for the name if the first one worked, but a retried notification add makes a second copy. Check whether the first request worked (for example, list the campaign's notifications) before retrying. Publish and pause are safe to repeat: publishing a campaign that isn't paused, or pausing one that's already paused, changes nothing.

For location imports, features with properties.external_id are safe to import twice without a key: the second import finds the locations by their external_id and updates them instead of creating copies. A key still saves the import running twice, and protects any features without an external_id.