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:
> 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: ForbiddenThe 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:
Error 403 Forbidden: This IP has been blocked for excessive or automated consumption of Maven CentralMaven 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:
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:
| Version | On Maven Central (UTC) | On the mirror (UTC) | Delay |
|---|---|---|---|
| 2.57.9 | 4 Nov, 22:48 | 6 Nov, 02:15 | 27.5 h |
| 2.57.10 | 5 Nov, 23:17 | 7 Nov, 02:23 | 27 h |
| 2.57.11 | 6 Nov, 22:16 | 8 Nov, 02:23 | 28 h |
| 2.57.12 | 9 Nov, 21:56 | 11 Nov, 02:17 | 28.5 h |
| 2.57.13 | 11 Nov, 01:45 | not there at 09:27 | over 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:
// 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()andmaven { url = "https://repo1.maven.org/maven2/" }both count. It changes the URL in place, sogoogle()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:
- 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:
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 Central | Through the Plugin Portal | Build | |
|---|---|---|---|
| Nothing, Gradle 9.1.0 | 332 requests | 60 files | works |
| Gradle blog script, Google's mirror | 0 | 0 | fails on the first plugin |
Gradle 9.9.1, mirror from settings.xml | 0 | 52 files | works, no fallback |
| The script above | 0 downloads | 0 | works, 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:
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 -rnThe Redirect lines matter: without them, the portal's trips to Maven Central don't show. For the Flutter app, the output was:
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.orgNo 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.