Skip to content

Commit 5228a57

Browse files
KrisBraunclaude
andcommitted
fix(google): stop retrying Gmail threads that no longer exist
A `threads.get` that answers 404 means the thread is gone from the mailbox — permanently deleted, or a draft that Gmail replaced on autosave or purged on discard. Gmail sync treated it as a transient failure: the id was kept in the pending-retry set, re-fetched on the next several incremental passes (taking a slot from each pass's fetch budget), logged as an error with the full API response every time, and finally reported as a change that "may be lost" — for a thread that in many cases was never synced at all. 404 is now classified as terminal. The id is dropped on the first pass and logged as a single informational line; genuine failures (5xx, rate limits, network) keep their retry and their error-level log. Draft messages are also skipped when walking mailbox history, unless the Draft channel is enabled. Gmail issues a new message id on every draft autosave and deletes the previous one, so draft ids churn constantly and are often gone before a sync pass can fetch them — and drafts are skipped when notes are built anyway. Label changes still sync even when the carrying message is a draft, so starring or archiving a thread behaves as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143sHdupsAodJozuwyx56cc
1 parent 3ebdbac commit 5228a57

3 files changed

Lines changed: 434 additions & 11 deletions

File tree

‎connectors/google/src/mail/gmail-api.ts‎

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,23 @@ export function isGmailRateLimitError(error: unknown): boolean {
126126
);
127127
}
128128

129+
/**
130+
* True when a {@link GmailApiError} says the entity is gone: an HTTP 404. For a
131+
* thread or message fetch this is TERMINAL, not a blip — the id no longer
132+
* resolves in the mailbox because it was permanently deleted, or because it was
133+
* a draft (Gmail replaces the draft message on every autosave and purges it
134+
* outright on discard, so a draft's ids routinely die inside the history
135+
* window). Retrying can never succeed, so callers drop the id instead of
136+
* carrying it in a pending set, and log it quietly rather than as an error.
137+
*
138+
* Note the caller's context decides the meaning: `history.list` also answers
139+
* 404, and there it means the history window expired and a full re-sync is
140+
* needed — those call sites handle the status themselves.
141+
*/
142+
export function isGmailNotFoundError(error: unknown): boolean {
143+
return error instanceof GmailApiError && error.status === 404;
144+
}
145+
129146
/**
130147
* In-process retry budget for {@link GmailApi.call}. Kept small and short so a
131148
* brief blip (a momentary 429, a 5xx, a dropped connection) is absorbed inside
@@ -1517,6 +1534,10 @@ async function syncGmailChannelIncremental(
15171534
const thread = await api.getThread(threadId);
15181535
threads.push(thread);
15191536
} catch (error) {
1537+
if (isGmailNotFoundError(error)) {
1538+
logVanishedThread(threadId);
1539+
continue;
1540+
}
15201541
console.error(`Failed to fetch thread ${threadId}:`, error);
15211542
}
15221543
}
@@ -1622,7 +1643,11 @@ async function syncGmailChannelFull(
16221643
try {
16231644
return await api.getThread(threadRef.id);
16241645
} catch (error) {
1625-
console.error(`Failed to fetch thread ${threadRef.id}:`, error);
1646+
if (isGmailNotFoundError(error)) {
1647+
logVanishedThread(threadRef.id);
1648+
} else {
1649+
console.error(`Failed to fetch thread ${threadRef.id}:`, error);
1650+
}
16261651
return null;
16271652
}
16281653
}
@@ -1657,6 +1682,40 @@ async function syncGmailChannelFull(
16571682
*/
16581683
export const MAX_HISTORY_PAGES_PER_PASS = 20;
16591684

