Design: Backup Copy Before Replacing a Doc's Content¶
Status: Implemented. Issue #878. Live-verified against real Drive/Docs API calls via scratch
scripts per CLAUDE.md's "Verify a ticket's API premise live" convention (2026-10-12).
Problem¶
write_doc_content and update_doc_from_file share _replace_doc_content
(tools/docs/content.py). It clears the doc in its own batchUpdate and then writes the new
content in later calls. The clear has to be separate because of the same-batch insertText
style-inheritance quirk. If anything after the clear fails (the content batchUpdate, a non-image
retry failure, fill_tables, anchor resolution), the original content is already gone, and the
error didn't say so.
The decision that didn't survive measurement¶
The issue was triaged on 2026-10-04 to record the doc's current Drive revision ID before the clear,
and to name it in the error for recovery with export_revision. Restoring the old content by
re-inserting a snapshot was ruled out, because formatting, images, and tables don't round-trip.
Measured live before implementing:
| Case | Revisions before the clear | Right after the clear | revisions.get on the recorded ID |
|---|---|---|---|
| Doc written 5 s before the clear | 1, 3 (3 exported the original text) |
1, 4 |
404 at +0 s, +30 s, +2, +5, +8 min |
| Doc idle for 7 min before the clear | 1, 3 (same) |
1, 4 |
404 at +0 s, +30 s, +2, +5, +8 min |
Docs merges the revision that was current before the clear into the clear's own new revision
straight away, however old it is. The pre-clear content is no longer reachable through the
revisions API, so an error naming that revision would point at a 404. This matches the merge
behavior doc-revision-export.md recorded, except that here it happened immediately rather than
after a later edit.
Decision (maintainer, 2026-10-12)¶
Copy the doc with Drive (files.copy) before the clear. A copy is the real document, so formatting,
images, and tables survive, which a re-inserted snapshot wouldn't.
- Only a doc with content is copied. An empty doc (
endIndex <= 2) has nothing to lose and isn't cleared. - Name and location.
"<title> (backup before replace)". With noparentsin the request, the copy inherits the source's parents, so it sits next to the doc. - Success: the copy is removed with
markdown_export._discard_temp_doc, the same helperexport_revision's temporary Doc uses. It deletes the copy, or trashes it whencanDeleteis false (a Content manager on a Shared Drive, #876), and retries on a 5xx or 429. A failed removal doesn't fail the call. The result gets awarningnaming the copy. - Failure after the clear: the copy is kept. The result is
{"error", "docId", "backupId"}(plusimageswhen there are any), and the error names the copy. - The clear answered with a 4xx (429 included): the API ran nothing, so the doc is unchanged. The copy is removed and the error says so. Anything else from the clear (a 5xx, a timeout, a transport error) doesn't say whether it landed, so the copy is kept and the error says the content may have been cleared. QA reproduced a 503 returned after a clear that had run (PR #1017 round 1), which an earlier version treated as rejected, deleting the only copy.
- The doc is re-read after the copy. The copy takes seconds, and a concurrent edit meanwhile would otherwise survive the clear (the doc grew) or make it fail (it shrank). A failed re-read stops before the clear: the doc is unchanged and the copy is removed. A doc emptied meanwhile isn't cleared, and a later failure still names the copy.
- The copy fails: nothing is changed, and an error is returned. A 429 is retried (up to 3 times,
with backoff), since Drive ran nothing. A 5xx isn't, since the copy may already exist and a
retry would make a second one. For a failure other than a 4xx, the error names the copy that
may have been created, so the caller can find and remove it. Some failures replace without
a backup instead (
_no_backup_reason). That function decides from Drive's errorreason, not the status, becauserateLimitExceededanduserRateLimitExceededare 403s too and must still refuse. The reasons that fall back, all 403s: storageQuotaExceeded. A service account can't own a copy of a user-owned doc, andwrite_doc_contentis the documented way for a service account to write into such a doc (known-limitations.md's quota section).insufficientFilePermissions. The copy has noparents, so it goes into the doc's folder, and a caller who can edit the doc but only read or comment on that folder can't add it. Measured live on 2026-10-14 with the service account as the caller. The doc was shared to it as writer, and it was a reader or commenter on a My Drive folder, or a commenter member of a Shared Drive. All three layouts returned this reason. With writer access to the My Drive folder, the same copy returnedstorageQuotaExceeded, so the folder permission is checked first.teamDriveFileLimitExceededandnumChildrenInNonRootLimitExceeded. The doc's Shared Drive, or its My Drive folder, is at its item limit, so the copy can't be added there. Neither limit can be reached in QA, so both rest on Drive's documented reason strings.
Each of these callers could write the doc before #878, which is why the maintainer chose to
replace without a backup rather than refuse (2026-10-13, #1018). Copying somewhere else was
rejected because it changes who can see the copy (the #989 trade-off). The fallback logs a
warning. A successful replace returns a warning naming the reason. If only the
webViewLink lookup after it fails, the raised error starts with that same warning, so the
caller still learns the old content is gone (PR #1034 QA round 1). If the replace fails
after the clear, the error says the content couldn't be backed up and why. In that case the
original content can't be recovered, since the pre-clear revision is merged away at once
(table above).
Two more 403 reasons replace without a backup only when the caller passes
allow_no_backup=True (_opt_in_no_backup_reason; maintainer decision, 2026-10-15, #1035):
- cannotCopyFile. downloadRestrictions.itemDownloadRestriction.restrictedForWriters is
set, and the caller is a writer. The reason text names the doc's sharing settings, not an
owner: a Shared Drive doc has no owners, and there a manager sets the restriction (PR
#1042 QA round 1). Measured live on 2026-10-14, with or without parents. On
2026-10-16 the same writer also got 403 cannotExportFile from files.export, and its
revisions.list entries had no exportLinks. Whoever set it chose to stop writers from
copying the file, so the backup is never made another way (an export or a download).
- activeItemCreationLimitExceeded, the account's item-creation cap (a documented reason; it
can't be produced in QA).
A reader gets cannotCopyFile too, when copyRequiresWriterPermission is set, and the flag
can't help it: the clear would fail. So for either reason the doc's capabilities.canEdit
is read after the copy fails (_can_edit). If it's false, the call refuses with an error
saying the account can't edit the doc, whatever the flag says (PR #1042 QA round 1). If it
can't be read, the call goes on by the flag; a non-editor's clear then gets a 4xx, which
still leaves the doc unchanged. The read only runs on these two reasons, so it costs
nothing on the normal path.
Without the flag the call refuses, leaving the doc unchanged. The error names the reason
and the flag, and says export_revision can't recover the previous content afterward. That
was measured in the same 2026-10-16 run: after the writer's clear, the revisions were 1, 4,
and the owner's export of both was empty. The flag has no effect on #1018's reasons above,
which replace without a backup either way, or on any other copy failure, which still refuses.
With the flag, the success warning and the after-clear failure text are the same as for
#1018's reasons.
- Cancellation: the copy is created inside a shielded scope, so its ID is always recorded (the
PR #877 rule). The scope is bounded by _BACKUP_COPY_TIMEOUT (60 s, all attempts together), per
CLAUDE.md's rule that a cancel waits for shielded work; a timeout is reported like a 5xx.
Once the copy exists, a cancellation keeps it and logs a warning with its ID, since the doc may
already be cleared and the call has no result left to report it in.
- On success, the copy's removal and the webViewLink lookup run together.
Cost¶
Measured on a 20 KB doc: files.copy 2.5–4.0 s, delete 0.8–1.3 s, so about 3.5–5 s added to every
replace of a non-empty doc. The maintainer accepted this over options with no recovery path.
Options rejected¶
- Name the pre-clear revision in the error. The revision is gone at once (table above).
- Snapshot and re-insert on failure. Formatting, images, and tables don't round-trip (2026-10-04 triage).
- Error message only, no recovery. A silently emptied doc is real data loss (2026-10-04 triage).
- Backup behind an opt-out parameter. Considered on 2026-10-12. Not chosen: the default would have been on anyway, and the latency cost is accepted.