Set X-Error-Message response header on RubyGems errors
🔔 Context
The GitLab RubyGems package registry returns error responses as Content-Type: application/json with a body like {"message":"403 Forbidden - Package protected."}. The gem client never reads the response body during gem install / gem fetch — it only reads the HTTP status code and reason phrase, so users see generic output (Bad response Forbidden 403 (...)) with no idea why an operation failed.
Gem::RemoteFetcher#fetch_http reads an X-Error-Message response header and, when present, uses its value in place of the HTTP reason phrase.
Related to #595492 (parent: #595485).
🤔 What does this MR do and why?
- Add the
X-Error-Messageresponse header to all 4xx/5xx error responses from the RubyGems API endpoints, gated behind therubygems_error_message_headerfeature flag (disabled by default). - The header value is the error detail with the
"NNN StatusPhrase - "prefix stripped (e.g.403 Forbidden - Package protected.→Package protected.). When the message is a bare status phrase (e.g.403 Forbidden) the header is not set — the client already shows the reason phrase. Custom messages without a status prefix (e.g. the dependency resolver's<gem> not found) are passed through verbatim. - The response body is unchanged — existing API consumers that parse the JSON are unaffected.
The implementation introduces API::Helpers::Packages::Rubygems::ErrorMessageHeader, which overrides render_structured_api_error! — the single chokepoint all Grape error helpers (forbidden!, bad_request!, not_found!, …) flow through. When the flag is disabled it calls super immediately (zero behavioral change).
This also de-duplicates the "NNN StatusPhrase - detail" parsing that previously lived inline only in the Maven helper: it is extracted into a shared API::Helpers::Packages::ErrorMessage module (error_message_detail, error_message_single_line) and reused by the Maven and NuGet error helpers. Their behavior is unchanged — guaranteed by their existing specs staying green.
⚠️ RubyGems client compatibility
The mechanism is version-dependent and worth calling out explicitly:
| Operation | rubygems < 4.0 | rubygems 4.x |
|---|---|---|
gem install / gem fetch |
reads X-Error-Message |
header handling removed — ignored (harmless); body discarded on error, so no server-side channel exists |
gem push / gem yank |
reads response body | reads response body (unchanged) |
I verified against the real Gem::RemoteFetcher#fetch_http that rubygems 4.0.12 no longer reads X-Error-Message (and there is no replacement header). So the benefit of this change is scoped to gem install/gem fetch clients on rubygems < 4.0; on 4.x the header is simply ignored. The change is harmless and standards-based on all versions.
🚩 Feature flag
Name: rubygems_error_message_header (gitlab_com_derisk, default disabled).
Rollout: #601846
🧪 How to set up and validate locally
# Rails console
Feature.enable(:rubygems_error_message_header)# 404 with a detail (dependency resolver) -> header set
curl -sI -H "Private-Token: $TOKEN" \
"http://gdk.test:3000/api/v4/projects/$PID/packages/rubygems/api/v1/dependencies?gems=nonexistent" \
| grep -i x-error-message # => X-Error-Message: nonexistent not found
# bare status phrase (gem file not found) -> no header
curl -sI -H "Private-Token: $TOKEN" \
"http://gdk.test:3000/api/v4/projects/$PID/packages/rubygems/gems/nonexistent-1.0.gem" \
| grep -i x-error-message # => (no output)With the flag disabled, the header is never present.
📚 References
- Feature issue: #595492
- Rollout issue: #601846
- Sibling MRs: Maven !230919 (merged), NuGet !231682 (merged), Conan !231680 (merged)
MR acceptance checklist
- Tests added (unit specs for the shared helper and the RubyGems override; request specs for header presence/absence + flag-off; Maven/NuGet regression specs unchanged)
- Feature flag created (
rubygems_error_message_header)