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