Skip to content
All articles

10 min read

Maven Central 403 and 429 in GitHub Actions: route Gradle through a mirror

Gradle on GitHub Actions gets Maven Central 403 Forbidden or 429? Why a cache won't help, and an init script with Google's mirror and Maven Central last.

By Yevhen Beshkarov

  • troubleshooting
  • ci
  • android

A Gradle build on a GitHub-hosted runner fails with a Maven Central 403: Received status code 403 from server: Forbidden for a file on repo.maven.apache.org. Or it gets 429, Too Many Requests. Nothing is wrong with your build. Maven Central limits the IP addresses that download the most, and GitHub's runners share their addresses with everyone else's jobs. A cache and retries don't fix it. Downloading less from Maven Central does: a Gradle init script that sends the downloads to Google's mirror of Maven Central, keeps Maven Central last for what the mirror doesn't have yet, and closes the path through the Gradle Plugin Portal.

The CI of SMF builds Flutter apps for Android in about 60 parallel jobs every night, and it has used this script since 2 October 2026. Since then it has run at least 3,917 Android jobs without one 403 or 429. The numbers below come from a fresh flutter create app (Flutter 3.44.2, Android Gradle plugin 9.0.1, Kotlin 2.3.20), built on my Mac with an empty Gradle home on 11 November 2026, with Gradle 9.1.0 and 9.9.1. The script itself has nothing specific to Flutter.

Received status code 403 from server: Forbidden

This is how it looked in our nightly run:

Text
> Could not resolve all files for configuration ':app:debugRuntimeClasspath'.
   > Could not download listenablefuture-1.0.jar (com.google.guava:listenablefuture:1.0)
      > Could not get resource 'https://repo.maven.apache.org/maven2/com/google/guava/listenablefuture/1.0/listenablefuture-1.0.jar'.
         > Could not GET 'https://repo.maven.apache.org/maven2/com/google/guava/listenablefuture/1.0/listenablefuture-1.0.jar'. Received status code 403 from server: Forbidden

The rate limit looks the same, with Received status code 429 from server: Too Many Requests. Maven reports both as status code: 403, reason phrase: Forbidden (403) or 429, reason phrase: Too Many Requests (429). Gradle doesn't show the body of the answer, but if you request the file with curl from a blocked address, Sonatype, which runs Maven Central, now explains it there:

Text
Error 403 Forbidden: This IP has been blocked for excessive or automated consumption of Maven Central

Maven Central 403 vs 429

Since 2026, Sonatype treats the two codes as different signals, as its FAQ on 429 errors and on 403 errors explains.

A 429 means you hit a consumption limit. The block is temporary, but it comes back and gets longer while the traffic stays the same, up to 30 days. Sonatype doesn't publish the limits, and it says that retries make things worse, so adding them to the workflow is the wrong fix.

A 403 means the traffic counts as abuse. Such a block may cover a subnet, and in extreme cases a whole autonomous system (ASN).

The terms of service of 31 March 2026 describe the model behind this: Maven Central stays free at levels that Sonatype considers reasonable, and high-volume consumers either reduce their traffic or pay.

Why GitHub-hosted runners get blocked

A job on a hosted runner leaves through IP addresses that many other jobs use too, and Sonatype counts the traffic per address and, since 2025, per organization behind several addresses. On 6 January 2026, Maven Central answered GitHub Actions with 403 for about four hours. The status page gives the cause: security rules of its CDN that GitHub Actions addresses triggered. The issue about it, actions/runner#4180, is still open with 75 upvotes, and the only reply from GitHub links that status page.

Other CI services handled it on their side. In May 2026, Sonatype allow-listed the dedicated IP ranges of CircleCI, and Bitrise has routed Gradle builds through its own proxy by default since 30 April 2026. GitHub has an agreement that exempts its runners from the rate limits of Docker Hub, but I found nothing like it for Maven Central, so on GitHub Actions the fix is up to you.

