Skip to content

feat!: own the transport, give providers a base class, ship six of them - #17

Merged
Halvanhelv merged 26 commits into
mainfrom
feature/provider-transport
Sep 9, 2026
Merged

feat!: own the transport, give providers a base class, ship six of them#17
Halvanhelv merged 26 commits into
mainfrom
feature/provider-transport

Conversation

@Halvanhelv

Copy link
Copy Markdown
Owner

Summary

This branch replaces the provider layer end to end and, with this last
commit, connects it back to TranslationDiff.translate -- before this
commit the pipeline still spoke the pre-refactor duck-typed contract and
every translation call raised NoMethodError.

  • Own transport instead of borrowed SDKs. DeepL and Google no longer go
    through deepl-rb / google-cloud-translate-v2. Every REST-backed
    provider now owns one Faraday connection (TranslationDiff::HTTPProvider),
    with its own retry policy, timeouts, and error mapping -- and, unlike the
    SDKs it replaced, installs no logging middleware and accepts no way to
    turn one on, so no HTTP client this library builds can ever be handed
    source text, a translation, or a credential to log. faraday and
    faraday-retry become runtime dependencies; deepl-rb and
    google-cloud-translate-v2 are gone.
  • A real Provider base class. TranslationDiff::Provider supplies the
    configuration check, capability defaults, and #cache_key; a provider no
    longer merely happens to answer the right methods, it inherits the
    contract. TranslationDiff::Providers.register now refuses anything that
    doesn't inherit it.
  • Declared capabilities instead of duck-typed methods. max_request_size
    and max_batch_size used to be provider methods (and "can it detect a
    language" was respond_to?(:detect), which every provider now answers
    truthfully regardless). They're TranslationDiff::Capabilities now:
    request/batch/text size limits, which option turns HTML handling on (every
    vendor spells it differently), whether notranslate is honoured, whether
    detection is supported, and whether billed-character usage is reported --
    one place per provider, checked by the pipeline instead of assumed.
  • Six providers, each declaring what it can actually do:
    • :deepl (default) -- unchanged behaviour, now via direct HTTP; the
      batch limit was corrected from a wrong 300 to DeepL's documented 50.
    • :google -- unchanged behaviour, now via direct HTTP, no more
      googleauth/signet/grpc pulled in for one POST with a key in the query
      string.
    • :azure (new) -- Azure AI Translator, the most generous per-request
      limits of the set and the only one besides DeepL that reports billed
      characters.
    • :modernmt (new) -- adaptive MT with translation memories.
    • :libretranslate (new) -- the one self-hostable provider. Measured
      against a real instance: its HTML mode preserves notranslate markup
      but translates the protected content anyway, so Capabilities declares
      notranslate: false rather than trusting the vendor's own HTML claim.
    • :amazon (new) -- signed with aws-sigv4 (required lazily, only by
      this provider) rather than merely headed. No batch API (one text per
      call) and no HTML mode at all, both stated as capabilities rather than
      discovered by a customer.
  • Translation::Request/Translation::Response replace the old
    translate(texts, from:, to:, **options) / bare array. A provider takes
    one, returns the other; Response.build is the one place the returned
    count is checked (ResponseError on a mismatch), so that check now holds
    for every provider, including ones (Amazon) that override #translate
    outright instead of using the HTTP seams.
  • An error hierarchy (ConfigurationError, ProviderError and its
    AuthenticationError/RateLimitError/QuotaExceededError/
    InvalidRequestError/ServiceError, TransportError, ResponseError,
    InvalidProviderError) replaces bare TranslationDiff::Error for
    anything a provider's transport can do wrong.
  • This commit: rewires Request#chunks, #detect_language and
    #call_api onto capabilities and the new request/response types (the
    three places nothing else in the branch had touched), migrates the three
    test files that were still carrying pre-Provider doubles for Null
    (instrumentation_test.rb, context_test.rb, request_test.rb) onto the
    real contract, and fixes a real aliasing bug the required chunking test
    surfaced: a provider handing back the same array it was given (rather
    than a fresh one, as :null does) had that array silently drained to
    empty by Cache#store's destructive #shift -- call_api now dups its
    return value.
  • Rewrote the README's provider documentation: a capabilities-verified
    provider table (every number cross-checked against
    TranslationDiff::Capabilities directly, not read off the source by eye),
    a "Writing a provider" guide with a full working example on the three
    HTTP seams, and the Amazon/LibreTranslate notranslate caveats called out
    in prose so a reader sees them before their bill and their brand names
    arrive, not after.

