Maven registry: RFC 9457 - Problem Details support
<!--IssueSummary start--> <details> <summary> Everyone can contribute. [Help move this issue forward](https://handbook.gitlab.com/handbook/marketing/developer-relations/contributor-success/community-contributors-workflows/#contributor-links) while earning points, leveling up and collecting rewards. </summary> - [Collaborate/take over this issue](https://contributors.gitlab.com/manage-issue?action=work&projectId=278964&issueIid=595487) </details> <!--IssueSummary end--> ## :fire: 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. ## :bulb: Solution Adopt [RFC 9457 — Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457) 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 ### :mag: Example ```http 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.'} ``` ### :gear: 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.). ### :shield: 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. ## :link: Client compatibility matrix ### :satellite: 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` ### :coffee: Maven (Apache Maven Resolver) Maven Resolver supports RFC 9457 since version 1.9.24 ([MRESOLVER-600](https://www.mail-archive.com/issues@maven.apache.org/msg282867.html)), 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](https://github.com/apache/maven-resolver/pull/576) 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: - [MRESOLVER-600](https://www.mail-archive.com/issues@maven.apache.org/msg282867.html) - RFC 9457 implementation - [apache/maven-resolver#576](https://github.com/apache/maven-resolver/pull/576) - implementation PR - [apache/maven#2454](https://github.com/apache/maven/issues/2454) - Maven 4 RC-3 shows "HTTP Status: 500" with no body/reason ### :elephant: 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](https://github.com/gradle/gradle/issues/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 | ### :hammer_and_wrench: 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](https://github.com/sbt/sbt/pull/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. ### :bar_chart: 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) | ## :wrench: 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. ## :file_folder: Impacted files ### :new: 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 | ### :pencil2: 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 | ### :rotating_light: 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)_ | ## :white_check_mark: Verification plan ### :globe_with_meridians: curl verification (GDK) ```bash # 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 ``` ### :package: 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_tube: 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 ### :crystal_ball: 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.
task