Maven registry: RFC 9457 - Problem Details support

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

🔥 Problem

The GitLab Maven package registry cannot communicate meaningful error messages to Maven clients (mvn, gradle, sbt).

Currently, all error responses use Content-Type: application/json with a body like {"message": "403 Forbidden - Package protected."}. Maven clients discard this body entirely — they only read the HTTP status code and reason phrase. Users see generic output like:

[ERROR] Could not transfer artifact com.example:foo:pom:1.0 from/to gitlab (https://gitlab.example.com/...):
  status code: 403, reason phrase: Forbidden (403)

On HTTP/2 connections (RFC 9113), it's even worse — reason phrases don't exist, so users see only:

[ERROR] Could not transfer artifact com.example:foo:pom:1.0 from/to gitlab (https://gitlab.example.com/...):
  HTTP Status: 403

This means protected package errors, permission issues, size limit violations, and validation failures all produce the same unhelpful output. Users have no way to understand why an operation failed without inspecting server logs.

💡 Solution

Adopt RFC 9457 — Problem Details for HTTP APIs for Maven package registry error responses.

When enabled, all 4xx/5xx responses from Maven endpoints will return:

  • Content-Type: application/problem+json (exact value, no parameters)
  • A JSON body with type, status, title, and detail fields

🔍 Example

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "about:blank",
  "status": 403,
  "title": "Forbidden",
  "detail": "Package protected."
}

Maven 3.9.11+ users will then see:

[ERROR] Could not transfer artifact com.example:foo:pom:1.0 from/to gitlab (https://gitlab.example.com/...):
  RFC9457Payload {type=about:blank, status=403, title='Forbidden', detail='Package protected.'}

⚙️ Design details

  • Use about:blank for the type field per RFC 9457 section 4.2.1 (standard HTTP errors). Custom URIs can be added later.
  • Feature-flagged as maven_problem_details_errors (default disabled for gradual rollout).
  • The detail field MUST NOT leak internal infrastructure details (hostnames, stack traces, etc.).

🛡️ Backward compatibility

This change is fully backward-compatible:

  • Older Maven clients (pre-3.9.11) ignore the response body on errors entirely — they only read the status code and reason phrase. Changing the body format and Content-Type has zero impact.
  • Gradle 8.x behaves the same way — ignores error bodies.
  • sbt/Coursier ignores error bodies for resolution (GET). For publish (PUT), sbt displays up to 1024 chars of the response body as plain text — the RFC 9457 JSON will appear as raw JSON, which is acceptable.

📡 How Maven clients handle HTTP errors

There are three mechanisms a server can use to communicate error information:

  1. HTTP status code — always available, all clients use it
  2. HTTP/1.1 reason phrase — deprecated in HTTP/1.1 (RFC 9112), absent in HTTP/2 (RFC 9113)
  3. RFC 9457 Problem Details — structured JSON body with Content-Type: application/problem+json

Maven (Apache Maven Resolver)

Maven Resolver supports RFC 9457 since version 1.9.24 (MRESOLVER-600), backported from 2.0.2.

When the server returns Content-Type: application/problem+json, Maven Resolver:

  1. Detects the content type via exact string comparison ("application/problem+json".equals(contentType))
  2. Reads and parses the body as JSON into type, status, title, detail, instance
  3. Throws HttpRFC9457Exception with the structured payload
  4. User sees the details in the "Could not transfer artifact..." error output

Critical implementation detail: Content-Type MUST be exactly application/problem+json with no parameters. application/problem+json; charset=utf-8 will NOT match due to exact string comparison in RFC9457Reporter. This is a known limitation in Maven Resolver.

Without RFC 9457, Maven falls back to status code + reason phrase. On HTTP/2 (no reason phrases), users see only the numeric code.

Maven Version Resolver Version Transport RFC 9457 Support Error Display
3.8.x and earlier 1.6.x-1.8.x Wagon (default) No Status code + reason phrase
3.9.0 - 3.9.10 1.9.0-1.9.23 HTTP transport No Status code + reason phrase
3.9.11+ 1.9.24+ HTTP transport Yes RFC 9457 detail field displayed
4.0.0-beta-5+ 2.0.2+ Apache/JDK transport Yes (GET only with JDK transport) RFC 9457 detail field displayed

Maven Wagon (legacy): Never reads the response body on errors. Calls EntityUtils.consumeQuietly() and constructs messages from URL, status code, reason phrase, and proxy info only. No RFC 9457 support.

JDK transport gaps (Maven 4): RFC 9457 is only checked during GET operations, not PUT or PEEK. PUT operations use HttpResponse.BodyHandlers.discarding().

References:

🐘 Gradle

Gradle does NOT support RFC 9457 today. Error display uses status code + reason phrase.

RFC 9457 support is in progress for Gradle 9.x (gradle/gradle#36203), citing MRESOLVER-600 as prior art.

Gradle Version RFC 9457 Support Error Display
Current (8.x) No Status code + reason phrase
9.x (planned) Planned RFC 9457 detail field

🛠️ sbt / Coursier

Operation Client RFC 9457 Support Error Display
Resolution (GET) Coursier No Status code only - body and reason phrase discarded
Publish (PUT) Ivy transport No (reads plain text) Up to 1024 chars of response body as plain text (sbt#8537)

Coursier's Downloader.scala (doDownload method) immediately returns error objects based on status code without reading the response body or reason phrase. The error stream is explicitly closed without reading. No feature request for RFC 9457 exists.

For sbt publish: the RFC 9457 JSON body will be displayed as raw JSON. The detail field should be placed early and be human-readable even without JSON parsing.

📊 Summary

Client GET/HEAD errors PUT errors
Maven 3.9.11+ RFC 9457 parsed and displayed RFC 9457 parsed (Apache transport)
Maven 3.9.10 and earlier Status code + reason phrase only Status code + reason phrase only
Gradle (current 8.x) Status code + reason phrase only Status code + reason phrase only
Gradle 9.x (planned) RFC 9457 planned RFC 9457 planned
sbt/Coursier Status code only Plain-text body shown (up to 1024 chars)

🔧 Implementation approach

Create a helper module API::Helpers::Packages::Maven::ProblemDetails that overrides render_structured_api_error! — the single method all error helpers flow through:

forbidden!("reason") / not_found!("Package") / bad_request!("reason") / ...
  -> render_api_error_with_reason! or render_api_error!
    -> render_structured_api_error!({ 'message' => "4xx ... - reason" }, status)
      -> error!(hash, status, header)   # Grape's method

The override:

  1. Checks the maven_problem_details_errors feature flag
  2. Only transforms 4xx/5xx responses (not 2xx like no_content! which also flows through this path)
  3. Extracts a clean detail from the legacy message string by stripping the "NNN StatusPhrase - " prefix
  4. Sets Content-Type: application/problem+json and constructs the RFC 9457 body
  5. When the flag is disabled, calls super — zero behavioral change

Include this module in the Maven API classes only — no impact on other API endpoints.

📁 Impacted files

🆕 New files

File Purpose
config/feature_flags/development/maven_problem_details_errors.yml Feature flag definition
lib/api/helpers/packages/maven/problem_details.rb Helper module overriding render_structured_api_error!
spec/lib/api/helpers/packages/maven/problem_details_spec.rb Unit tests for the helper

✏️ Modified files

File Change
lib/api/maven_packages.rb Include the helper module
ee/lib/api/dependency_proxy/packages/maven.rb Include the helper module
ee/lib/api/virtual_registries/packages/maven/endpoints.rb Include the helper module
spec/requests/api/maven_packages_spec.rb Add RFC 9457 shared examples + feature flag contexts
ee/spec/requests/api/maven_packages_spec.rb Same
ee/spec/requests/api/dependency_proxy/packages/maven_spec.rb Same

🚨 Error scenarios in lib/api/maven_packages.rb

Status Trigger Expected detail
400 ActiveRecord::RecordInvalid Validation message
400 bad_request!('File is too large') "File is too large"
401 unauthorized! (no current_user) (none)
403 forbidden! (path doesn't exist) (none)
403 forbidden!(result.errors.first) (protected package) "Package protected."
404 not_found!('Package') "Package not found"
404 not_found!('Group') "Group not found"
404 not_found!('Project') "Project not found"
409 conflict! (SHA verification mismatch) (none)
422 unprocessable_entity! (FIPS mode MD5 rejection) (none)

Verification plan

🌐 curl verification (GDK)

# Enable feature flag
# Rails console: Feature.enable(:maven_problem_details_errors)

# 401 - no token
curl -v "http://gdk.test:3000/api/v4/projects/1/packages/maven/com/example/foo/1.0/foo.jar"
# Expected: {"type":"about:blank","status":401,"title":"Unauthorized"}

# 403 - protected package upload
curl -v -X PUT -H "Private-Token: $TOKEN" \
  "http://gdk.test:3000/api/v4/projects/1/packages/maven/com/example/protected/1.0/foo.jar/authorize"
# Expected: {"type":"about:blank","status":403,"title":"Forbidden","detail":"Package protected."}

# 404 - nonexistent package
curl -v -H "Private-Token: $TOKEN" \
  "http://gdk.test:3000/api/v4/projects/1/packages/maven/com/example/nonexistent/1.0/foo.jar"
# Expected: {"type":"about:blank","status":404,"title":"Not Found","detail":"Package not found"}

# CRITICAL: verify Content-Type has no parameters
curl -sI -H "Private-Token: invalid" \
  "http://gdk.test:3000/api/v4/projects/1/packages/maven/com/example/foo/1.0/foo.jar" \
  | grep -i content-type
# MUST be exactly: Content-Type: application/problem+json
# MUST NOT be: Content-Type: application/problem+json; charset=utf-8

📦 Client versions to test

Client Version Purpose
Maven 3.9.11+ First version with RFC 9457 support - primary test target
Maven 3.8.8 Pre-RFC 9457 baseline - verify backward compatibility
Maven 4.0.0+ (if available) New resolver with JDK transport
Gradle current 8.x Verify no breakage (ignores error body)
sbt current Verify no breakage for resolution; check publish raw body display

🧪 Test scenarios

For each client version, test with feature flag enabled and disabled:

  1. Authentication failure (401) - wrong/missing token; verify error detail displayed (Maven 3.9.11+) or graceful fallback (older)
  2. Permission denied (403) - token without package read permission
  3. Protected package (403) - attempt upload to protected package; verify "Package protected." detail
  4. Package not found (404) - resolve nonexistent artifact
  5. Upload too large (400) - upload exceeding size limit
  6. SHA mismatch (409) - upload with mismatched checksum
  7. FIPS MD5 rejection (422) - if applicable

🔮 Expected output

Maven 3.9.11+ with flag enabled:

[ERROR] Could not transfer artifact com.example:foo:pom:1.0 from/to gitlab (http://gdk.test:3000/...):
  RFC9457Payload {type=about:blank, status=403, title='Forbidden', detail='Package protected.'}

Maven 3.8.x with flag enabled (backward compat):

[ERROR] Could not transfer artifact com.example:foo:pom:1.0 from/to gitlab (http://gdk.test:3000/...):
  status code: 403, reason phrase: Forbidden (403)

Old clients ignore the application/problem+json body entirely and fall back to status code + reason phrase — no breakage.

Edited by 🤖 GitLab Bot 🤖