← All posts
DevOps

Composer's HTTP/2 400 failures from codeload.github.com in CI

Composer install failing with an HTTP/2 400 from codeload.github.com while downloading a package archive in CI

We hit a frustrating issue recently while building PHP images in AWS CodeBuild: composer install started failing, often, with an HTTP/2 400 from codeload.github.com while downloading a package archive. The package that failed changed from run to run, and the same Dockerfile and composer.lock that broke in one pipeline built cleanly in another. The short version is that this is GitHub wobbling rather than anything wrong in your project, that the same failure has come and gone for years, and that the usual environment-variable fixes only take the edge off. If your deployments run composer install, here is what is actually going on and how to stop it taking your builds down.

What the failure looks like

The build dies part-way through downloading dependency archives. The package varies, but the error is always the same: a 400 from codeload.github.com, then a fallback to a source (git) install that also fails because minimal PHP images often do not ship git.

  23/198 [===>------------------------]  11%    Failed to download <org>/<package> from dist:
  The "https://codeload.github.com/<org>/<package>/legacy.zip/<commit-sha>" file could not be
  downloaded (HTTP/2 400 )
    Now trying to download from source
  ...
In GitDownloader.php line 82:
  git was not found in your PATH, skipping source download

A few details are worth pinning down before guessing at a cause:

  • The status is 400, not 403 or 429. GitHub answers a rate limit, primary or secondary, with a 403 or 429 and a set of x-ratelimit-* headers, so a bare 400 is not throttling.
  • It is intermittent and environment-specific. The same image builds fine in one place and fails in another.
  • Fetching the failing URL on its own with curl usually works. It is the parallel, connection-reusing download that trips, not the URL being wrong.
  • The git was not found line is a secondary failure. Composer’s rescue for a failed dist download is a source (git clone) install, and a slim image without git cannot take it.

What Composer downloads from GitHub

It helps to know which URLs Composer hits. For a GitHub-hosted package, composer.lock stores a dist.url pointing at the GitHub API, like https://api.github.com/repos/<owner>/<repo>/zipball/<sha>. At install time Composer requests that URL, GitHub answers with a 302 redirect to a codeload.github.com archive URL like https://codeload.github.com/<owner>/<repo>/legacy.zip/<sha>, and Composer follows the redirect and streams the zip.

Only that first leg, the api.github.com request, is rate limited: unauthenticated REST API requests get 60 per hour per IP, authenticated ones get 5,000. The quota is per source IP, and on cloud CI the egress is often a shared NAT, so a busy runner can burn through the anonymous 60 on other people’s traffic, not yours. Solution for tha is an access token (see below). But the 400 we are chasing is on the second leg, the codeload download, and that is a different problem a token does not touch.

It is GitHub, and it is not new

When the same lockfile builds in one place and not another, and the failing URL works fine in curl, the cause is not your dependencies. It is GitHub’s edge, and the same failure has surfaced on and off for years:

  • January 2021 - composer#9607: an early report of Composer’s HTTP/2 transport throwing protocol errors and breaking downloads.
  • July 2024 - composer#12054: a run of codeload.github.com connection failures and timeouts, again landing on a different package each time.
  • September 2024 - composer#12126: a related bug where Composer forwarded its auth header past the 302 and codeload rejected it with a 404. Worth knowing, because it is the one case where adding a token makes things worse. It was fixed in Composer 2.8, so stay current.
  • June 2026 - composer#12958, plus a GitHub community thread of CI pipelines failing the same way all at once, with no changes on anyone’s end.

The mechanism, as the 2026 report puts it, is that “reuse of a prior working HTTP/2 connection failed”. Composer negotiates HTTP/2 and reuses connections across its parallel downloads, and every so often GitHub’s edge rejects a reused connection with a 400. The telling part is that these 400s arrive with no x-ratelimit-* headers, so this is not throttling, and a token does nothing for it.

The quick fixes help, but only so much

The first things everyone reaches for are environment variables. They are worth setting, but be clear-eyed about what they buy. In our case they took us from roughly nine builds in ten failing to about six in ten: better, but nowhere near good enough to put a deployment behind.

Set a token, the right way

A token is not a fix for the 400. It lifts the rate limit on the api.github.com leg and is needed for private packages, so set it as a baseline either way. The one thing to get right is not baking it into an image layer; a BuildKit secret keeps it out of the image history:

In your Dockerfile:

1RUN --mount=type=secret,id=composer_auth \
2    COMPOSER_AUTH="$(cat /run/secrets/composer_auth 2>/dev/null)" \
3    composer install --no-dev --optimize-autoloader --no-interaction

In your script/pipeline that builds the docker image:

printf '{"github-oauth":{"github.com":"%s"}}' "$TOKEN" > /tmp/composer_auth.json
DOCKER_BUILDKIT=1 docker build --secret id=composer_auth,src=/tmp/composer_auth.json -t app .
rm -f /tmp/composer_auth.json

A classic token with no scopes, or a fine-grained one with read-only access to public repositories, is enough. Prefer a bot account so it doesn’t go away when the person goes away.