Verification

  • bundle exec rake test green on seeds 1, 7, 99, 1234, 4242 (343 runs,
    783 assertions, 0 failures).
  • bundle exec rubocop: no offenses.
  • TranslationDiff::Providers.names => [:null, :deepl, :google, :azure, :modernmt, :libretranslate, :amazon].
  • Differential check: ran the same 10 representative inputs through main
    and through this branch with the :null provider -- byte-identical
    output.

Test plan

  • bundle exec rake test on a clean checkout
  • bundle exec rubocop
  • Skim the README's provider table against Capabilities for the
    provider you know best
  • Confirm no cache-key format changed (Cache#key in
    lib/translation_diff/cache.rb is untouched by this branch)

Not merging -- left for review.

TranslationDiff::Provider replaces the duck-typed provider contract:
subclasses declare configuration options/requirements and capabilities,
translate a Translation::Request into a Translation::Response, and get
a checked, all-or-nothing option registration and a guarded cache_key
for free. Providers.register now rejects anything that isn't a
Provider subclass, and Null is ported onto the new base class.

DeepL and Google still predate Provider (Tasks 4/5 port them), so their
unconditional registration at require time is rescued in
translation_diff.rb rather than taking the whole library down; they're
simply absent from the registry until then. Migrating Null also means
every provider built through the registry now speaks the new contract,
which request.rb (owned by a later task) doesn't consume yet -- three
integration test files that routed through :null needed small local
doubles in place of the now-incompatible provider to keep exercising
their own behaviour without touching request.rb.
Providers.register's "not a Provider subclass" check raised the
generic TranslationDiff::Error, which is also what ProviderOptionOwners
raises for an option-name collision. The rescue in translation_diff.rb
around the still-unported DeepL/Google requires caught both, so a
genuine option collision on deepl_api_key or google_api_key would have
been swallowed and misreported as the expected transitional state.

InvalidProviderError narrows the rescue to exactly the case it's meant
to cover; a collision now still takes the require chain down.
Add HTTPProvider: one Faraday connection every REST provider inherits,
with HTTP-status-to-error mapping and no logging middleware, ever, so
no line this gem writes can carry source text, translated text, or a
credential. Rewrite the shared provider contract onto Translation::
Request/Response and provider.class.capabilities, and add the stubbed-
provider and HTTP-provider-contract test helpers every REST provider's
test will include.

Pin json to < 3 in the Gemfile: json 3.0 dropped the positional `opts`
argument Faraday::Response::Json still passes to JSON.parse, which
broke every JSON response Faraday parses.
json 3.0 dropped the positional opts argument Faraday::Response::Json
still passes to JSON.parse, and json 3 is now the default gem on
Ruby 4.x -- so any application on a modern Ruby would have hit an
ArgumentError on the first response this gem parsed. A Gemfile pin
only protected our own suite, not the users who would ship with it.

Stop installing faraday's response-JSON middleware and decode the
body ourselves in #post, the same reasoning that took this gem off
the vendor SDKs. #post now returns a small Decoded value (status,
headers, already-parsed body) so #raise_for_status! and every
subclass's #parse_translate_response keep the same shape they had.
A non-JSON body -- an HTML error page from a proxy, for instance --
passes through untouched rather than raising.
The provider now speaks DeepL's REST API on this library's own Faraday
transport. Dropping deepl-rb removes a dependency, but the reason is
narrower than that: its defaults were not ours, and neither was its
logging. It writes the whole request at DEBUG -- the Authorization
header, auth key and all, followed by the payload, which is the text
being translated -- so a customer's content reached the application log
the moment anyone turned DEBUG on to diagnose something. Nothing we
configure on our own connection does that.

Two vendor facts are now stated where they can be read. The free host is
selected from the `:fx` key suffix, which deepl-rb used to do for us.
And `max_batch_size` is 50, DeepL's documented limit; the old code said
300, which the per-request size limit usually capped first -- a list of
short values would have reached it and been rejected.

`tag_handling: html` with `tag_handling_version: v2` is preserved
exactly. It is what makes `class="notranslate"` work, and it failed
silently for the whole life of the DeepL provider before it was added.
Ports the Google provider onto HTTPProvider, the same way DeepL was
ported in the previous commit, and drops google-cloud-translate-v2
along with its transitive dependencies (googleauth, signet, os,
google-protobuf, grpc). Also removes the transitional require-bridge
in lib/translation_diff.rb now that both built-in HTTP providers are
ported, and adds :google back to the built-in providers assertion.
Faraday's test adapter reuses one Env for both the request and the
response, overwriting its body once the stub block returns -- so
StubbedProvider was recording the reply, not the request, for every
assertion made after #translate returned. DeepL and Google's tests
already worked around this locally with `env.dup`; fix it here so
Task 6 and beyond can use the shared helper as-is.

