Skip to content
Latchkey LogoLatchkey home

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.

Composer curl errno table beside which numbers are retried and the IPv4 fallback branch
Composer retries a fixed set of curl error numbers on GET requests only, up to three times. The middle panel is the branch nobody expects: on error 7 the second attempt is made with the address family forced to IPv4.

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.

Reconstructed from the TransportException throw in CurlDownloader::tick, Composer 2.10.3, with the description after the colon as libcurl prints it for errno 6, observed with curl on 2026-09-21
curl error 6 while downloading https://repo.packagist.org/packages.json: Could not resolve host: repo.packagist.org

The 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.

errnoWhat libcurl meansRetried by Composer on a GET?
6Could not resolve host.Yes, up to three times.
7Could not connect.Yes, and the retry forces IPv4 if no stack was chosen.
28Operation timed out.Yes, up to three times.
16 and 92An HTTP/2 or HTTP/2 stream error.Yes, up to three times.
56 and 35Receive error, or an SSL connect error.Only when the text contains Connection reset by peer.
55Send 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

  1. Add -v to 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.
  2. 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.
  3. Leave -v on in CI permanently if the noise is acceptable. A failure that shows its attempts is worth several minutes of guessing.
.github/workflows/ci.yml
- run: composer install --no-interaction --no-progress --prefer-dist -v

Lower 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.

.github/workflows/ci.yml
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-dist

Cache 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.

.github/workflows/ci.yml
- 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.

.github/workflows/ci.yml
concurrency:
  group: composer-install-${{ github.repository }}
  cancel-in-progress: false

What 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.

Terminal
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 -v on 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?
Yes, for a fixed list of curl error numbers on GET requests, up to three times for four attempts in total. Error 6, 7, 16, 28 and 92 always qualify, 56 and 35 qualify when the text mentions a connection reset, and 55 is retried unconditionally. The notices are written at debug verbosity, so you only see them with -v.
What does curl error 6 mean in Composer?
It is libcurl's could-not-resolve-host code, so the request never got as far as a connection. The words after the colon in Composer's message come from libcurl's error buffer and usually name the host that failed to resolve. Because Composer retries this errno, a failure means resolution failed on all four attempts, which points at the resolver rather than at a momentary blip.
Why does Composer fail immediately on a 429?
Because 429 is not in the retryable status list. Composer retries 423, 425, 500, 502, 503, 504, 507 and 510, plus 400 from codeload.github.com specifically, and nothing else. A throttled response therefore ends the run on the first answer, which is why rate limiting looks like a hard failure in Composer where it looks like a slow success in some other package managers.
Why did my build pass on the second attempt with no change?
If the errno was 7, Composer may have retried with the address family forced to IPv4, because it sets that on the retry whenever the connection failed and no stack was explicitly selected. That makes a broken IPv6 path self-heal inside the run, at the cost of the failed first attempt, and it is invisible unless you are running with -v.

Related guides

References

One curl error in the log was four requests. Latchkey runners repair transient failures and report the rest. Start free → 30-day trial · No credit card