OSPulse

Retries & idempotency

Networks fail midway. This page covers which requests you can safely repeat, how to retry transient errors correctly, and what to do when you never found out whether a request succeeded.

No idempotency-key header

The OSPulse API does not currently accept an Idempotency-Key header. Safe retries rely on HTTP method semantics and on reconciling state before repeating a write, as described below. If your integration needs exactly-once creation guarantees, use the read-then-write pattern in recovering from an ambiguous response.

Which methods are safe to repeat

Idempotency is a property of the method. Repeating an idempotent request leaves the server in the same state as making it once.

MethodIdempotentSafe to retry automatically
GETYesAlways. Reads have no side effects.
DELETEYesYes. Deleting an already-deleted resource returns 404, which you can treat as success.
PATCHUsuallyYes, when the body sets absolute values. Not when it applies a relative change.
PUTYesYes. The request describes the complete desired state.
POSTNoNot automatically. A second call generally creates a second resource.

Everything under GET in the API — packages, repositories, scans, alerts, vulnerabilities, reports — can be retried freely. Retry logic that only ever repeats reads needs no special care beyond backing off.

Retrying safely

Retry on failures that are plausibly transient, and never on failures that will repeat.

Retry429, 502, 503, 504, network timeoutsOptional

Rate limiting and transient infrastructure errors. Back off exponentially, honouring Retry-After when present.

Do not retry400, 401, 403, 404, 409, 422Optional

The request itself is the problem. Repeating it produces the same result. Fix the request, the token, or the state it depends on.

Retry with care500Optional

A genuine server error. Retry a small number of times for reads. For writes, verify state first — the operation may have partially completed.

A correct retry loop

Three properties matter: only retry retryable statuses, cap the number of attempts, and add jitter so parallel workers do not resynchronise.

js
const RETRYABLE = new Set([429, 502, 503, 504])
 
async function withRetry(fn, { attempts = 4 } = {}) {
  let lastError
 
  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      const res = await fn()
      if (!RETRYABLE.has(res.status)) return res
 
      // Prefer the server's guidance; otherwise exponential backoff.
      const retryAfter = Number(res.headers.get('retry-after') ?? 0)
      const backoff = retryAfter ? retryAfter * 1000 : 2 ** attempt * 500
      await new Promise((r) => setTimeout(r, backoff + Math.random() * 250))
      lastError = new Error(`HTTP ${res.status}`)
    } catch (err) {
      // Network-level failure — also worth retrying.
      lastError = err
      await new Promise((r) => setTimeout(r, 2 ** attempt * 500 + Math.random() * 250))
    }
  }
 
  throw lastError
}

Writes that are naturally safe

Several OSPulse write operations are idempotent by design, because they express a desired end state rather than an increment:

  • Watching a packagePOST /api/v1/packages/{packageId}/watch sets the package to watched. Calling it twice leaves it watched once. The matching DELETE is equally safe.
  • Updating a repositoryPATCH /api/v1/repositories/{repositoryId} with absolute field values converges on the same result however many times you send it.
  • Tenant settings — updating scoring weights or the benchmark profile replaces values rather than accumulating them.
  • Triaging an exposurePATCH /api/v1/vulnerability-exposures/{exposureId} sets a state; repeating it is harmless.

Operations that create something — importing a repository, creating a policy, creating an API token, triggering a scan — are not idempotent. Repeating them creates another one.

Scans are cheap to duplicate, tokens are not

Accidentally triggering a second scan wastes a little compute and resolves to the same findings. Accidentally creating a second API token leaves a live credential you did not record. Treat credential creation as the operation most deserving of care.

Recovering from an ambiguous response

If a request times out or the connection drops, you do not know whether the server processed it. Do not blindly repeat a POST. Reconcile instead.

Query for the resource you were creating

List the collection and filter for something that identifies your intended write — a repository's external identifier, a policy name, a token name. For example, after an uncertain repository import:

bash
curl "https://api.ospulse.app/api/v1/repositories?take=100" \
  -H "Authorization: Bearer $OSPULSE_TOKEN"

Decide from what you find

If the resource exists, the original request succeeded — record its id and continue. If it does not, the request did not take effect and it is safe to send again.

Make your own writes identifiable

Give resources you create a deterministic, meaningful name — a CI run id, a commit SHA, a pipeline identifier. That turns "did this work?" into a single lookup rather than a guess, and it makes the reconciliation above reliable.

Concurrency

Two clients writing to the same resource can conflict. A 409 Conflict means the resource changed underneath you — re-read it, reapply your change to the current state, and send again. Do not loop on 409 without re-reading; the conflict will simply recur.

For workflows where you trigger a scan on every push, note that a scan already in flight covers work you may be about to duplicate. Check the scan list before triggering, or accept the duplicate as harmless. You can also cancel one with POST /api/v1/scans/{scanId}/cancel.

Always log the correlation id

Every error response carries a correlationId, and successful responses carry an X-Correlation-Id header. Log it alongside your retry attempts. When a retry sequence ends badly, that identifier is what lets support trace exactly which of your attempts reached the API and what each one did.

Next steps