Force IPv4 and lower the parallelism

COMPOSER_IPRESOLVE=4 puts curl on IPv4, which changes the network path and dodges some of the flaky edge. COMPOSER_MAX_PARALLEL_HTTP caps concurrent downloads (the default is 12); turning it down lowers the number of reused HTTP/2 connections in flight at once, which is the thing going wrong.

1RUN --mount=type=secret,id=composer_auth \
2    COMPOSER_AUTH="$(cat /run/secrets/composer_auth 2>/dev/null)" \
3    COMPOSER_IPRESOLVE=4 \
4    COMPOSER_MAX_PARALLEL_HTTP=4 \
5    composer install --no-dev --optimize-autoloader --no-interaction
tip Dropping all the way to COMPOSER_MAX_PARALLEL_HTTP=1 helped most but roughly doubled install time in our project, so a middle value like 4 is usually the better balance.
note You might expect forcing HTTP/1.1 to help too, since the fault is in HTTP/2 connection reuse, but Composer does not expose a switch for it, so IPv4 is the transport-related switch we get.

Retry

Because the failure is random and lands on a different package each run, a retry usually gets through. Wrapping the install in a small loop turns many of these into a non-event, and it is exactly the gap Composer is looking to close upstream with built-in retries:

for i in 1 2 3 4 5; do
  composer install --no-dev --optimize-autoloader --no-interaction && break
  echo "composer install failed (attempt $i), retrying..." && sleep 5
done

Retries plus lower parallelism are the most effective of the cheap fixes, but they are still a bet against a flaky service on every build. To actually stop builds failing, you have to stop depending on a live download from codeload.

Cache the downloads: COMPOSER_CACHE_DIR

The first serious win is to stop downloading the same archives on every build. Composer caches every dist archive it fetches, keyed by package and exact reference, and it never re-downloads a version it already has. The catch in CI is that the cache lives under $COMPOSER_HOME/cache by default, which is thrown away with each ephemeral runner. Point it somewhere you can persist with COMPOSER_CACHE_DIR and a warm cache only fetches versions it has not seen before.

The hard part is what “persist” means on your CI. In a plain Docker build, a BuildKit cache mount keeps the cache on the build host and reuses it across builds, without copying it into the image itself, so the cached archives never end up as a layer:

1RUN --mount=type=secret,id=composer_auth \
2    --mount=type=cache,target=/composer-cache \
3    COMPOSER_AUTH="$(cat /run/secrets/composer_auth 2>/dev/null)" \
4    COMPOSER_CACHE_DIR=/composer-cache \
5    COMPOSER_MAX_PARALLEL_HTTP=4 \
6    composer install --no-dev --optimize-autoloader --no-interaction

But that mount is local to the builder, and an ephemeral CodeBuild runner has no builder to come back to, so it starts empty every time unless store it and restore it on every build. That is what the project cache and the buildspec cache block are for. On the CodeBuild project (here in CloudFormation):

1Project:
2  Type: AWS::CodeBuild::Project
3  Properties:
4    Cache:
5      Type: S3
6      Location: !Sub "${Bucket}/codebuild-cache"
7      CacheNamespace: composer
warning Don’t forget to include permissions to access the bucket in CodeBuild’s service role.

Then in the buildspec, the paths to cache and the key to store them under (hash of composer.lock):

1cache:
2  key: composer-$(codebuild-hash-files composer.lock)
3  fallback-keys:
4    - composer-
5  paths:
6    - ".composer-cache/**/*"

CodeBuild restores .composer-cache into the source directory before the build. You then point COMPOSER_CACHE_DIR at it - in a Docker build, pass it in as a named build context (--build-context composer_cache=.composer-cache) and bind-mount it, so the cache is reused without being copied into an image layer.

The CodeBuild cache-key gotcha

This is where “just cache it” quietly breaks, so it is worth knowing exactly how that key behaves. From the buildspec reference:

  • The key is an exact match, and codebuild-hash-files composer.lock hashes the lockfile into it. Change a single dependency and the hash changes, the key changes, and the exact key no longer exists. A stored cache is also immutable: CodeBuild “create[s] a unique cache copy that will not be updated once created”.
  • fallback-keys are used only when the exact key misses, and they match by prefix. This is the part that matters. With no fallback, every composer.lock change is an exact-key miss, which means an empty cache and a full re-download from codeload - and a lockfile change is exactly when you are shipping new code, so you meet the flaky edge at the worst possible moment. The single composer- fallback is what prevents that: on a miss, CodeBuild prefix-matches composer- and restores the most recent cache that starts with it (in practice the previous build’s), so Composer only fetches the few packages that actually changed.

So “you only fetch what you have not seen before” holds because of that fallback key, not on its own. Without it, a lockfile-hash key makes the cache all-or-nothing: pristine while the lockfile is untouched, empty the moment it changes. Two things follow from how the keys work:

  • Each new hash writes a new immutable cache object, so the bucket gains one entry per distinct composer.lock over time. Put an S3 lifecycle rule on the cache prefix so it does not grow without bound.
  • The fallback restores the most recent match, not a perfectly matched one. That is a fine warm base, but it is “near enough” reuse, not a guarantee that any given package is present.

