RubyGems registry: set X-Error-Message response header on error responses
## :fire: Problem
The GitLab RubyGems package registry cannot communicate meaningful error messages to `gem install` / `gem fetch` users.
Currently, all error responses use `Content-Type: application/json` with a body like `{"message": "403 Forbidden - Package protected."}`. The `gem` client **never reads the response body** during install/fetch operations — it only reads the HTTP status code and reason phrase. Users see generic output like:
```
ERROR: While executing gem ... (Gem::RemoteFetcher::FetchError)
Bad response Forbidden 403 (https://gitlab.example.com/api/v4/projects/1/packages/rubygems/gems/foo-1.0.gem)
```
This means protected package errors, permission issues, size limit violations, and dependency resolution failures all produce the same unhelpful output. Users have no way to understand **why** an operation failed without inspecting server logs.
**Note:** `gem push` already displays the response body as plain text via `clean_text()` sanitization, so the response body is partially visible for push operations. However, GitLab returns JSON, so push users currently see raw JSON like `{"message":"400 Bad request - File is too large"}`.
## :bulb: Solution
Set the [`X-Error-Message`](https://github.com/rubygems/rubygems/blob/master/lib/rubygems/remote_fetcher.rb) response header on all 4xx/5xx responses from RubyGems package registry endpoints.
The `gem` client (via `Gem::RemoteFetcher#fetch_http`) explicitly checks for this header and uses its value instead of the HTTP reason phrase when constructing error messages:
```ruby
# lib/rubygems/remote_fetcher.rb (rubygems source)
custom_error = response["X-Error-Message"]
error_detail = custom_error || response.message
raise FetchError.new("Bad response #{error_detail} #{response.code}", uri)
```
### :mag: Example
```http
HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Error-Message: Package protected.
{"message":"403 Forbidden - Package protected."}
```
`gem install` users will then see:
```
ERROR: While executing gem ... (Gem::RemoteFetcher::FetchError)
Bad response Package protected. 403 (https://gitlab.example.com/api/v4/projects/1/packages/rubygems/gems/foo-1.0.gem)
```
Instead of the current generic:
```
ERROR: While executing gem ... (Gem::RemoteFetcher::FetchError)
Bad response Forbidden 403 (https://gitlab.example.com/api/v4/projects/1/packages/rubygems/gems/foo-1.0.gem)
```
### :gear: Design details
- Feature-flagged as `rubygems_error_message_header` (default disabled for gradual rollout).
- The `X-Error-Message` header value is extracted from the error message by stripping the `"NNN StatusPhrase - "` prefix (e.g., `"403 Forbidden - Package protected."` → `"Package protected."`).
- When the error message has no detail beyond the status phrase (e.g., `"403 Forbidden"`), the header is **not** set — the `gem` client will fall back to the HTTP reason phrase, which is equivalent.
- The header value MUST NOT leak internal infrastructure details (hostnames, stack traces, etc.).
- The response body format is unchanged — the JSON `{"message": "..."}` body remains for API consumers.
### :shield: Backward compatibility
This change is fully backward-compatible:
- **Response body unchanged** — existing API consumers and scripts that parse the JSON body are unaffected.
- **Unknown headers ignored** — HTTP clients that don't recognize `X-Error-Message` simply ignore it.
- **Bundler** does not read this header (confirmed in source code analysis) — no impact on `bundle install`.
- **`gem push`** does not read response headers on errors (only reads `response.body`) — no impact on push behavior.
## :link: Client compatibility matrix
### :satellite: How RubyGems ecosystem clients handle HTTP errors
There are four 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** — used as fallback by `gem` client, not used by `bundler`
3. **`X-Error-Message` response header** — read by `gem install`/`gem fetch` only
4. **Response body** — read by `gem push`/`gem yank` as plain text, partially by `bundler`
### :gem: RubyGems (`gem`)
The `gem` client has two distinct error handling paths depending on the operation:
**Install/Fetch path** (`Gem::RemoteFetcher#fetch_http`):
1. Sends HTTP request for gem/gemspec download
2. On non-2xx response, reads `response["X-Error-Message"]` header
3. If present, uses it as error detail; otherwise falls back to `response.message` (HTTP reason phrase)
4. Raises `FetchError` with message `"Bad response #{error_detail} #{response.code}"`
5. `FetchError` auto-redacts credentials from URIs
**Push/Yank path** (`GemcutterUtilities#with_response`):
1. On non-success response, reads `response.body` directly
2. Passes body through `clean_text()` (strips control characters via regex: `/[\000-\b\v-\f\016-\037\177]/`)
3. Displays sanitized body to user
4. Does **NOT** read any response headers for error information
| Operation | Reads `X-Error-Message`? | Reads response body? | Error display |
|-----------|-------------------------|---------------------|---------------|
| `gem install` | **Yes** | No | `"Bad response {header or reason} {code}"` |
| `gem fetch` | **Yes** | No | `"Bad response {header or reason} {code}"` |
| `gem push` | No | **Yes** (via `clean_text()`) | Raw response body |
| `gem yank` | No | **Yes** (via `clean_text()`) | Raw response body |
**References:**
- [`Gem::RemoteFetcher#fetch_http`](https://github.com/rubygems/rubygems/blob/master/lib/rubygems/remote_fetcher.rb) — X-Error-Message handling
- [`GemcutterUtilities#with_response`](https://github.com/rubygems/rubygems/blob/master/lib/rubygems/gemcutter_utilities.rb) — push/yank error display
- [`Gem::Text#clean_text`](https://github.com/rubygems/rubygems/blob/master/lib/rubygems/text.rb) — control character sanitization
### :package: Bundler
Bundler does **NOT** support the `X-Error-Message` header.
Bundler uses its own HTTP stack (`Bundler::Fetcher::Downloader`) which handles errors based on HTTP status codes and exception types:
- **401**: Raises `AuthenticationRequiredError` with actionable guidance
- **403**: Raises `AuthenticationForbiddenError` with host info
- **404**: Raises `FallbackError` (falls back to full index)
- **429**: Raises `TooManyRequestsError`
- **Other errors**: Wraps in `HTTPError` with `response.body`
The only response header Bundler reads during error handling is `location` (for redirects). No custom error headers are supported.
| Operation | Reads `X-Error-Message`? | Error display |
|-----------|-------------------------|---------------|
| `bundle install` | No | Status code-based exception with generic message |
| `bundle update` | No | Status code-based exception with generic message |
| `bundle add` | No | Status code-based exception with generic message |
**Known limitation:** [rubygems/rubygems#3805](https://github.com/rubygems/rubygems/issues/3805) — Bundler error messages lack specificity about underlying causes.
**References:**
- [`Bundler::Fetcher::Downloader#fetch`](https://github.com/rubygems/rubygems/blob/master/bundler/lib/bundler/fetcher/downloader.rb) — HTTP error handling
### :bar_chart: Summary
| Client | `gem install` / `gem fetch` | `gem push` / `gem yank` | `bundle install` |
|--------|---------------------------|------------------------|-------------------|
| **Reads X-Error-Message** | **Yes** | No | No |
| **Reads response body** | No | Yes (plain text) | Partial (some error types) |
| **Reads reason phrase** | Yes (fallback) | No | No |
| **Impact of this change** | **Error detail displayed** | None | None |
## :wrench: Implementation approach
Create a helper module `API::Helpers::Packages::Rubygems::ErrorMessageHeader` 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 `rubygems_error_message_header` feature flag
2. Only processes 4xx/5xx responses (not 2xx)
3. Extracts a clean detail from the message string by stripping the `"NNN StatusPhrase - "` prefix
4. Sets `header['X-Error-Message'] = detail` when a meaningful detail exists
5. When the flag is disabled, calls `super` — zero behavioral change
Include this module in the `API::RubygemPackages` class only — no impact on other API endpoints.
## :file_folder: Impacted files
### :new: New files
| File | Purpose |
|------|---------|
| `config/feature_flags/development/rubygems_error_message_header.yml` | Feature flag definition |
| `lib/api/helpers/packages/rubygems/error_message_header.rb` | Helper module overriding `render_structured_api_error!` to set `X-Error-Message` header |
| `spec/lib/api/helpers/packages/rubygems/error_message_header_spec.rb` | Unit tests for the helper |
### :pencil2: Modified files
| File | Change |
|------|--------|
| `lib/api/rubygem_packages.rb` | Include the helper module |
| `spec/requests/api/rubygem_packages_spec.rb` | Add `X-Error-Message` header verification + feature flag contexts |
| `spec/support/shared_examples/requests/api/rubygems_packages_shared_examples.rb` | Add shared examples for header presence/absence |
### :rotating_light: Error scenarios in `lib/api/rubygem_packages.rb`
| Status | Trigger | Current message | Expected `X-Error-Message` |
|--------|---------|----------------|---------------------------|
| 400 | `bad_request!('File is too large')` | `400 Bad request - File is too large` | `File is too large` |
| 400 | `bad_request!(response&.message)` (service failure) | `400 Bad request - {message}` | `{message}` |
| 401 | `authenticate_non_get!` (no auth on write) | `401 Unauthorized` | _(not set — same as reason phrase)_ |
| 403 | `authorize_read_package!` / `authorize_upload!` (no permission) | `403 Forbidden` | _(not set — same as reason phrase)_ |
| 403 | `forbidden!` (ObjectStorage error) | `403 Forbidden` | _(not set)_ |
| 403 | Dependency resolver — no permission | `forbidden` | `forbidden` |
| 404 | Feature flag disabled | `404 Not Found` | _(not set — same as reason phrase)_ |
| 404 | Unimplemented spec index route | `404 Not Found` | _(not set)_ |
| 404 | `ActiveRecord::RecordNotFound` (package/gemspec not found) | `404 Not Found` | _(not set)_ |
| 404 | Dependency resolver — gem not found | `{gem_name} not found` | `{gem_name} not found` |
## :white_check_mark: Verification plan
### :globe_with_meridians: curl verification (GDK)
```bash
# Enable feature flag
# Rails console: Feature.enable(:rubygems_error_message_header)
# 403 - no permission (anonymous on private project)
curl -v "http://gdk.test:3000/api/v4/projects/1/packages/rubygems/gems/foo-1.0.gem"
# Expected header: (not set — generic 403/404)
# 404 - nonexistent package
curl -v -H "Private-Token: $TOKEN" \
"http://gdk.test:3000/api/v4/projects/1/packages/rubygems/gems/nonexistent-1.0.gem"
# Expected: 404 without X-Error-Message (detail same as reason phrase)
# 400 - file too large (upload)
curl -v -X POST -H "Private-Token: $TOKEN" \
-F "file=@large_file.gem" \
"http://gdk.test:3000/api/v4/projects/1/packages/rubygems/api/v1/gems"
# Expected header: X-Error-Message: File is too large
# 404 - dependency not found
curl -sI -H "Private-Token: $TOKEN" \
"http://gdk.test:3000/api/v4/projects/1/packages/rubygems/api/v1/dependencies?gems=nonexistent" \
| grep -i x-error-message
# Expected: X-Error-Message: nonexistent not found
# Verify flag disabled → no header
# Rails console: Feature.disable(:rubygems_error_message_header)
curl -sI -H "Private-Token: $TOKEN" \
"http://gdk.test:3000/api/v4/projects/1/packages/rubygems/api/v1/dependencies?gems=nonexistent" \
| grep -i x-error-message
# Expected: (no output — header not present)
```
### :package: Client versions to test
| Client | Version | Purpose |
|--------|---------|---------|
| `gem` | current (3.5+) | Primary test target — verify X-Error-Message displayed |
| `gem` | any older version | X-Error-Message support has been present for many years |
| `bundler` | current (2.5+) | Verify no breakage (ignores header) |
### :test_tube: Test scenarios
For each client, test with feature flag **enabled** and **disabled**:
1. **Package not found** (404) — `gem fetch nonexistent` from project; verify generic "Not Found" message (no detail to add)
2. **Permission denied** (403) — `gem fetch` with insufficient permissions; verify generic "Forbidden" message
3. **Dependency not found** (404) — `gem install` triggering dependency resolution for missing gem; verify `"{gem_name} not found"` detail
4. **Upload too large** (400) — `gem push` with oversized file; verify body-based error display (push doesn't read header)
5. **Feature flag disabled** — repeat above; verify no `X-Error-Message` header present
### :crystal_ball: Expected output
**`gem install` / `gem fetch` with flag enabled (dependency not found):**
```
ERROR: While executing gem ... (Gem::RemoteFetcher::FetchError)
Bad response foo not found 404 (http://gdk.test:3000/api/v4/projects/1/packages/rubygems/api/v1/dependencies?gems=foo)
```
**`gem install` / `gem fetch` with flag disabled (fallback):**
```
ERROR: While executing gem ... (Gem::RemoteFetcher::FetchError)
Bad response Not Found 404 (http://gdk.test:3000/api/v4/projects/1/packages/rubygems/api/v1/dependencies?gems=foo)
```
**`bundle install` with flag enabled (no change — bundler ignores header):**
```
Retrying fetcher due to error (2/4): Bundler::HTTPError Could not fetch specs from http://gdk.test:3000/...
```
## :link: References
- Parent issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/595485
- Sibling (Maven): https://gitlab.com/gitlab-org/gitlab/-/work_items/595487
- [`Gem::RemoteFetcher#fetch_http`](https://github.com/rubygems/rubygems/blob/master/lib/rubygems/remote_fetcher.rb) — `X-Error-Message` header handling
- [`GemcutterUtilities#with_response`](https://github.com/rubygems/rubygems/blob/master/lib/rubygems/gemcutter_utilities.rb) — `gem push` error display
- [`Bundler::Fetcher::Downloader#fetch`](https://github.com/rubygems/rubygems/blob/master/bundler/lib/bundler/fetcher/downloader.rb) — bundler HTTP error handling
- [rubygems/rubygems#3805](https://github.com/rubygems/rubygems/issues/3805) — Bundler error messages lack specificity
task