* fix(translate): enforce 1500-char limit upfront and add request timeout
Two related stability issues hit during real-world use:
1. **Hung requests** — without an explicit timeout the upstream HTTP
call could dangle indefinitely on a stuck connection. Browser
extensions calling /translate would sit on a spinner forever with
no error to surface to the user (reported in the field).
2. **No-feedback on oversized input** — the oneshot endpoint caps the
total text length at 1500 characters (matches the extension's own
\`G.notLoggedIn = 1500\` constant). We were forwarding the request
anyway and letting DeepL 400 it, which a) wasted an upstream round
trip and b) the caller had no way to distinguish from other 400s.
Changes:
- Pre-validate \`text\` length in characters (utf8.RuneCountInString,
not byte length — verified the cap is rune-based: 1500 Chinese
characters / 4500 bytes is accepted, 1501 is rejected). Return
HTTP 413 Payload Too Large with a clear message naming both the
observed length and the limit.
- Set a 20s timeout on the oneshot HTTP client (req.SetTimeout).
On timeout return HTTP 504 Gateway Timeout — distinguishes a slow
DeepL from other 503 failure modes (DNS, TLS, etc.). The check
catches both context.DeadlineExceeded and url.Error{Timeout()=true}.
- Set a separate 5s timeout on the cookie-jar warmup GET to
www.deepl.com. Warmup is best-effort; we'd rather a slow warmup
(cookies still seed eventually next time) than block the very first
translation behind a hung GET.
Behaviour verified against the live oneshot endpoint:
- 1500 ASCII chars → 200
- 1501 ASCII chars → 413 (upstream not contacted)
- 1500 Chinese chars (4500 bytes) → 200
- 1501 Chinese chars → 413
- Pathological "your"*1500 → 504 at 20s (was hanging without timeout)
- Realistic 245-char Chinese → 200 in ~13s
* perf(translate): share oneshot req.Client across requests + eager warmup
Each TranslateByDeepLX call was building a brand-new req.Client via
newOneshotClient(), which meant a fresh TLS handshake + HTTP/2 SETTINGS
negotiation per request — ~200-400ms of pure overhead on top of DeepL's
own ~1.5s processing latency. Share one client per proxy URL
(sync.Map) so subsequent requests reuse the kept-alive HTTP/2
connection in the underlying http.Transport's pool.
Also flip the cookie-jar warmup from synchronous-on-first-call to
fire-and-forget at first client creation. Same sync.Once semantics
(runs exactly once per process), but in a background goroutine so the
first translate request runs in parallel with the TLS handshake to
www.deepl.com rather than serially behind it.
Measured against the live oneshot endpoint (Tokyo → Frankfurt):
before, 5 sequential requests: 3.19s, 2.05s, 2.07s, 2.89s, 2.22s
after, 5 sequential requests: 2.20s, 1.27s, 1.26s, 1.42s, 1.34s
└─ first └────────── warm path ─────┘
The warm-path 1.3s is also faster than a bare \`curl\` to oneshot
(~1.9s, every call doing its own TLS handshake) — proof the
connection-pool reuse is now actually paying off.
Previously TranslateByDeepLX silently mapped any caller-supplied code
through toOneshotLang(), falling back to a lowercased pass-through for
unknown codes. The oneshot endpoint accepts unknown codes with a 200
but echoes the source text back untranslated, leaving callers to
distinguish "translated, identical to source" from "language not
supported" without a clear signal.
Validate strictly against the language table the extension bundles in
background.js (array `y` for target-capable codes, `A` for the
source-only EN / PT aliases) and return HTTP 400 with a list of
supported codes on mismatch. This also catches:
- target_lang = "" → "target_lang is required"
- target_lang = "auto" → "target_lang cannot be \"auto\"; pick one of: ..."
- source_lang = ""/"auto" → allowed, server autodetects
- case-insensitive → strings.ToUpper before lookup
Pick up languages the previous map missed:
+ ES-419 (Latin American Spanish)
+ HE (Hebrew)
+ VI (Vietnamese)
Fix the EN / PT source-lang mapping: the extension's `A` array maps
both to the generic langCodeForIta ("en"/"pt"), not the regional
default. As a target they continue to resolve to en-US / pt-BR for
backward compat with callers that historically passed "EN" / "PT".
Verified end-to-end:
- 5 valid codes (DE, ZH-HANT, HE, VI, ES-419) → 200 + translated text
- Invalid target "XX" → 400, message lists 38 supported codes
- Invalid source "ZZ" → 400, message lists 38 codes + "auto"
- target_lang "auto" → 400
- source autodetect (empty / "auto") + valid target → 200
- Lowercase input "de" → 200 (case-insensitive)
* feat(translate): migrate to oneshot endpoint to bypass www2 anti-bot
The www2.deepl.com/jsonrpc backends behind LMT_handle_texts /
LMT_handle_jobs now sit behind aggressive WAF + per-IP throttling that
returns HTTP 429 (code 1042911 "Too many requests") within a handful of
calls from any single host — making the free path effectively unusable.
The official DeepL browser extension and iOS app skip that backend
entirely for stateless single-shot translation and POST to a separate
"oneshot" endpoint on a different host pool with its own (much looser)
rate limit. It accepts anonymous traffic with a literal
`Authorization: None` header, returns plain JSON, and supports the same
language pairs.
Switch the free path to:
POST https://oneshot-free.www.deepl.com/v1/translate
Authorization: None
{"text": ["..."], "target_lang": "de", "source_lang": "en"}
Pro users continue to hit oneshot-pro.www.deepl.com with their bearer
token (the `-s` flag now carries an OAuth access token rather than the
legacy dl_session cookie).
This removes:
- the JSON-RPC envelope (jsonrpc/method/id/params/timestamp wrapper)
- the `i`-count timestamp trick (getICount + getTimeStamp)
- the random-id body-spacing trick (handlerBodyMethod)
- the whatlanggo client-side detection (oneshot detects server-side)
The DeepLXTranslationResult contract is unchanged for service handlers;
Alternatives is now always nil because the oneshot endpoint does not
return alternative translations.
Verified against /translate, /v1/translate and /v2/translate routes
end-to-end (EN/DE/ZH/JA/FR pairs, multi-sentence input, autodetect, 10x
burst) — all 200 OK on an IP that was concurrently being 429'd by www2.
* fix(translate): align oneshot request bytes with the real extension
After capturing the exact bytes the Chrome extension's service-worker
fetch() emits (via an offline echo server pointed at deeplx in place of
oneshot-free.www.deepl.com) and diffing them against what we were
sending, several distinguishable signals remained. Close them all.
Headers
-------
- Origin: chrome-extension://cofdbpoegempjloogbagkncekinflcnj
(was https://www.deepl.com — a request from www.deepl.com itself
never lands on the oneshot endpoint, so that origin is unusual.
The extension ID is the canonical sender.)
- Sec-Fetch-Site: cross-site
(was same-site — wrong; chrome-extension -> www.deepl.com IS cross-site)
- Drop Referer entirely (extension SW fetch sends none)
- Drop Pragma / Cache-Control / Upgrade-Insecure-Requests / Sec-Fetch-User
(req.ImpersonateChrome() sets these for top-level navigation; a
fetch() never sends them — leaving them in is a strong nav-vs-XHR tell)
- Accept-Encoding: gzip, deflate, br
(was just gzip, Go stdlib default — Chrome 120's fetch() sends all
three; zstd only landed as a default in Chrome 123+ so leave it off)
Body
----
- Add usage_type: "Translate" and the full app_information object
(os/os_version/app_version/app_build/instance_id) so the JSON the
server sees is structurally identical to what background.js IN()
assembles. Field order in oneshotRequest matches the extension's
object-literal order so encoding/json produces byte-identical output.
- instance_id is a v4 UUID generated once at process start and reused,
mirroring the extension's chrome.storage-pinned ID rather than
rotating per-request (rotation would be a far stronger signal).
- All version strings (TLS handshake, User-Agent, sec-ch-ua,
app_information.os_version) are pinned to Chrome 120 so they tell
one consistent story.
Transport
---------
- SetBodyBytes instead of bytes.NewReader so Content-Length is set
(an io.Reader body forces Transfer-Encoding: chunked, which a
fetch() with JSON.stringify body never emits)
- Once we set Accept-Encoding manually, the Go stdlib disables its
transparent decompression and req hands us raw compressed bytes.
Handle gzip / deflate / br by hand from Content-Encoding.
- DisableAutoReadResponse so we own the body stream end-to-end.
The Chrome 120 TLS ClientHello, HTTP/2 SETTINGS frame, pseudo-header
order and sec-ch-ua claim continue to come from ImpersonateChrome()
unchanged.
Verified end-to-end:
- Outbound bytes (against a local echo server) diff-match the
extension's observed profile on every header and on body JSON order.
- Live oneshot-free.www.deepl.com calls: 4 language pairs OK,
/v2/translate official-API compat OK, 10x burst 10/10 200.
* chore(deps): upgrade to latest compatible versions
Run `go get -u ./...` + `go mod tidy`. Direct upgrades:
- github.com/andybalholm/brotli 1.2.0 → 1.2.1
- github.com/tidwall/gjson 1.18.0 → 1.19.0
Indirect (notable):
- github.com/bytedance/sonic 1.15.0 → 1.15.1
- github.com/bytedance/sonic/loader 0.5.0 → 0.5.1
- github.com/bytedance/gopkg 0.1.3 → 0.1.4
- github.com/cloudwego/base64x 0.1.6 → 0.1.7
- github.com/gin-contrib/sse 1.1.0 → 1.1.1
- github.com/go-playground/validator/v10 10.30.1 → 10.30.2
- github.com/goccy/go-json 0.10.5 → 0.10.6
- github.com/klauspost/compress 1.18.4 → 1.18.6
- github.com/mattn/go-isatty 0.0.20 → 0.0.22
- github.com/pelletier/go-toml/v2 2.2.4 → 2.3.1
- golang.org/x/arch 0.24.0 → 0.27.0
- golang.org/x/crypto 0.48.0 → 0.52.0
- golang.org/x/net 0.51.0 → 0.55.0
- golang.org/x/sys 0.41.0 → 0.45.0
- golang.org/x/text 0.34.0 → 0.37.0
github.com/imroc/req/v3 (the HTTP client we depend on for Chrome
impersonation) is already on its latest tag v3.57.0 and pins
github.com/quic-go/quic-go to <= v0.57.x — newer quic-go removed
ConnectionTracingID/ConnectionTracingKey, which req's internal/http3
still references. That constraint also holds gin-gonic/gin at v1.11.0
and gin-contrib/cors at v1.7.6 (their later versions pull quic-go
≥ 0.58 transitively). Pin quic-go to v0.57.1 to keep the build green;
revisit when req publishes a release compatible with quic-go ≥ 0.58.
Build + live oneshot end-to-end: 4 language pairs OK, /v2/translate
official-API compat OK, 8x burst 8/8 200.
* fix(translate): seed cookie jar from www.deepl.com on first call
A real chrome-extension fetch() to oneshot-free.www.deepl.com inherits
whatever cookies the browser has on .deepl.com — at minimum
`userCountry=<iso2>` and `verifiedBot=false`, both of which the
deepl.com server sets on any page load. Our outbound bytes were
otherwise extension-identical but went out cookieless, which is a
distinguishable signal.
Wire a process-wide net/http/cookiejar onto the req.Client and trigger
a single warmup GET to https://www.deepl.com/translator on the first
translate call (sync.Once). The Set-Cookie response (userCountry,
verifiedBot) lands on .deepl.com, which the jar then automatically
echoes back on every subsequent POST to oneshot-free.www.deepl.com
(cookies set on .deepl.com match any *.deepl.com subdomain).
Verified outbound:
Cookie: userCountry=JP; verifiedBot=false
Latency cost: first call after process start pays one extra HTTP GET
(~1s warmup); subsequent calls are unaffected (sync.Once + connection
keep-alive).
Note: we cannot replicate the _ga / _ga_<id> cookies a real user
would also carry — those are set client-side by GA's JS, which a
non-browser HTTP client can't execute. The userCountry+verifiedBot
pair already matches the "first-time visitor with JS disabled" profile,
which is the closest plausible non-browser approximation.
* Initial plan
* Update Dockerfile to use golang:1.25 to match go.mod requirement
Co-authored-by: thedavidweng <95214375+thedavidweng@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Reverts quic-go from v0.57.0 back to v0.49.1 and qpack from v0.6.0 to v0.5.1
due to breaking API changes that are incompatible with req/v3. The req/v3
library (downgraded to v3.50.0) has not been updated to support the newer
quic-go APIs yet.
Build was failing with undefined quic.Connection, quic.EarlyConnection, and
incompatible qpack.Decoder method signatures.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Renamed makeRequest to makeRequestWithBody for clarity.
- Introduced new TextItem and TextResponse types for better structure in translation requests and responses.
- Updated translation logic to handle multiple texts and alternatives.
- Enhanced error handling for blocked requests and translation failures.
- Adjusted timestamp and random number generation for improved request uniqueness.