And a cold cache - the first build, or the next one after a lifecycle sweep - still fetches everything. Caching lowers how often you touch codeload, but it does not remove the dependency.

Build once, deploy the same image

If your deployments run composer install, every environment you ship to is another roll of the dice. The better shape is to not install per deploy at all: run composer install once, in a build stage, bake the resulting vendor/ into the image, and promote that same image through staging and production. A transient codeload 400 then only affects the single build that produced the image, which you retry, rather than every deployment. As a bonus your deploys get faster and what you tested is exactly what you ship.

The blunt version of the same idea is to commit vendor/ to the repository, which some teams do for precisely this reason. It removes the install-time download entirely, at the cost of a noisier repo, larger diffs, and care around platform-specific binaries. Building the image once and promoting it gets you the same resilience without living with vendor/ in version control.

The durable fix: a private mirror, and whether it is worth it

Everything so far either lowers the odds or reuses what you have already downloaded. The only thing that removes the dependency on codeload at install time is to put a mirror you control between CI and GitHub: a pull-through proxy, or a built mirror that holds the dist archives in your own storage. CI then pulls from an internal, stable source and never touches codeload.

The case for one:

  • It removes the failure mode instead of reducing it. No live codeload fetch, no HTTP/2 400.
  • It covers the wider case too: Packagist or GitHub outages, a package being deleted or retagged, and slow installs.
  • It is the honest answer when a persistent cache is not dependable in your CI. As above, COMPOSER_CACHE_DIR only helps when your tooling can carry the cache reliably between runs; if it cannot, a mirror gives you the same “do not re-fetch from GitHub” guarantee from a fixed endpoint you own.
  • It gives you one place to manage supply-chain risk. The recent run of npm package compromises is a reminder that a public registry can serve a poisoned release, and Packagist is no different. A mirror you own is a single point to scan, pin and freeze what reaches your builds, and a version you have already cached cannot be swapped out from under you by an upstream retag. It is only a partial safeguard - the mirror still serves whatever you point it at, so it will not stop you pulling in a bad version yourself - but one controllable point beats every build reaching straight out to the internet.

The case against:

  • It is real infrastructure to run, secure and keep available. Now CI depends on it being up.
  • The transport problem does not vanish, it moves to the mirror’s refresh, which still pulls from GitHub. That is fine, because the refresh is infrequent and easy to retry, but it is not nothing.
  • The lean option, Satis, trades the running service for a staleness problem: it only knows the versions present at its last build, so you have to rebuild it whenever composer.lock changes, before the consuming build. The live options remove that discipline but cost more to run or buy.

A quick read of the options:

OptionCostFootprint
Satisfree (MIT)a build job plus a static file host; refresh on lockfile change
Repmanfree (MIT)a running service: PHP, PostgreSQL, Redis, object storage
Private Packagistcommercialmanaged, or a self-hosted image
Artifactory / Nexuscommercial / limiteda JVM service; Composer support varies by edition

Satis is the lean pick: no running server, you build a static repository and host it anywhere a web server or object store can. The key is the archive option, which downloads each dist zip and rewrites the URLs to your host:

 1{
 2  "name": "acme/mirror",
 3  "homepage": "https://composer-mirror.example.internal",
 4  "repositories": [
 5    { "type": "composer", "url": "https://packagist.org" }
 6  ],
 7  "require-dependencies": true,
 8  "require": { "vendor/package": "*" },
 9  "archive": {
10    "directory": "dist",
11    "format": "zip",
12    "prefix-url": "https://composer-mirror.example.internal",
13    "skip-dev": true
14  }
15}

Then point the project at the mirror and switch Packagist off so Composer cannot wander back upstream:

1{
2  "repositories": [
3    { "type": "composer", "url": "https://composer-mirror.example.internal" },
4    { "packagist.org": false }
5  ]
6}

Give the (infrequent) satis build the token and COMPOSER_IPRESOLVE=4, since that is the one place left that still pulls from GitHub. It runs rarely and is easy to retry, while every real CI build pulls from the mirror at full speed.

Where we landed

There is no single switch for this, because the cause is not in your project. It is a recurring wobble in GitHub’s HTTP/2 edge, and the job is to keep it from reaching a deploy:

  • First aid: retry the install, drop COMPOSER_MAX_PARALLEL_HTTP to around 4, force IPv4, and keep a token set. Cheap and worth doing, but on its own it only moved us from nine failures in ten to about six.
  • The easy structural win: a persistent COMPOSER_CACHE_DIR, so you only fetch versions you have not seen before, as long as your CI can carry the cache between runs.
  • The one that protects deploys: build the vendor/ once and promote the same image, so a bad build is one retry rather than a failed production rollout.
  • Full insulation: a private mirror, for when you genuinely cannot depend on codeload at build time, or want the outage and supply-chain cover too.

Set the “quick fixes” today, but if composer install is part of your deployment, the cache and a build-once pipeline are what actually stop a GitHub hiccup from becoming your problem.