composer could not resolve host ci and the errno that decides
A composer could not resolve host ci failure is a TransportException whose text is half Composer and half libcurl, and the small integer in the middle is the part worth reading, because Composer uses it to decide whether the request is worth another attempt. For the errnos it does retry, reaching you as a failure means four attempts failed rather than one, which changes what the fix should be.

What this error means
A composer install or composer update step stops with a line of the form curl error <n> while downloading <url>: <description>, sometimes wrapped in a [Composer\Downloader\TransportException] banner. The integer is a libcurl error number and the description after the colon is libcurl's own text for that failure, so curl error 6 carries Could not resolve host: <host> and curl error 28 carries a timeout message. Composer prints nothing at all about the attempts it made first: the retry notices are written at debug verbosity, so an ordinary job shows one line for what was four requests. A curl error 7 is worth separating from the rest, because Composer handles it specially in a way that changes the second attempt.
curl error 6 while downloading https://repo.packagist.org/packages.json: Could not resolve host: repo.packagist.orgThe line is two programs speaking in turn
Composer contributes the frame. In CurlDownloader::tick, in src/Composer/Util/Http/CurlDownloader.php, the throw is built as 'curl error ' . $errno . ' while downloading ' . Url::sanitize($url) . ': ' . $error. Libcurl contributes $error, which comes from curl_error() on the handle, and only when that is empty does Composer fall back to curl_strerror() for a generic description of the number. So on a DNS failure the words after the colon are libcurl's specific Could not resolve host: <host>, not the generic phrase you get from the error table.
That is why searching for the whole line in the Composer source finds nothing. The literal does not exist in any file: the prefix is Composer's, the number is libcurl's, and the tail is libcurl's error buffer for that particular attempt. The stable, searchable part is curl error <n> while downloading, and the part that tells you what to do is the number.
Composer also sanitises the URL before putting it in the message, which is worth knowing if you use credentials in a repository URL. What you see in the log is not necessarily what was requested, so a URL that looks wrong in a failure is sometimes just redacted.
| errno | What libcurl means | Retried by Composer on a GET? |
|---|---|---|
| 6 | Could not resolve host. | Yes, up to three times. |
| 7 | Could not connect. | Yes, and the retry forces IPv4 if no stack was chosen. |
| 28 | Operation timed out. | Yes, up to three times. |
| 16 and 92 | An HTTP/2 or HTTP/2 stream error. | Yes, up to three times. |
| 56 and 35 | Receive error, or an SSL connect error. | Only when the text contains Connection reset by peer. |
| 55 | Send error. | Yes, unconditionally, working around a known curl issue. |
Common causes
DNS in the job is failing under Composer's own parallelism
Composer downloads with a curl multi handle and runs up to twelve requests at once by default. Twelve simultaneous resolutions of the same and different hosts is enough to expose a small or overloaded resolver, and the result is curl error 6 on some requests while others in the same run succeed. The pattern to look for is a failure that names a different package each time, which points at the resolver rather than at any one host.
IPv6 egress is advertised but not routed
A network that hands out AAAA records but cannot actually route them produces curl error 7 on the first attempt of each connection. Composer retries with IPv4 forced, so many of these recover silently and only the unlucky ones surface. The symptom is a build that is mysteriously slower than it used to be, with occasional failures, rather than a clean break.
A proxy is in the path and is slow rather than broken
An outbound proxy that accepts the connection and then takes its time gives you curl error 28. The distinguishing feature against a DNS problem is that the failure attaches to the large transfers, the distribution zips, rather than to the small metadata requests, because those are the ones with time to run out.
The repository throttled you with a 429
Composer does not retry 429, so the first throttled response ends the run. In our experience this is the version that looks like a sudden regression after a matrix was widened, because the number of jobs hitting one repository in a minute went up while nothing else changed. The error text here is a status code rather than a curl errno, which is itself the clue that you are on the status path and not the transport path.
How to fix it
Turn on verbosity before you decide anything
- Add
-vto the failing Composer call in a scratch branch. The retry notices are debug level, so this is the only way to see whether the single line in your log represents one attempt or four. - Read which URLs retried. If every retried URL is a different host, suspect the resolver. If they are all the same large archive, suspect a timeout or a proxy size limit.
- Leave
-von in CI permanently if the noise is acceptable. A failure that shows its attempts is worth several minutes of guessing.
- run: composer install --no-interaction --no-progress --prefer-dist -vLower the parallelism when DNS is the constraint
The maximum number of simultaneous HTTP jobs is read from COMPOSER_MAX_PARALLEL_HTTP and clamped between 1 and 50. Dropping it reduces the burst of resolutions and connections at the start of an install, which is often enough to make a marginal resolver stop failing. It costs wall clock time, so treat it as a diagnostic first and a permanent setting only if it works.
jobs:
build:
runs-on: ubuntu-latest
env:
COMPOSER_MAX_PARALLEL_HTTP: "6"
steps:
- uses: actions/checkout@v5
- run: composer install --no-interaction --no-progress --prefer-distCache the Composer directory so most runs make no requests
Keyed on composer.lock, a restored cache means an install that resolves nothing and downloads nothing. This removes the whole class of failure for unchanged dependency sets rather than making it less likely, which is a better trade than any retry setting.
- uses: actions/cache@v4
with:
path: ~/.cache/composer/files
key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }}
restore-keys: composer-${{ runner.os }}-Handle the 429 case outside Composer
Since Composer will not retry a 429, a repository that throttles needs either fewer concurrent jobs or an authenticated token with a higher allowance. Serialising the dependency-install jobs with a concurrency group is the change that does not require anyone to hold a credential, and it is reversible in one line.
concurrency:
group: composer-install-${{ github.repository }}
cancel-in-progress: falseWhat Composer already tried before it told you
The retry limit is a private property set to 3, so a retryable errno gets four attempts in total. The notice for each one is written through IOInterface::DEBUG, which means it is invisible at normal verbosity. Run the same command with -v and the log gains a Retrying (1) <url> due to curl error 6 line per attempt, and the failure stops looking instantaneous.
Status codes have their own list. A 4xx or 5xx response is retried only when the code is one of 423, 425, 500, 502, 503, 504, 507 or 510, plus a narrow special case for 400 from codeload.github.com, which intermittently returns that on reused connections. A 429 is not in the list. If Packagist or your private repository throttles you with a 429, Composer fails on the first response, so the advice to let Composer ride out a rate limit does not apply.
The IPv4 detail on error 7 deserves its own sentence because it produces a genuinely confusing symptom. When the connection could not be made and you have not pinned an address family, Composer sets the retry to resolve IPv4 only. On a runner with broken IPv6 egress, that means the first attempt fails and the second succeeds, so the build passes while quietly telling you, at debug verbosity, that your IPv6 path is dead.
composer install --no-interaction --no-progress -v 2>&1 | grep -E "Retrying|curl error"Why this page has no recorded run
Composer is not installed on the machine this page was written on, and a reconstruction that said otherwise would be the exact defect the rebuild exists to remove. The message is short and its assembly is two lines of PHP, so quoting the throw and naming what fills each slot loses nothing that a pasted log would have added.
What a runner reproduction would have contributed is a statement about how often this happens in one environment, which is a frequency claim, and frequency claims here have to come from recorded aggregates rather than from a single job. The rest of the page is the retry table, the status code list and the parallelism limit, all of which are constants in Composer at a named version and are better read than measured. Nothing here says Latchkey repairs the failure, because content/heal-evidence.mjs has no record for this slug.
How to prevent it
- Keep
-von the Composer step so a failure always shows how many attempts it really made. - Cache ~/.cache/composer/files keyed on composer.lock, so an unchanged lockfile does no network work.
- Pick a COMPOSER_MAX_PARALLEL_HTTP value deliberately rather than inheriting twelve by accident.
- Alert on the errno, not the word
curl, so a DNS failure and a timeout are two different signals.
Frequently asked questions
Does Composer retry failed downloads automatically?
-v.What does curl error 6 mean in Composer?
Why does Composer fail immediately on a 429?
Why did my build pass on the second attempt with no change?
-v.Related guides
References
- composer/composer: CurlDownloader, the retry lists and the TransportException text
- composer/composer: HttpDownloader, where COMPOSER_MAX_PARALLEL_HTTP is read and clamped
- Composer documentation: environment variables, including COMPOSER_MAX_PARALLEL_HTTP
- libcurl: the error code table behind the integer in the message
- Docker documentation
- Docker build cache
- GitHub Actions documentation