Also let `body:` be a callable handed the request's env, so a
provider's own `provider` test helper can echo back a response sized
to match whatever texts a particular test sent, the way DeepL's and
Google's local helpers already did.
The comment and test name still referenced "the transitional bridges
in lib/translation_diff.rb", which are gone. What the test guards is
unchanged: InvalidProviderError is specific to a provider of the
wrong shape, so a caller rescuing that cannot also swallow an
unrelated option-name collision, which stays a generic Error.
Azure AI Translator v3, the third HTTP provider on the shared
transport. It differs from DeepL and Google in one structural way:
the language pair and api-version/textType travel in the query
string while the texts travel in the body, so #translate is
overridden to build the URL per request. Billed characters come from
the X-metered-usage response header. Limits (1000 strings, 50,000
characters per request and per string) are Azure's documented ones,
the largest of the three providers here.
No ModernMT trial key was available in this environment, so the
notranslate capability could not be probed against the live API and
stays false with an "unverified, safe default" comment rather than a
verified observation.

RuboCop's Metrics cops rejected the brief's parse_translate_response
verbatim (AbcSize, CyclomaticComplexity, MethodLength); extracted
results_from/usage_for helpers to bring it under the limits without
changing behaviour. Also renamed the "documented 128" test method to
avoid Naming/VariableNumber.
Docker was available, so the notranslate capability was probed for
real rather than left an unverified guess: against
libretranslate/libretranslate --load-only en,ru,
<span class="notranslate">Bold Mountain</span> is a good place. came
back with the span tag intact but "Bold Mountain" translated to
"Смелая гора" anyway. LibreTranslate's HTML format preserves markup;
it does not honour the notranslate marker, so the constant is a
documented false rather than a safe-default false.
Both files carried their own copy of the request-recording Faraday
stub, written before StubbedProvider's body: callable existed. Now
that the shared helper can echo request-sized responses, both migrate
onto it, leaving the local copy in Azure's test as the only style and
StubbedProvider as the one place the plumbing lives. Assertions are
unchanged; only how each stub is built moved.
Wires the pipeline to the Provider/Capabilities/Translation::Request-Response
contract every provider already speaks: Request#chunks reads Capabilities
instead of two provider methods nothing defines any more, #detect_language
checks capabilities.detects_language? instead of a respond_to? check that
was true for every provider, and #call_api sends a Translation::Request and
returns a Translation::Response, dropping the now-dead manual count check.
Without this, TranslationDiff.translate raised NoMethodError for every
provider on this branch.

Also fixes a real aliasing bug the required chunking test surfaced: a
provider that hands back the same array it was given (rather than a fresh
one, as Null does) had that array silently drained to empty by Cache#store's
destructive #shift, visible to anything else still holding the reference.
call_api now dups its return value.

Migrates the three test files still carrying pre-Provider doubles
(instrumentation_test.rb, context_test.rb, request_test.rb) onto the real
contract, preferring the actual :null provider where a double added nothing.
Every multi-line comment block in lib/ and test/ is reduced to at most one
line, keeping the load-bearing fact (a decision, a vendor quirk, a measured
observation) and dropping restated prose and usage examples.
Dedup savings from the magic comment are traded for plain mutable string
literals; explicit .freeze keeps the constants that need to stay frozen.
Ruby 3.4 chills string literals without the comment, so it becomes the
supported floor.
A provider returning a well-formed response that carries nil for one input
passed Response.build -- it checked only the count. The nil was written into
the cache under a real key and the caller got NoMethodError: undefined method
'strip' for nil out of Spacing.restore, naming neither the provider nor the
position. Azure documents exactly this shape: 200 for a batch where one
element carries `error` instead of `translations`. Five of six providers can
produce it since the branch replaced SDK objects with raw JSON lookups.

Response.build now names the class and the position of the first offender,
never the value, which is the customer's text or the provider's error object.
#store shifted its `updates` argument empty. Task 10 dup'd at the one call
site, which fixes that caller and nothing else: #store is a public method on
a public class taking an outside array, so the next call site anyone adds
reintroduces the bug in the same silent form -- the caller's array comes back
empty with no error. It now indexes instead of shifting. The dup at the call
site stays: a provider's array is the provider's, and Request should not hand
it to a collaborator either.

