Batch Updating DataCite Metadata for Existing DOIs
Metadata is never finished. A funder reference gets added after an award lands, a landing-page URL changes when a repository migrates, a typo in a creator name is caught during an audit — and each correction has to be applied across not one DOI but hundreds or thousands of already-registered ones. This page builds a client that updates existing DataCite DOIs in bulk: a bounded-concurrency loop of PUT /dois/{id} requests that respects the account’s rate limit, isolates a per-record failure so one bad DOI does not sink the run, collects unrecoverable failures into a dead-letter list, and is safe to re-run from the top. It extends the single-record work in registering DOIs with the DataCite REST API to the scale problem, and it assumes the lifecycle and JSON:API contract set out in the DOI Minting with DataCite overview.
The prerequisites are the same DataCite Fabrica test account and prefix, Python 3.10+, and httpx with async support. The one new constraint that dominates the design is the rate limit: at batch scale you will hit HTTP 429, and the only correct response is to honour the Retry-After header rather than hammer the endpoint. The general concurrency and backpressure machinery here is the DataCite-specific instance of the patterns in the async batch processing guide.
Update Scenarios and Their Outcomes
Every batch update is a PUT /dois/{id} carrying a partial or full attributes object; DataCite merges the supplied properties into the existing record. Omitting event leaves the DOI in its current state, so updating a findable DOI keeps it findable. The table maps the common update scenarios to the field written and the outcome the client must handle.
| Update scenario | Verb · field | Outcome |
|---|---|---|
| Correct a creator name | PUT · creators |
200 — record merged, state unchanged |
| Change the landing-page URL | PUT · url |
200 — resolution updated |
| Add a funding reference | PUT · fundingReferences |
200 — property appended |
| Promote drafts in bulk | PUT · event: publish |
200 findable, or 422 if incomplete |
| Invalid metadata supplied | PUT · any |
422 — permanent, route to dead-letter |
| Unknown or mistyped DOI | PUT /dois/{id} |
404 — permanent, route to dead-letter |
| Rate limit exceeded | PUT · any |
429 + Retry-After — back off and retry |
| Registry or gateway fault | PUT · any |
5xx — transient, retry with backoff |
The split that governs the whole client is permanent versus transient. A 422 or 404 is a property of the request itself and will never succeed on retry, so it is isolated to the dead-letter list. A 429, 5xx, or timeout is a property of the moment, so it is retried. Never let the two categories share a code path.
Concurrency and the rate limit are two separate dials that interact. Concurrency — the semaphore’s ceiling — governs how many requests are in flight at once; the rate limit governs how many requests DataCite will accept per unit time regardless of how you spread them. Setting concurrency modestly (a handful rather than dozens) keeps a batch comfortably under the limit and turns 429s from the common case into a rare exception the Retry-After path handles gracefully. Cranking concurrency high to finish faster is a false economy: the endpoint simply returns 429 more often, every one of those requests sleeps and retries, and the run ends up slower and noisier than a gentler one that never trips the limit.
Production Implementation
The client below is complete and runnable. An asyncio.Semaphore caps in-flight requests so the batch never exceeds the account’s concurrency budget; each DOI is updated inside its own task with try/except isolation; transient faults retry with exponential backoff and Retry-After; and permanent failures land in a dead-letter list with the reason attached. Because each update is an idempotent PUT that sets the same attributes regardless of how many times it runs, re-running the whole batch after a crash converges on the same end state and safely re-attempts only what the dead-letter list reports.
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any
import httpx
JSONAPI = "application/vnd.api+json"
TEST_BASE = "https://api.test.datacite.org" # Fabrica test; prod: api.datacite.org
_PERMANENT = {400, 404, 409, 422}
_TRANSIENT = {429, 500, 502, 503, 504}
@dataclass(frozen=True)
class UpdateJob:
doi: str
attributes: dict[str, Any] # partial DataCite attributes to merge
@dataclass
class BatchResult:
updated: list[str] = field(default_factory=list)
dead_letter: list[tuple[str, str]] = field(default_factory=list) # (doi, reason)
class DataCiteBatchUpdater:
def __init__(
self,
client_id: str,
password: str,
base_url: str = TEST_BASE,
concurrency: int = 8,
max_retries: int = 5,
base_delay: float = 1.0,
) -> None:
self._auth = (client_id, password)
self._base = base_url.rstrip("/")
self._sem = asyncio.Semaphore(concurrency)
self._max_retries = max_retries
self._base_delay = base_delay
def _body(self, job: UpdateJob) -> dict[str, Any]:
return {"data": {"type": "dois", "id": job.doi, "attributes": job.attributes}}
async def _update_one(
self, http: httpx.AsyncClient, job: UpdateJob, result: BatchResult
) -> None:
"""Update a single DOI with retry, isolating its failure from the batch."""
async with self._sem:
for attempt in range(self._max_retries + 1):
try:
resp = await http.put(
f"{self._base}/dois/{job.doi}", json=self._body(job), auth=self._auth
)
except (httpx.TransportError, httpx.TimeoutException) as exc:
if attempt == self._max_retries:
result.dead_letter.append((job.doi, f"transport: {exc!r}"))
return
await asyncio.sleep(self._base_delay * (2 ** attempt))
continue
if resp.status_code < 300:
result.updated.append(job.doi)
return
if resp.status_code in _PERMANENT:
result.dead_letter.append((job.doi, f"HTTP {resp.status_code}: {resp.text[:200]}"))
return
if resp.status_code in _TRANSIENT and attempt < self._max_retries:
delay = float(resp.headers.get("Retry-After", self._base_delay * (2 ** attempt)))
await asyncio.sleep(delay)
continue
result.dead_letter.append((job.doi, f"HTTP {resp.status_code} after retries"))
return
async def run(self, jobs: list[UpdateJob]) -> BatchResult:
"""Update every DOI under a bounded-concurrency limit; return a partitioned result."""
result = BatchResult()
async with httpx.AsyncClient(
timeout=30.0, headers={"Content-Type": JSONAPI, "Accept": JSONAPI}
) as http:
await asyncio.gather(*(self._update_one(http, job, result) for job in jobs))
return result
async def _main() -> None:
jobs = [
UpdateJob(doi="10.5072/reef-2026-0001", attributes={"url": "https://data.example.org/reef/1"}),
UpdateJob(doi="10.5072/reef-2026-0002", attributes={"publisher": "Marine Data Archive"}),
]
updater = DataCiteBatchUpdater("YOUR.CLIENT", "your-password")
result = await updater.run(jobs)
print(f"updated={len(result.updated)} dead_letter={len(result.dead_letter)}")
for doi, reason in result.dead_letter:
print(f" FAILED {doi}: {reason}")
if __name__ == "__main__":
asyncio.run(_main())
Passing the shared result into each task and appending to plain lists is safe here because asyncio tasks run cooperatively on one thread — a list append never interleaves mid-operation, so no lock is needed. The dead-letter list is the run’s durable output: persist it to disk and feed it back in as the next run’s worklist to retry only what failed.
Verification
Verify the classification logic without a live account by stubbing the HTTP layer, then run a small real batch against Fabrica. The test below drives one success, one permanent failure, and one rate-limited-then-successful request through a mock transport and asserts the batch partitions them correctly.
import asyncio
import httpx
import pytest
@pytest.mark.asyncio
async def test_batch_partitions_success_and_permanent_failure() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if "0001" in request.url.path:
return httpx.Response(200, json={"data": {"id": "10.5072/x-0001"}})
if "0002" in request.url.path:
return httpx.Response(422, json={"errors": [{"title": "missing titles"}]})
return httpx.Response(429, headers={"Retry-After": "0"})
updater = DataCiteBatchUpdater("id", "pw", concurrency=4, max_retries=1)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as http:
result = BatchResult()
await asyncio.gather(
updater._update_one(http, UpdateJob("10.5072/x-0001", {"url": "https://a"}), result),
updater._update_one(http, UpdateJob("10.5072/x-0002", {"url": "https://b"}), result),
)
assert result.updated == ["10.5072/x-0001"]
assert result.dead_letter[0][0] == "10.5072/x-0002"
assert "422" in result.dead_letter[0][1]
Run with pytest -q. For a live check, update two real test DOIs, confirm result.updated holds both, then GET one back and assert the changed field took effect. Re-running the identical batch must report the same successes and an empty dead-letter list — the machine-readable proof the operation is idempotent.
Gotchas
- Retrying a 429 without honouring
Retry-After. Immediately re-firing a rate-limited request deepens the limit and can escalate to a temporary block. Fix: read theRetry-Afterheader and sleep for exactly that duration before the next attempt, as_update_onedoes, and cap concurrency so the limit is rarely reached at all. - One bad DOI aborting the whole run. A bare
asyncio.gatherwithout per-task isolation propagates the first exception and cancels every sibling, losing all in-flight progress. Fix: catch inside each task and route the failure to the dead-letter list so the batch always runs to completion. - Sending
event: publishon an update you meant to keep quiet. Includingeventon aPUTtransitions the DOI’s state; a straypublishon a registered DOI makes it findable unexpectedly. Fix: omitevententirely for metadata-only edits so the state is preserved.
Related Guides
- DOI Minting with DataCite — the overview of the DOI lifecycle and JSON:API contract this batch client updates against.
- Registering DOIs with the DataCite REST API — the single-record reserve-then-publish client this scales up.
- Async batch processing — the bounded-concurrency, dead-letter, and idempotency patterns applied here to DataCite.