Skip to content
Latchkey LogoLatchkey home

GitHub Actions reserveCache failed is two different failures

A GitHub Actions reserveCache failed line is built at run time from an operation name and whatever went wrong underneath it, and it only exists on the version 1 cache service. The size refusal people search for alongside it is a different message entirely, raised before any reservation is attempted.

Three prefixes assembled into one log line, and where each of them is written
Failed to save comes from cache.ts, reserveCache failed from a template in requestUtils.ts, and the last clause from the service the client was talking to.

What this error means

The post step of a cache action writes one long line beginning with a phrase about failing to save, and the rest of it is a stack of prefixes: an operation name, then a transport error, then sometimes an address or a URL. The job is green. Nothing was cached, so the next run installs from scratch and the run after that does too. People then search for the whole line, find nothing in any repository, and conclude that either the message is misquoted or something is badly wrong. Neither is true, and the reason is worth understanding because it repeats across the caching area.

Actions log, cache save step, quoted from actions/cache#1718
::warning::Failed to save: reserveCache failed: getaddrinfo ENOTFOUND [2001:bc8:1d90:1fc1:dc00:ff:fe2b:3f97]

Where each part of the line comes from

The phrase reserveCache failed is not written anywhere. It is produced by a retry helper that formats ${name} failed: ${errorMessage}, and the name it is given at the call site is the string reserveCache. The result is then caught one level up and prefixed again with Failed to save: .

Grepping for the assembled phrase confirms this rather than contradicting it. It appears in none of the shipped versions of the cache action from v1.2.1 through v4.3.0, and in none of the toolkit sources, while the surrounding literals that are real, such as the ones about not saving a cache, are found immediately in the same search. So a reader who searches the whole line and comes back empty has found the expected result, not a fabrication.

actions/toolkit, packages/cache: three excerpts from the v1 save path
// packages/cache/src/internal/requestUtils.ts
throw Error(`${name} failed: ${errorMessage}`)

// packages/cache/src/internal/cacheHttpClient.ts
const response = await retryTypedResponse('reserveCache', async () =>
  httpClient.postJson<ReserveCacheResponse>(
    getCacheApiUrl('caches'),
    reserveCacheRequest
  )
)

// packages/cache/src/cache.ts, the outer catch
core.warning(`Failed to save: ${typedError.message}`)

Common causes

The reservation call could not reach the service

On the version 1 path this is what the assembled line usually carries: a DNS failure, a connection reset, or a request timeout, appended after the operation name. The retry helper has already tried several times by then, so a line like this means the failure was persistent rather than momentary.

The archive is larger than the per-entry limit

A separate failure with a separate message. The client measures the compressed archive and refuses locally above ten gigabytes, so the reservation is never attempted and the line you get names the size rather than the operation. Caching a build tree rather than a package manager store is the usual reason.

Another job is already writing that key

Two jobs in a matrix computing the same key will race, and the loser is told that another job may be creating the cache. That one is logged at info level rather than as a warning, which is why it is easy to miss entirely and why it looks like nothing was attempted.

The service refused the write for its own reasons

On the version 2 path the refusal text is produced by the service and passed through. GitHub publishes what the cache service offers but not what it runs, so the only honest reading of that clause is that the service declined and said why in its own words. Treat the clause as data rather than as something you can trace.

How to fix it

Work out which of the three lines you actually have

  1. Expand the post step of the cache action in the failed run.
  2. If the line contains the operation name, you are on the version 1 service and the clause after it is a transport error.
  3. If it names a size in megabytes and bytes, the archive was too large and no request was sent.
  4. If it says a reservation failed without naming an operation, you are on version 2 and the clause came from the service.

Shrink what you are caching, not the limit

The limit is not adjustable, so the only lever is the archive. Cache the package manager store rather than the installed tree, and leave build output to a dedicated layer cache. This is also the change that makes restores faster, because most of the size in an oversized cache is content that would have been regenerated anyway.

.github/workflows/ci.yml (illustrative)
      - uses: actions/cache@v6
        with:
          path: |
            ~/.npm
            ~/.cache/ms-playwright
          key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

Give matrix legs keys that cannot collide

If the line says another job may be creating the cache, two legs computed the same key. Put the distinguishing part of the matrix into the key so each leg writes its own entry, and use a shared prefix in restore-keys if they should still be able to warm from each other.

