Class: DeepLClient

Inherits:
Object
  • Object
show all
Defined in:
app/services/deep_l_client.rb

Overview

Thin HTTP client for DeepL's document-translation API
(https://developers.deepl.com/docs/api-reference/document). DeepL is the sole
PDF-translation provider; it may reflow pages and substitute fonts, so every
result is staged for human review before it enters the publication library.

Auth: DeepL-Auth-Key from credentials (Heatwave::Configuration :deepl,
same key config/initializers/deepl.rb uses). Canadian French (FR-CA) is a
supported document target (generally available since 2026-07-17).

Billing note: every successfully downloaded PDF bills a 50,000-character
MINIMUM — surfaced in meta[:billed_characters] so the exact billable amount
stays auditable.

Examples:

result = DeepLClient.translate('/tmp/manual.pdf', lang_out: 'FR')
result.bytes # => "%PDF-1.7…"
result.meta  # => { engine: 'deepl', billed_characters: 50000, … }

Defined Under Namespace

Classes: Error, NotConfigured, Result, Timeout, UnsupportedLanguage

Constant Summary collapse

DOCUMENT_TARGETS =

Product locales exposed by the CRM and Sunny PDF flows, mapped to DeepL's
document target codes. Keep BCP 47 casing on our side (fr-CA) and the
uppercase codes DeepL expects on the API side (FR-CA). DeepL's live
/v3/languages?resource=translate_document response omitted FR-CA on
2026-08-12, but the release notes declare it GA and an actual
/v2/document request with this account succeeded; do not use that listing
as a runtime gate until DeepL fixes the inconsistency.

{
  'de' => 'DE', 'es' => 'ES', 'fr' => 'FR', 'fr-CA' => 'FR-CA',
  'it' => 'IT', 'nl' => 'NL', 'pl' => 'PL', 'sv' => 'SV'
}.freeze
BASE_URL =

DeepL API Pro endpoint.

'https://api.deepl.com'
FREE_BASE_URL =

DeepL Free keys (ending ':fx') are rejected by the Pro endpoint — they
only work against api-free.deepl.com.

'https://api-free.deepl.com'
TRANSLATE_TIMEOUT =

Whole-task deadline in seconds. DeepL is fast for small docs; 20-page
manuals took under a minute in testing.

600
POLL_INTERVAL =

Status poll interval in seconds.

3

Class Method Summary collapse

Class Method Details

.api_keyString?

Returns the configured DeepL auth key.

Returns:

  • (String, nil)

    the configured DeepL auth key



69
70
71
# File 'app/services/deep_l_client.rb', line 69

def api_key
  Heatwave::Configuration.fetch(:deepl).presence
end

.base_urlString

Returns API base URL for the configured key — Free keys
(ending ':fx') must hit api-free.deepl.com or every call 403s.

Returns:

  • (String)

    API base URL for the configured key — Free keys
    (ending ':fx') must hit api-free.deepl.com or every call 403s



80
81
82
# File 'app/services/deep_l_client.rb', line 80

def base_url
  api_key.to_s.end_with?(':fx') ? FREE_BASE_URL : BASE_URL
end

.canonical_document_locale(language) ⇒ String

Return the application's canonical BCP 47 locale for a document target.

Parameters:

  • language (String)

Returns:

  • (String)

    e.g. fr-CA

Raises:



104
105
106
107
108
109
# File 'app/services/deep_l_client.rb', line 104

def canonical_document_locale(language)
  locale = document_target_pair(language)&.first
  return locale if locale

  normalize_document_target_language(language) # raises the detailed error
end

.configured?Boolean

Returns whether a DeepL auth key is configured.

Returns:

  • (Boolean)

    whether a DeepL auth key is configured



74
75
76
# File 'app/services/deep_l_client.rb', line 74

def configured?
  api_key.present?
end

.normalize_document_target_language(language) ⇒ String

Normalize and validate a target for the document endpoint.

Parameters:

  • language (String)

Returns:

  • (String)

    uppercase DeepL document target

Raises:



90
91
92
93
94
95
96
97
# File 'app/services/deep_l_client.rb', line 90

def normalize_document_target_language(language)
  target = document_target_pair(language)&.last
  return target if target

  raise UnsupportedLanguage,
        "DeepL document translation does not support #{language.inspect} here " \
        "(supported: #{DOCUMENT_TARGETS.keys.join(', ')})"
end

.translate(path, lang_out:, lang_in: nil, filename: File.basename(path.to_s), resume_document_id: nil, resume_document_key: nil) {|document_id, document_key| ... } ⇒ Result

Translate a local PDF. Layout is NOT page-faithful (DeepL reflows and
may change page counts) — the PDF-studio review shows the result.

Parameters:

  • path (String)

    local file path

  • lang_out (String)

    DeepL document target language. Case-insensitive;
    normalized to uppercase (fr-CAFR-CA).

  • lang_in (String, nil) (defaults to: nil)

    optional source language code; nil lets DeepL
    auto-detect the document language

  • filename (String) (defaults to: File.basename(path.to_s))

    original filename sent with the upload

  • resume_document_id (String, nil) (defaults to: nil)

    previously submitted document ID

  • resume_document_key (String, nil) (defaults to: nil)

    encryption key for the previously
    submitted document. Both resume values must be present to skip submit;
    otherwise a fresh submit runs. DeepL bills only after a successful
    result download.

Yields:

  • (document_id, document_key)

    optional block invoked with the
    submitted (or resumed) ids BEFORE polling starts, so callers can
    persist them for a later resume.

Returns:

  • (Result)

    translated PDF bytes + meta (engine, billed_characters)

Raises:

  • (NotConfigured)

    when the auth key is missing

  • (Timeout)

    when the task exceeds TRANSLATE_TIMEOUT

  • (Error)

    on HTTP or translation failure



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'app/services/deep_l_client.rb', line 132

def translate(path, lang_out:, lang_in: nil, filename: File.basename(path.to_s), resume_document_id: nil, resume_document_key: nil)
  raise NotConfigured, 'DeepL auth key is not configured (credentials :deepl)' unless configured?

  lang_out = normalize_document_target_language(lang_out)

  document_id, document_key =
    if resume_document_id.present? && resume_document_key.present?
      [resume_document_id, resume_document_key]
    else
      submit(path, filename:, lang_in:, lang_out:)
    end
  yield(document_id, document_key) if block_given?
  billed = await(document_id, document_key)
  bytes = download(document_id, document_key)

  Result.new(bytes:, meta: { engine: 'deepl', document_id:, billed_characters: billed,
                             lang_in:, lang_out:, filename:, warnings: [] })
rescue HTTP::ConnectTimeoutError => e
  # Connection never established — transient, worth a bounded retry. Must
  # precede HTTP::TimeoutError (ConnectTimeoutError < TimeoutError).
  raise Error, "deepl connection failed: #{e.message}"
rescue HTTP::TimeoutError => e
  raise Error, "deepl request timed out: #{e.message}"
rescue HTTP::Error => e
  raise Error, "deepl request failed: #{e.message}"
rescue JSON::ParserError
  raise Error, 'deepl returned invalid JSON'
end