Abdulkader Safi

A failed download that was never going to be retried

Two GitHub calls per changed page against a token allowed 5,000 an hour meant a big vault's first sync would fail partway. Fixing that turned up a worse bug: a file that failed to download was marked as synced along with everything else.

3 min read

Share
A sync log showing a rate limited run followed by a run that fetched only the missing pages

When GitBasedDocs started showing who changed each page, sync gained a second GitHub call per changed file: the blob, then the newest commit that touched it. Both ran one after the other. For a vault of a few dozen notes that is nothing.

A fine-grained token gets 5,000 calls an hour. At two calls per page, a vault of about 2,500 notes cannot finish its first sync inside that hour. I went looking for how a run fails partway, and found that it did not fail at all. It succeeded, wrongly.

The worse bug

When a blob fetch failed, the loop did this:

} catch (e) {
  // Keep the last good copy, flag it, try again next run.
  errors.push(`${entry.path}: ${note(e)}`)
  if (prior) await markStale(prior.id)
  continue
}

The comment promises a retry. The end of the run broke that promise:

await markConnection("connected", errors.length ? errors[0] : null, headSha)

The head sha was stored whether or not every file made it. The next run asked GitHub for the head, got the same sha, and stopped with "unchanged". A new file that failed never got a row. An existing one stayed flagged stale. Neither was fetched again until somebody pushed.

On a rate limited first sync, that is hundreds of pages missing with nothing to bring them back.

One rule: the head moves when every file is in

A run is now incomplete if any of these happened:

  • a blob fetch failed
  • GitHub answered 403 or 429
  • commit lookups ran past this run's budget
  • asset downloads stopped early
await markConnection(
  rateLimited ? "error" : "connected",
  errors.length ? errors[0] : null,
  incomplete ? undefined : headSha,
)

undefined leaves the stored head alone. That sounds expensive until you trace the next run. It sees a head that differs from the stored one, reads the tree (one call), and skips every page whose blob sha already matches. Only the files still missing cost anything.

A rate limited run also leaves the connection in "error", which is what raises the admin banner.

Stop asking once GitHub says no

lastCommit used to swallow every error and return null, which is right for an optional detail and wrong for a rate limit: the loop kept calling an API that had already refused it. It now rethrows 403 and 429, and every fetch checks a flag before it starts.

Pages download four at a time with a small pool instead of a queue library:

async function eachLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>) {
  let next = 0
  const worker = async () => {
    while (next < items.length) await fn(items[next++])
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
}

A budget for the optional part

Who changed a page is nice to have. The page itself is not optional. So commit lookups get 300 per run, and the lookup function returns three different things:

// undefined: not looked up this run. null: looked up, nothing found.
const commitFor = async (path: string): Promise<LastCommit | null | undefined> => {
  if (rateLimited || commitBudget <= 0) {
    incomplete = true
    return undefined
  }
  ...
}

Past the budget, a page syncs without author details and the run counts as incomplete. The next run finds unchanged pages with no commit recorded and spends its budget filling them in.

There is a known ceiling, written into the code: a file whose lookup genuinely finds no commit gets asked again on every run that reads the tree. The budget bounds it. If it ever shows up in the call count, the fix is a "looked up" column.

Proving it with a fake GitHub

The sync check already swapped fetch for an in-memory GitHub. It gained a counter and a switch:

if (++calls.blobs > limitBlobsAfter) return new Response("slow down", { status: 429 })

With the switch at 5 and 20 changed pages, the first run keeps at most five pages and leaves the old head in place. The second run, with the limit lifted, makes exactly 20 - got blob calls and then stores the head.

A project with 310 changed pages makes exactly 300 commit lookups on the first run and 10 on the second. A third run reports "unchanged".

The numbers are the point. "The retry works" is a sentence. "Fifteen calls, not twenty" is a check that fails the day someone breaks the skip.

Last updated 13 Sep 2026 · filed under Typescript, web application, nextjs

Every entry on this project

19 build notes, in order

Including the ones where nothing worked. You are on part 15.

Read the series →

FAQ

Frequently asked questions

Should a sync store the new head commit when some files failed to download?

No. If the head is stored, the next run sees nothing changed and never retries the failed files. Store it only when every file at that head is in. The next run then reads the tree again and fetches only what is still missing, which costs little because unchanged blobs are skipped.

How does GitBasedDocs stay under GitHub's rate limit on a large first sync?

It fetches pages four at a time, stops asking after the first 403 or 429, and caps the optional commit lookups at 300 per run. Pages past the cap sync without author details, and later runs fill those in.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from GitBasedDocs

All 19 entries →
  • The GitBasedDocs search palette showing results for a partial word
    GitBasedDocs

    · part 19 of 19

    Search that quotes whatever you type

    GitBasedDocs search moved from LIKE over every page body to SQLite FTS5 kept in step by triggers. Word prefixes and accent folding came with the tokenizer. The real work was quoting input, choosing a stable order, and a backup detail about rowids.

  • A GitBasedDocs doc page with a Linked from list under the article
    GitBasedDocs

    · part 18 of 19

    Backlinks that use the renderer's own link rules

    GitBasedDocs pages now show which pages link to them, resolve Obsidian aliases, open embedded PDFs and list recent changes. The backlinks reuse the functions that turn links into hrefs, so a page appears under Linked from exactly when its link works.

  • A docker-compose.yml with BACKUP_ENABLED set to true and a folder of dated backup archives
    GitBasedDocs

    · part 17 of 19

    Backups behind one flag in docker compose

    GitBasedDocs now has a Dockerfile, a compose file and backups you turn on with BACKUP_ENABLED. Each archive is a VACUUM INTO copy plus the asset files, and the copy is edited before it is packed so a restored app catches up on its own.