1685+
/**
1686+
* Logs a thread that no longer exists in the mailbox. Deliberately NOT
1687+
* `console.error`: a vanished thread is an ordinary outcome of a mailbox
1688+
* changing under us (a deleted message, a discarded draft), not a fault, and
1689+
* the connector's log is the author-facing surface.
1690+
*/
1691+
function logVanishedThread(threadId: string): void {
1692+
console.log(
1693+
`[gmail] thread ${threadId} no longer exists in the mailbox; skipping`
1694+
);
1695+
}
1696+
1697+
/**
1698+
* True when a history record's message is a draft. Gmail issues a fresh message
1699+
* id on every draft autosave and deletes the previous one (and discarding a
1700+
* draft purges it outright, without a trip through Trash), so draft ids churn
1701+
* constantly and are frequently gone before an incremental pass can fetch them.
1702+
* Drafts are also skipped when notes are built, and the Draft channel is off by
1703+
* default — so unless the user actually syncs Drafts, a draft change is work we
1704+
* would throw away even when the fetch succeeds.
1705+
*/
1706+
function isDraftMessage(message: { labelIds?: string[] }): boolean {
1707+
return message.labelIds?.includes("DRAFT") ?? false;
1708+
}
1709+
1710+
/**
1711+
* Result of one `threads.get` during an incremental pass. `gone` (404) is
1712+
* terminal and dropped; `failed` is retryable and carried forward.
1713+
*/
1714+
type ThreadFetchOutcome =
1715+
| { status: "ok"; thread: GmailThread }
1716+
| { status: "gone" }
1717+
| { status: "failed" };
1718+
16601719
/**
16611720
* Mailbox-wide incremental sync. Calls the Gmail History API without a
16621721
* `labelId` filter, so it returns every change in the mailbox since
@@ -1675,12 +1734,22 @@ export const MAX_HISTORY_PAGES_PER_PASS = 20;
16751734
* window) but must re-attempt the failed IDs on a subsequent sync by passing
16761735
* them back via `retryThreadIds` — otherwise the cursor would move past
16771736
* changes we never ingested, permanently losing that mail.
1737+
*
1738+
* A thread that answers 404 is the exception: it no longer exists, so it is
1739+
* dropped rather than returned as failed. Reporting it as failed sent every
1740+
* vanished thread around the retry ladder — one wasted `threads.get` and one
1741+
* logged error per pass, a stolen slot in the per-pass budget, and finally a
1742+
* "its change may be lost" warning about a thread we never had.
1743+
*
1744+
* Pass `includeDrafts` when the Draft channel is enabled; otherwise draft
1745+
* messages are skipped entirely (see {@link isDraftMessage}).
16781746
*/
16791747
export async function syncGmailMailboxIncremental(
16801748
api: GmailApi,
16811749
historyId: string,
16821750
retryThreadIds: string[] = [],
1683-
maxThreads: number = Infinity
1751+
maxThreads: number = Infinity,
1752+
options: { includeDrafts?: boolean } = {}
16841753
): Promise<
16851754
| { expired: true }
16861755
| {
@@ -1730,6 +1799,10 @@ export async function syncGmailMailboxIncremental(
17301799
for (const entry of historyResult.history) {
17311800
lastReadRecordId = entry.id;
17321801
for (const added of entry.messagesAdded ?? []) {
1802+
// Draft autosave churn: skipped unless the user syncs Drafts. Only
1803+
// applied to added messages — a LABEL change on a draft (starring,
1804+
// archiving) is the user acting on the thread, so those still sync.
1805+
if (!options.includeDrafts && isDraftMessage(added.message)) continue;
17331806
changedThreadIds.add(added.message.threadId);
17341807
}
17351808
for (const labeled of entry.labelsAdded ?? []) {
@@ -1760,23 +1833,31 @@ export async function syncGmailMailboxIncremental(
17601833

17611834
// Fetch a bounded number of threads in flight; results (and the failed
17621835
// list) keep `toFetch` order — retry ids first — so downstream processing
1763-
// is deterministic.
1836+
// is deterministic. Each fetch resolves to one of three outcomes, because
1837+
// "gone" and "failed" must not be conflated: only a failure is worth
1838+
// retrying.
17641839
const fetched = await mapWithConcurrency(
17651840
toFetch,
17661841
THREAD_FETCH_CONCURRENCY,
1767-
async (threadId): Promise<GmailThread | null> => {
1842+
async (threadId): Promise<ThreadFetchOutcome> => {
17681843
try {
1769-
return await api.getThread(threadId);
1844+
return { status: "ok", thread: await api.getThread(threadId) };
17701845
} catch (error) {
1846+
if (isGmailNotFoundError(error)) {
1847+
logVanishedThread(threadId);
1848+
return { status: "gone" };
1849+
}
17711850
console.error(`Failed to fetch thread ${threadId}:`, error);
1772-
return null;
1851+
return { status: "failed" };
17731852
}
17741853
}
17751854
);
1776-
const threads = fetched.filter(
1777-
(thread): thread is GmailThread => thread !== null
1855+
const threads = fetched.flatMap((outcome) =>
1856+
outcome.status === "ok" ? [outcome.thread] : []
1857+
);
1858+
const failedThreadIds = toFetch.filter(
1859+
(_, i) => fetched[i].status === "failed"
17781860
);
1779-
const failedThreadIds = toFetch.filter((_, i) => fetched[i] === null);
17801861

17811862
return {
17821863
expired: false,

0 commit comments

Comments
 (0)