Pins the exact cache keys so this change, and the option-declaration change
that follows, are shown not to move one.
The design promised that DEEPL_AUTH_KEY, TRANSLATE_KEY/GOOGLE_CLOUD_KEY and
TRANSLATE_PROJECT would be read by us now that the SDKs that read them are
gone. Nothing implemented it and nothing could: configuration_options was a
bare symbol array with nowhere to put a default. An application that set
DEEPL_AUTH_KEY and never assigned config.deepl_api_key worked on main and
raised ConfigurationError on the first translate here -- at boot, for a Rails
app.

configuration_options now takes either a bare symbol or `key => default`, and
a default routes through Configuration.option's existing callable support, so
it is resolved on read rather than at load: the variable may be exported after
this gem is required. A blank default reads as unset, the rule assignment
already followed, so DEEPL_AUTH_KEY= is a missing key rather than an empty
credential. Amazon declares no fallback on purpose and now says so.

CHANGELOG: the sentence claiming Google reads those variables and falls back
to application default credentials was false twice over -- corrected, ADC
recorded as dropped, and the two missing Breaking entries added (deepl_host ->
deepl_api_base, and a count mismatch raising ResponseError rather than
Request::Error).
Providers.register raised InvalidProviderError for a class that skipped the
base class, but config.provider = <object> -- a documented extension point --
returned any non-Symbol untouched, so an object was never checked. An app that
upgraded with its own duck-typed provider object heard nothing at configure
time and got NoMethodError: undefined method 'capabilities' from request.rb on
the first translate, with no message naming the contract change. The
`provider:` keyword had the same hole.

All three paths now raise InvalidProviderError in the same words. cache,
segmenter and rate_limiter are still genuinely duck-typed and stay untouched,
which a test now pins.

Also fixes the registry guard itself (M8): `klass < Provider` raised
NoMethodError for an instance, nil or a symbol, and ArgumentError for a
non-Module -- the first thing someone writing their own provider hits. The
message names the class, never the object, whose #to_s would render its own
contents.
Only Google and DeepL normalised. Measured with from: "EN", to: "RU", Amazon
sent "SourceLanguageCode" => "EN", LibreTranslate and ModernMT sent "EN"/"RU",
and Azure sent &to=RU&from=EN. Amazon Translate rejects those outright and
LibreTranslate answers 400 -- on every call, for anyone who followed the
README's "switch provider by changing config.provider" while keeping
DeepL-style upper-case codes.

The rule Google had is now Provider#language, reachable by the base class and
by every provider, with self.language_case choosing the casing: DeepL
up-cases, the other five down-case. The subtag exception is kept and now
applies to DeepL too, which up-cased "zh-Hans" into "ZH-HANS" -- the casing of
a script or region subtag is its own and a blanket transform corrupts it.

Tested per provider, both the bare code and the subtag, not only Google.
…t it

deepl.rb and modernmt.rb mapped a summed 0 to nil while azure.rb returned 0
when its header said 0, so the same fact reached a caller two ways depending
on the provider. nil now means the provider reported nothing and a number,
0 included, means it reported that number -- Azure's behaviour, and the one
Translation::Usage's own comment already described. A provider that bills
nothing for a call (a translation memory hit) no longer has that reported as
"unknown".

The rule lives once, in Provider#billed_characters, rather than twice.
amazon.rb applied its mandatory fields first and the caller's options second,
so TranslationDiff.translate(text, to: :ru, TargetLanguageCode: "de") sent
German, and Text: could substitute somebody else's text for the caller's.
Azure had the same shape, reachable by anyone building a Translation::Request
directly. All six now apply defaults, then caller options, then mandatory
fields, which is what the other four already did: a caller can still override
a default such as Azure's textType, and can no longer displace the language
pair or the texts.
M1 changed Cache#store and I1 changed how provider options are declared;
either moving a key would make every user re-translate their whole corpus on
upgrade. This pins five keys covering the provider segment, the normalised
language codes including a subtag, the options digest over a nested hash and
an array, and the sentence digest. The five values were read off `main` and
are byte-identical there.
The matrix still listed 3.2 and 3.3, which this branch dropped when it
raised `required_ruby_version` to 3.4. Both jobs failed at `bundle
install`, because bundler correctly refuses to install a gem that
declares a floor above the running Ruby -- so the failure was the
gemspec being obeyed, not the code being wrong.

A matrix that outlives the version it tests turns CI red for a reason
unrelated to any change, which is the fastest way to teach a team to
merge over a red build.
@Halvanhelv
Halvanhelv merged commit 68edbff into main Sep 9, 2026
3 checks passed
@Halvanhelv
Halvanhelv deleted the feature/provider-transport branch September 9, 2026 16:13
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