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.
| Method | Idempotent | Safe to retry automatically |
|---|---|---|
GET | Yes | Always. Reads have no side effects. |
DELETE | Yes | Yes. Deleting an already-deleted resource returns 404, which you can treat as success. |
PATCH | Usually | Yes, when the body sets absolute values. Not when it applies a relative change. |
PUT | Yes | Yes. The request describes the complete desired state. |
POST | No | Not 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 timeoutsOptionalRate limiting and transient infrastructure errors. Back off exponentially, honouring
Retry-After when present.
Do not retry400, 401, 403, 404, 409, 422OptionalThe request itself is the problem. Repeating it produces the same result. Fix the request, the token, or the state it depends on.
Retry with care500OptionalA 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.
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 package —
POST /api/v1/packages/{packageId}/watchsets the package to watched. Calling it twice leaves it watched once. The matchingDELETEis equally safe. - Updating a repository —
PATCH /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 exposure —
PATCH /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:
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.