It happens more and more. GitHub issues that mention repo.maven.apache.org and Forbidden went from 11 in 2024 to 45 in 2025 and 108 in 2026, up to 11 November. Even the CI of Gradle itself ran into 429 in September (gradle/gradle#39257).

Why the Gradle cache does not fix the Maven Central 403

Our job above did restore a cache of Gradle, just not its own. When a job doesn't find the cache for its key, actions/cache restores the newest one under a shorter prefix, and that came from an app with Firebase. In that app, Guava replaces com.google.guava:listenablefuture:1.0 with an empty version, 9999.0-empty-to-avoid-conflict-with-guava, so the cache had no listenablefuture-1.0.jar. Gradle went to Maven Central for it. In that run, 27 Android jobs topped up their caches from Maven Central, and one of them got the 403.

A cache cuts the number of downloads. It doesn't take Maven Central out of the build: every new dependency, every new version, and every cache that misses a file still goes there.

Google's mirror of Maven Central

Google keeps a copy of Maven Central in Cloud Storage, in three regions:

Text
https://maven-central.storage-download.googleapis.com/maven2/
https://maven-central-eu.storage-download.googleapis.com/maven2/
https://maven-central-asia.storage-download.googleapis.com/maven2/

Maven Central lists these three in its own repository-metadata.xml. Google's page about the mirror says that it isn't an officially supported product, so there is no SLA.

The page also says that the American copy is updated 12 times a day. That isn't what I measured. I compared the Last-Modified dates of the daily releases of software.amazon.awssdk:s3 on both hosts:

VersionOn Maven Central (UTC)On the mirror (UTC)Delay
2.57.94 Nov, 22:486 Nov, 02:1527.5 h
2.57.105 Nov, 23:177 Nov, 02:2327 h
2.57.116 Nov, 22:168 Nov, 02:2328 h
2.57.129 Nov, 21:5611 Nov, 02:1728.5 h
2.57.1311 Nov, 01:45not there at 09:27over 7.5 h

New files reach the mirror once a day, at about 02:15 UTC, a day or more after they appear on Maven Central. A build whose dependency came out yesterday fails on the mirror alone. We hit that on 22 October: purchases_flutter 10.18.1 needed com.revenuecat.purchases:purchases-hybrid-common:19.15.0, the mirror answered 404, and 10 jobs failed with Could not find. So the mirror goes first, and Maven Central stays last for what the mirror doesn't have yet.

The init script: mirror first, Maven Central last

Put this file into ~/.gradle/init.d/ on the runner, and Gradle applies it to every build there, whatever the build declares:

maven_central_mirror.gradle
// Takes what Gradle would download from Maven Central from Google's mirror,
// and from Maven Central only what the mirror does not have yet.

def central = ~/^https:\/\/(repo\.maven\.apache\.org|repo1\.maven\.org)\/maven2\/?/
def mirror = 'https://maven-central.storage-download.googleapis.com/maven2/'
def fallback = 'mavenCentralBehindMirror'

def addFallback = { RepositoryHandler repositories ->
    repositories.maven {
        name = fallback
        url = 'https://repo.maven.apache.org/maven2/'
    }
}

def toMirror = { RepositoryHandler repositories ->
    def hasFallback = false
    // all, not configureEach: a configureEach action may not add or move a repository.
    repositories.all { repository ->
        if (repository.name == fallback) {
            return
        }
        if (repository instanceof MavenArtifactRepository) {
            def url = repository.url.toString()
            if (url =~ central) {
                // In place, so the order of the repositories stays.
                repository.url = url.replaceFirst(central, mirror)
                if (!hasFallback) {
                    hasFallback = true
                    addFallback(repositories)
                    return
                }
            }
        }
        // Keep Maven Central last when the build adds a repository later.
        def last = repositories.findByName(fallback)
        if (hasFallback && !repositories.last().is(last)) {
            repositories.remove(last)
            repositories.add(last)
        }
    }
}

beforeSettings { settings ->
    toMirror(settings.buildscript.repositories)
    toMirror(settings.pluginManagement.repositories)
    toMirror(settings.dependencyResolutionManagement.repositories)
}

settingsEvaluated { settings ->
    // A build without plugin repositories goes to the Gradle Plugin Portal,
    // which redirects to Maven Central whatever it does not host itself.
    if (settings.pluginManagement.repositories.isEmpty()) {
        settings.pluginManagement.repositories { repositories ->
            maven { url = mirror }
            addFallback(repositories)
            gradlePluginPortal()
        }
    }
}

allprojects { project ->
    toMirror(project.buildscript.repositories)
    toMirror(project.repositories)
}

What it does:

  • It finds Maven Central by its URL, not by the name of the repository, so mavenCentral() and maven { url = "https://repo1.maven.org/maven2/" } both count. It changes the URL in place, so google() and the other repositories keep their order.
  • Every list of repositories in which it changed one gets Maven Central itself at the end. Gradle asks the repositories of a list in order and moves on when one doesn't have a module, so Maven Central gets only what nothing before it has. The script keeps it last when the build adds a repository later, as the Gradle plugin of Flutter does with its own storage.
  • It covers the settings, the build scripts and the projects of every build, including included builds such as the one of the Gradle plugin of Flutter.
  • A build that declares no plugin repositories gets the mirror and Maven Central before the Gradle Plugin Portal. The next section explains why.

The workflow puts the file into place before the first step that runs Gradle:

.github/workflows/build.yml
- name: Take what Gradle downloads from Maven Central from its mirror
  run: |
    mkdir -p ~/.gradle/init.d
    cp .github/gradle/maven_central_mirror.gradle ~/.gradle/init.d/

If your job sets GRADLE_USER_HOME, the script goes into init.d there. gradle/actions/setup-gradle adds an init script of its own to the same directory, and Gradle runs both.

The Gradle Plugin Portal sends you back to Maven Central

A mirror for mavenCentral() isn't enough. Since July 2024, the Gradle Plugin Portal answers 303 See Other with an address on repo.maven.apache.org for every artifact it doesn't host itself. The portal serves the jar of the Kotlin Gradle plugin, but not the plugin's dependencies: kotlin-gradle-plugin-api, kotlin-stdlib, kotlinx-coroutines-core-jvm, Gson and others. Gradle follows the redirect without a word, so the output only shows plugins.gradle.org.

In the Flutter app, 60 files reached Maven Central this way. A build that declares no plugin repositories in pluginManagement, such as the included build of the Gradle plugin of Flutter, uses the portal without listing it, so no rewrite of a URL reaches it. That's what the settingsEvaluated block is for: the mirror and Maven Central go in front of the portal, and the portal only gets the plugin markers and jars that it hosts itself.

Don't point the portal at the mirror instead. Google's mirror has only Maven Central, and the build fails on the first plugin it looks up.

Gradle 9.8 and the init script of the Gradle blog

Gradle has two answers of its own, and neither handles the lag of the mirror or the portal.

The Gradle blog post Good Neighbors (August 2025) has an init script that points the repositories named MavenRepo and Gradle Central Plugin Repository at one URL. It is written for an internal proxy that serves both Maven Central and the Plugin Portal. With Google's mirror as that URL, the Flutter build fails right away:

Text
Plugin [id: 'org.gradle.kotlin.kotlin-dsl', version: '6.2.0'] was not found in any of the following sources:
...
    Gradle Central Plugin Repository(https://maven-central.storage-download.googleapis.com/maven2/)

When I let it change only MavenRepo, the build worked, but the 60 redirects through the portal stayed.

Gradle 9.8.0, released on 24 September 2026, can read <mirror> entries from Maven's ~/.m2/settings.xml when org.gradle.mirror.maven.settings=true is set. The feature is incubating. With <mirrorOf>central</mirrorOf> pointing at Google's mirror, Gradle 9.9.1 took nothing directly from Maven Central, but 52 files still came from there through the portal. And a version published that morning, org.jetbrains.kotlin:kotlin-stdlib:2.5.0-RC, failed to resolve, since a Maven mirror replaces the repository and leaves no fallback. A new Flutter app doesn't have the feature until you update its Gradle wrapper: flutter create writes Gradle 9.1.0 with Flutter 3.44 and 9.3.1 with 3.47.

Directly from Maven CentralThrough the Plugin PortalBuild
Nothing, Gradle 9.1.0332 requests60 filesworks
Gradle blog script, Google's mirror00fails on the first plugin
Gradle 9.9.1, mirror from settings.xml052 filesworks, no fallback
The script above0 downloads0works, with fallback

Check that nothing reaches Maven Central

Run the build once with an empty Gradle home and the script in it, then count the hosts:

Shell
export GRADLE_USER_HOME="$(mktemp -d)"
mkdir -p "$GRADLE_USER_HOME/init.d"
cp maven_central_mirror.gradle "$GRADLE_USER_HOME/init.d/"
./gradlew --debug assembleDebug 2>&1 \
  | grep -oE "Performing HTTP (GET|HEAD): https://[^/]+|Redirect requested to location 'https://[^/]+" \
  | sort | uniq -c | sort -rn

The Redirect lines matter: without them, the portal's trips to Maven Central don't show. For the Flutter app, the output was:

Text
 387 Performing HTTP GET: https://maven-central.storage-download.googleapis.com
 380 Performing HTTP GET: https://dl.google.com
  14 Performing HTTP HEAD: https://dl.google.com
   8 Performing HTTP GET: https://storage.googleapis.com
   4 Performing HTTP GET: https://plugins.gradle.org
   3 Redirect requested to location 'https://plugins-artifacts.gradle.org
   2 Performing HTTP HEAD: https://maven-central.storage-download.googleapis.com
   2 Performing HTTP GET: https://repo.maven.apache.org
   1 Performing HTTP HEAD: https://plugins.gradle.org

No redirect leads to Maven Central. The two requests to repo.maven.apache.org are 404s, not downloads: the org.gradle.kotlin.kotlin-dsl plugin and its marker are neither on the mirror nor on Maven Central, so Gradle finds them on the portal, which comes next in the list.

How SMF keeps its CI off Maven Central

SMF is a free, open-source Flutter project generator, and its CI generates and builds apps from combinations of its modules for pull requests and every night. Every job that runs Gradle on Linux first puts the same script into ~/.gradle/init.d, and a test of the workflows fails when a new job forgets it. From 2 October to 11 November 2026, that was at least 3,917 Android jobs with no 403 and no 429. The one failure from a Maven repository in that time was the lag of the mirror on 22 October, and that's why Maven Central now stays last. Since the shared_preferences release build crash, which no debug build shows, the same CI also starts a generated app as a release build on an emulator.

Back to all articles

Release notes by email

Get an email when SMF or its modules have a new release.

We use Resend. You can unsubscribe at any time.