.github/workflows/ci.yml (illustrative)
      - uses: actions/cache@v6
        with:
          path: ~/.npm
          key: deps-${{ matrix.os }}-${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            deps-${{ matrix.os }}-

Stop treating a green job as a saved cache

Every failure on this page is logged rather than raised, so a workflow can go months without saving anything. If cache hits matter to your build times, check the post step after any change to the cached paths, and be suspicious of a pipeline whose restore step reports a miss on every single run.

The size refusal is a different message

The phrase people pair with this, about a cache size exceeding a limit, is not part of the same failure. On the version 1 path the client measures the archive itself and throws before it calls the reservation at all, with a message naming the size in megabytes and bytes and ending in a statement that no cache is being saved. That message never picks up the reserveCache failed prefix, because the reservation was never attempted.

The check is also skipped on GitHub Enterprise Server, where the size decision is deferred to the service so that an enterprise limit can apply instead of a hard ten gigabytes. So the same oversized cache produces a local refusal on github.com under the old path and a service refusal on an enterprise installation.

actions/toolkit, packages/cache/src/cache.ts, saveCacheV1
const fileSizeLimit = 10 * 1024 * 1024 * 1024 // 10GB per repo limit
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath)

// For GHES, this check will take place in ReserveCache API with enterprise file size limit
if (archiveFileSize > fileSizeLimit && !isGhes()) {
  throw new Error(
    `Cache size of ~${Math.round(
      archiveFileSize / (1024 * 1024)
    )} MB (${archiveFileSize} B) is over the 10GB limit, not saving cache.`
  )
}

Which service version you are on decides what you can see

The client picks a path at run time. Enterprise Server is always version 1. Everywhere else it uses version 2 when the runner has set the environment variable that selects it, and the action documents that the new service began rolling out on February 1st, 2025 with the legacy service sunset on the same date.

On the version 2 path there is no reservation call and no local size check at all. The reserve becomes a request to create a cache entry, and a refusal is logged as Cache reservation failed: followed by whatever the service said, which is another assembled line whose tail comes from a closed service and therefore exists as a literal nowhere. The error that follows it is shorter than its version 1 counterpart: it says a key could not be reserved and that another job may be creating the cache, and it does not append the details clause the older path adds.

The practical consequence is that on github.com today, a reserveCache failed line is a line from the past. If you are reading one in a current run, either you are on Enterprise Server or you are reading an old log. On version 2 the same underlying problems surface under the other two phrasings.

Cache service versionWhere the size is checkedThe line a refusal produces
v1, on Enterprise Serverat the service, with the enterprise limitFailed to save: reserveCache failed: ...
v1, historic github.comin the client, against 10 GBFailed to save: Cache size of ~... not saving cache.
v2, github.com todayat the service onlyCache reservation failed: ... then Unable to reserve cache with key

Why there is no recorded run on this page

This is the one page in the area where a reproduction is not merely unhelpful but impossible. The line the page is named after is written by the version 1 cache client, and github.com retired that service in February 2025. A Latchkey runner talks to the version 2 service like any other runner does, so there is no job we can schedule, on our hardware or anyone else, that produces this line today.

The quoted line is therefore from a log somebody else recorded and reported, named in the label above it, and the rest of the page is read from the code that assembles each piece of it. The version 2 phrasings are quoted from the same source tree, and the section above says which of the three you should expect on which service, so that a reader on Enterprise Server and a reader on github.com are not given the same answer.

How to prevent it

  • Cache package manager stores rather than installed or built trees.
  • Put every matrix dimension that changes the content into the cache key.
  • Read the post step after changing cached paths, since none of these failures are red.
  • Know which cache service your runners talk to before matching a log line to advice.

Frequently asked questions

Why can I not find reserveCache failed in any source code?
Because it is assembled at run time. A retry helper formats the operation name and the underlying error into one string, and the name passed at that call site is reserveCache. The phrase appears in no source file, which is why grepping the action and the toolkit for it returns nothing.
What is the GitHub Actions cache size limit per entry?
Ten gigabytes, checked by the client against the compressed archive before it contacts the service, on the version 1 path. The check is skipped on Enterprise Server so an enterprise limit can be applied at the service instead, and on the version 2 path the size decision is left to the service entirely.
Does reserveCache failed mean my cache is too big?
No, and they are separate messages. An oversized archive is refused locally with a message naming the size, before any reservation is attempted, so it never carries the operation name. A line with the operation name in it is a failure of the reservation call itself, usually a transport error.
Should reserveCache failed still appear in 2026?
Only on GitHub Enterprise Server. The client picks version 1 unconditionally there, and version 2 everywhere else, and the action records that the new service rolled out from February 1st, 2025 with the legacy one sunset the same day. On github.com you should see the version 2 phrasings instead.

Related guides

References

One streaming request per save means no reservation call to fail. Latchkey Fast Cache is a one-line swap. Start free → 30-day trial · No credit card