Skip to content

Resolve error messages through a translator - #2980

Draft
ericproulx wants to merge 1 commit into
masterfrom
translator-seam
Draft

ericproulx wants to merge 1 commit into
masterfrom
translator-seam

Conversation

@ericproulx

@ericproulx ericproulx commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Why

I18n keeps its configuration in class variables:

def backend;        @@backend ||= Backend::Simple.new; end
def default_locale; @@default_locale ||= :en;          end

A non-main Ractor may not read a class variable at all — not "unless it is frozen", at all, and there is no make_shareable for a variable slot the way there is for a constant or a class instance variable:

class Demo
  @@frozen_integer = 42
  def self.read = @@frozen_integer
end

Ractor.new { Demo.read }.value
# => Ractor::IsolationError: can not access class variables from non-main Ractors

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_translations and reload!, 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::Translation knew 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::Translation called I18n directly, so an error message was an I18n lookup and nothing else was possible. It now hands the lookup to Grape.translator, and Grape ships two that answer it.

Grape::Translator::I18n is 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 :en fallback and the same enforce_available_locales handling.

Grape::Translator::Catalog answers from one frozen table instead:

Grape.translator = Grape::Translator::Catalog.build

It reads Grape's own locale/en.yml with plain YAML, so it needs no I18n at all, and merges whatever I18n has loaded over the top, so overrides of grape.errors.messages.* and other locales are carried. Keys are flat and the placeholders are rewritten from %{name} into the %<name>s that format takes, once, at build time — so answering a message is a Hash read and a format.

What it costs to look a message up

I18n.translate, no interpolation 3.36 µs
catalog, no interpolation 45 ns
I18n.translate, interpolated 5.45 µs
catalog, interpolated ~300 ns

End to end, a 400 validation-error response — which makes several lookups, for the message, the attribute name and the format wrapper:

400 via I18n      53.43 µs/i
400 via catalog   32.22 µs/i   — 1.66x faster

What the catalog trades away

Documented in the README, all three:

  • It is a snapshot. A locale file loaded lazily, or reloaded through 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.
  • The locale comes from 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.
  • It carries the scopes it was asked for, grape by 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

  • Nothing at the call sites changes. Exceptions::Base and Validations::Validators::Base still include Grape::Util::Translation, and translate keeps its signature, so custom validators built against the documented helper are unaffected.
  • A translator is anything answering call(key, default:, scope:, locale:, **options), so an application can supply its own.
  • Two traps worth knowing if you touch these specs: a store_translations call made before the backend has read its load path is silently undone by it, and a store for a locale outside available_locales is dropped without a word when enforcement is on (spec_helper.rb turns it on). Both are commented where they bite.
  • Ractor.shareable?(catalog) is true, so Add Ractor mode #2978 can drop its snapshot_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_2 gemfile: rubocop clean, full suite green.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 22, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant