Why this matters beyond the homelab: Loops of individual API writes are the default shape most people reach for, and they are usually the wrong one. They are slow, they fail partway leaving inconsistent state, and against any API with optimistic concurrency they race themselves. Recognizing when a batch endpoint exists is a meaningful difference in reliability, not just speed.
The Original Design
The documentation pipeline exports pages from the wiki nightly and commits them to a Git repository, giving me version history on documentation that otherwise lives in a database.
The obvious implementation, and the one I built: loop over the pages, push each one.
For each of 32 pages:
GET /repos/{owner}/{repo}/contents/{path} → retrieve current SHA
PUT /repos/{owner}/{repo}/contents/{path} → update with new content + SHA
Thirty-two iterations. Thirty-two commits per night. It worked in testing and failed intermittently in production, which is the most annoying possible outcome.
Why It Raced Itself
Gitea’s contents API, like GitHub’s, uses optimistic concurrency. To update a file you supply the SHA of the version you are replacing, which is how the server knows you are not clobbering someone else’s change.
That is a good design. It also means every write invalidates the SHA state that subsequent reads depend on, because each commit advances the branch.
In a sequential loop the reads and writes interleave badly. A SHA fetched at the start of an iteration can be stale by the time that iteration writes, if anything else has advanced the branch in between. The result was 403 and 409 responses scattered unpredictably through the run, and the pattern of which files failed changed every night.
The deeper problem was not the failures themselves. It was that a partial failure left the repository in an inconsistent state: some pages current, some stale, no clean point to retry from. The commit history for a single night’s export looked like thirty-two unrelated commits with a few missing.
What I Tried First
Retry logic with backoff. Re-fetch the SHA immediately before each write. Rate limiting between iterations.
All of these made it fail less often. None of them made it correct, because they were treating symptoms of a design that was wrong in shape. Thirty-two commits was never the goal. One commit containing thirty-two files was the goal, and I had been approximating it badly.
The Actual Fix
Gitea exposes a batch endpoint that accepts multiple file operations in a single request and produces one commit:
POST /repos/{owner}/{repo}/contents
The payload carries an array of file operations, each with an operation type, a path, and base64 content:
{
"message": "nightly export: full sync 2026-07-25 (32 pages)",
"files": [
{ "operation": "update", "path": "adr-001-proxmox-as-hypervisor.md", "content": "<base64>" },
{ "operation": "update", "path": "vlan-and-network-map.md", "content": "<base64>" }
]
}
One request. One commit. No per-file SHA handling at all, because the server resolves the whole set against the branch head atomically rather than making you coordinate it from outside.
The workflow collapsed from a loop with retry logic, error branches, and rate limiting into: fetch pages, build the array, one POST.
First run landed a single commit reading nightly export: full sync 2026-07-25 (32 pages). Every page, one commit, no races.
What Actually Improved
Atomicity. Either all thirty-two files land or none do. There is no partial state to reconcile. This is the real win, and it is a correctness property rather than a performance one.
A readable history. One commit per night instead of thirty-two. Diffing what changed in documentation between two dates is now trivially possible, and the message says exactly how many pages were in the sync.
Speed, though it is the least interesting benefit. Thirty-two round trips became one.
A drastically simpler workflow. No retry branches, no rate limiting, no SHA-fetch step. Most of the complexity in the original existed purely to manage a problem the batch endpoint does not have.
The Token Detail
The batch endpoint needs write access, which meant a new token. Two things worth doing at the same time:
Scope the token to the repository and to write. Not admin, not org-wide. If this token leaks it should be able to commit to one documentation repo and nothing else.
Use a distinct token per workflow. The read-only token the health watchdog uses to check commit freshness and the write token the export uses are separate credentials. Revoking one does not break the other, and audit logs attribute commits to the automation rather than to me.
The Watchdog That Found This
Worth noting how the problem surfaced at all. A separate scheduled workflow checks the repository’s most recent commit timestamp every morning and alerts if it is stale beyond a threshold.
That watchdog is what told me the export had last succeeded 211 hours earlier. Nearly nine days. The workflow was “running” nightly the whole time, in the sense that it executed and did not report a failure loudly enough for me to notice.
A pipeline that runs is not a pipeline that works. Monitor the output, not the execution. The export job’s own success status was useless here. The commit timestamp in the destination repository was the only honest signal, and it is the one worth alerting on.
Takeaways
Check for a batch endpoint before writing a write loop. Many APIs have one, and it is frequently under-documented compared to the single-resource endpoints.
Sequential writes against optimistic concurrency race themselves. Each write invalidates the state the next one depends on.
Retry logic on a wrong-shaped design hides the problem. Fewer failures is not correctness.
Atomicity is the point, speed is a bonus. Partial state is harder to recover from than outright failure.
Scope automation tokens narrowly and separately per workflow.
Monitor the artifact, not the job. A workflow reporting success while producing nothing is the failure mode worth catching.
Related: The Fleet Codex Pipeline | n8n as Infrastructure Glue | Writing ADRs for a Homelab