Resolve error messages through a translator - #2980
Draft
ericproulx wants to merge 1 commit into
Draft
ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
ericproulx
force-pushed
the
translator-seam
branch
from
September 22, 2026 10:30
a2f83cf to
6e73b85
Compare
Danger ReportNo issues found. |
Grape::Util::Translation called I18n directly, so every error message was an I18n lookup and nothing else was possible. It now hands the lookup to Grape.translator, and ships two that answer it. Grape::Translator::I18n is the default and is today's code moved, not changed: it resolves at request time, so the locale in force and an application's own overrides both apply. Grape::Translator::Catalog answers from one frozen table instead, built once from Grape's own locale/en.yml -- read with YAML, so it needs no I18n -- with whatever I18n has loaded merged over it, so overrides and other locales are carried. Keys are flat and the interpolation placeholders are rewritten into the form format takes, so a lookup is a Hash read and a format. A 400 validation-error response measures 32.2 us against 53.4 us, 1.66x faster. What the catalog trades away is in the README: it is a snapshot, so a locale loaded after it was built is not in it; the locale in force comes from Grape.locale, which is fiber-local, rather than from I18n; and it carries the scopes it was asked for, grape by default. The two are held to each other by a spec that compares every message Grape ships, with a value supplied for each placeholder, so the one cannot drift from the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
translator-seam
branch
from
September 22, 2026 10:34
6e73b85 to
c614a54
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
I18n keeps its configuration in class variables:
A non-main Ractor may not read a class variable at all — not "unless it is frozen", at all, and there is no
make_shareablefor a variable slot the way there is for a constant or a class instance variable:So an API served from a Ractor dies on the first error message it has to render, which is every validation failure. And there is no fixing it from outside I18n: its backend cannot even be made shareable (it holds a Proc), and freezing it would break
store_translationsandreload!, which are the point of it. This is not I18n being sloppy — its configuration model predates Ractors by a decade and its store is mutable by intent.That left Grape with one way to look a message up, and it was a way that cannot work. #2978 worked around it with a snapshot buried inside the Ractor code, which is the wrong place for it: the real problem is that
Grape::Util::Translationknew about exactly one source of messages. This PR gives it a seam instead, so the snapshot becomes a translator anyone can choose, rather than a special case one mode carries.Two things fell out of building it, and they are consequences rather than reasons: answering from a table is materially faster than asking I18n, and an application that does not localize can now avoid the I18n call path entirely.
Grape::Util::Translationcalled I18n directly, so an error message was an I18n lookup and nothing else was possible. It now hands the lookup toGrape.translator, and Grape ships two that answer it.Grape::Translator::I18nis the default, and is the old code moved rather than changed: it resolves at request time, so the locale in force and an application's own overrides both apply, with the same:enfallback and the sameenforce_available_localeshandling.Grape::Translator::Cataloganswers from one frozen table instead:It reads Grape's own
locale/en.ymlwith plainYAML, so it needs no I18n at all, and merges whatever I18n has loaded over the top, so overrides ofgrape.errors.messages.*and other locales are carried. Keys are flat and the placeholders are rewritten from%{name}into the%<name>sthatformattakes, once, at build time — so answering a message is aHashread and aformat.What it costs to look a message up
I18n.translate, no interpolationI18n.translate, interpolatedEnd to end, a 400 validation-error response — which makes several lookups, for the message, the attribute name and the format wrapper:
What the catalog trades away
Documented in the README, all three:
I18n.reload!in development, is not in a table that was already built. Which is why it is opt-in and I18n stays the default.Grape.locale, not I18n — fiber-local, so requests do not see each other's, and a fiber started mid-request inherits it.before { Grape.locale = I18n.locale }bridges the two.grapeby default. A validator translating from its own namespace names it:Catalog.build(scopes: %w[grape my_app]).Keeping the two honest
One spec walks every message Grape ships, supplies a value for each placeholder the message carries, and asserts both translators return the identical string — 40-odd keys, so the contract is covered rather than sampled. A future change to either one that alters a message fails there.
Notes
Exceptions::BaseandValidations::Validators::Basestillinclude Grape::Util::Translation, andtranslatekeeps its signature, so custom validators built against the documented helper are unaffected.call(key, default:, scope:, locale:, **options), so an application can supply its own.store_translationscall made before the backend has read its load path is silently undone by it, and a store for a locale outsideavailable_localesis dropped without a word when enforcement is on (spec_helper.rbturns it on). Both are commented where they bite.Ractor.shareable?(catalog)is true, so Add Ractor mode #2978 can drop itssnapshot_translations!step and get per-request locale back instead of pinning the default. This PR stands on its own, though, and does not depend on that one.Verified on Ruby 4.0.6, 3.4.9 and 3.3.12, and against the
rails_7_2gemfile: rubocop clean, full suite green.🤖 Generated with Claude Code