Compare commits

...

9 Commits
v1.2.0 ... main

Author SHA1 Message Date
Bonny Lin
88a00874c8
fix(install): detect architecture instead of hardcoding amd64 (#227)
install.sh always downloaded the linux_amd64 binary, so arm64/386/mips machines got a binary that does not run. Detect the architecture via uname and pick the matching release artifact; releases already ship those builds.
2026-06-07 17:45:46 +08:00
Bonny Lin
231393c669
ci(release): build cross-platform binaries in parallel via matrix (#226)
The release job ran .cross_compile.sh, which builds all 12 targets sequentially in a single runner. Split the build into a matrix so each GOOS/GOARCH pair builds concurrently on its own runner, then a dependent Release job collects the artifacts and publishes them in one release. Output filenames are unchanged.
2026-06-05 11:10:47 +08:00
Vincent Young
9d6b294a61
fix(service): log translation errors and hide internals from clients
Return a generic "Translation failed" message instead of echoing
err.Error() in the HTTP response, and log the underlying error
server-side via log.Printf. Avoids leaking internal details (proxy
address, upstream URL, etc.) to API clients while keeping the 500
behavior introduced in #224.
2026-06-02 14:38:02 +08:00
Bonny Lin
d19a3902df
fix(service): validate request body on /translate and /v1/translate (#225)
The two handlers ignored the error from c.BindJSON, so a malformed JSON body was silently processed as an empty payload. Check the error and return 400, consistent with /v2/translate.
2026-06-02 14:32:22 +08:00
Bonny Lin
3f099d187c
fix(service): return 500 instead of crashing on translation error (#224)
The /translate, /v1/translate and /v2/translate handlers called log.Fatalf when TranslateByDeepLX returned an error. log.Fatalf calls os.Exit, so a single failed translation would tear down the whole server process. Return a 500 JSON response and keep serving instead.
2026-05-31 03:47:09 -07:00
趙子賢
1b88e428ae
feat(docker): non-root user + ENTRYPOINT (#222)
Closes #221.

- Run as non-root using an Alpine system account (UID auto-assigned from the 100-999 system range, referenced by name to avoid hard-coding).
- Replace CMD with ENTRYPOINT so args passed to `docker run` flow through to the deeplx binary.
- EXPOSE 1188 for documentation.
2026-05-24 01:30:05 +08:00
Vincent Yang
432c0a223c
fix(translate): enforce 1500-char text limit + add request timeout (#220)
* 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.
2026-05-22 13:03:15 +08:00
Vincent Yang
1a06baec6f
chore: ignore .DS_Store (#219)
macOS Finder writes .DS_Store sidecar files into every directory it
touches; they should never be tracked.
2026-05-22 12:23:11 +08:00
Vincent Yang
057387c957
feat(translate): validate source/target language codes (#218)
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)
2026-05-22 12:13:38 +08:00
6 changed files with 300 additions and 36 deletions

View File

@ -5,11 +5,28 @@ on:
pull_request:
name: Release
jobs:
Build:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { goos: darwin, goarch: amd64 }
- { goos: darwin, goarch: arm64 }
- { goos: linux, goarch: '386' }
- { goos: linux, goarch: amd64 }
- { goos: linux, goarch: arm64 }
- { goos: linux, goarch: mips }
- { goos: openbsd, goarch: amd64 }
- { goos: openbsd, goarch: arm64 }
- { goos: freebsd, goarch: amd64 }
- { goos: freebsd, goarch: arm64 }
- { goos: windows, goarch: '386' }
- { goos: windows, goarch: amd64 }
steps:
- uses: actions/checkout@v4
@ -17,12 +34,40 @@ jobs:
with:
go-version: "1.24.2"
- run: bash .cross_compile.sh
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
mkdir -p dist
BINARY="dist/deeplx_${GOOS}_${GOARCH}"
if [ "${GOOS}" = "windows" ]; then
BINARY="${BINARY}.exe"
fi
go build -trimpath -ldflags "-w -s" -o "${BINARY}" .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: deeplx_${{ matrix.goos }}_${{ matrix.goarch }}
path: dist/*
if-no-files-found: error
Release:
needs: Build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Release
uses: softprops/action-gh-release@v1
with:
draft: false
generate_release_notes: true
files: |
dist/*
files: dist/*

3
.gitignore vendored
View File

@ -1 +1,4 @@
DeepLX
# macOS Finder metadata
.DS_Store

View File

@ -6,5 +6,8 @@ RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o deeplx .
FROM alpine:latest
WORKDIR /app
COPY --from=builder /go/src/github.com/OwO-Network/DeepLX/deeplx /app/deeplx
CMD ["/app/deeplx"]
RUN addgroup -S deeplx && adduser -h /app -G deeplx -SH deeplx
USER deeplx:deeplx
COPY --from=builder --chown=deeplx:deeplx /go/src/github.com/OwO-Network/DeepLX/deeplx /app/deeplx
EXPOSE 1188
ENTRYPOINT ["/app/deeplx"]

View File

@ -16,7 +16,20 @@ install_deeplx(){
exit 1
fi
echo -e "DeepLX latest version: ${last_version}, Start install..."
wget -q -N --no-check-certificate -O /usr/bin/deeplx https://github.com/OwO-Network/DeepLX/releases/download/${last_version}/deeplx_linux_amd64
arch=$(uname -m)
case "${arch}" in
x86_64 | amd64) file_arch="amd64" ;;
aarch64 | arm64) file_arch="arm64" ;;
i386 | i686) file_arch="386" ;;
mips) file_arch="mips" ;;
*)
echo -e "${red}Unsupported architecture: ${arch}${plain}"
exit 1
;;
esac
wget -q -N --no-check-certificate -O /usr/bin/deeplx https://github.com/OwO-Network/DeepLX/releases/download/${last_version}/deeplx_linux_${file_arch}
chmod +x /usr/bin/deeplx
wget -q -N --no-check-certificate -O /etc/systemd/system/deeplx.service https://raw.githubusercontent.com/OwO-Network/DeepLX/main/deeplx.service

View File

@ -108,7 +108,13 @@ func Router(cfg *Config) *gin.Engine {
// Free API endpoint, No Pro Account required
r.POST("/translate", authMiddleware(cfg), func(c *gin.Context) {
req := PayloadFree{}
c.BindJSON(&req)
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"message": "Invalid request payload",
})
return
}
sourceLang := req.SourceLang
targetLang := req.TargetLang
@ -127,7 +133,12 @@ func Router(cfg *Config) *gin.Engine {
result, err := translate.TranslateByDeepLX(sourceLang, targetLang, translateText, tagHandling, proxyURL, "")
if err != nil {
log.Fatalf("Translation failed: %s", err)
log.Printf("Translation failed: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"message": "Translation failed",
})
return
}
if result.Code == http.StatusOK {
@ -152,7 +163,13 @@ func Router(cfg *Config) *gin.Engine {
// Pro API endpoint, Pro Account required
r.POST("/v1/translate", authMiddleware(cfg), func(c *gin.Context) {
req := PayloadFree{}
c.BindJSON(&req)
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"message": "Invalid request payload",
})
return
}
sourceLang := req.SourceLang
targetLang := req.TargetLang
@ -191,7 +208,12 @@ func Router(cfg *Config) *gin.Engine {
result, err := translate.TranslateByDeepLX(sourceLang, targetLang, translateText, tagHandling, proxyURL, dlSession)
if err != nil {
log.Fatalf("Translation failed: %s", err)
log.Printf("Translation failed: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"message": "Translation failed",
})
return
}
if result.Code == http.StatusOK {
@ -243,7 +265,12 @@ func Router(cfg *Config) *gin.Engine {
result, err := translate.TranslateByDeepLX("", targetLang, translateText, "", proxyURL, "")
if err != nil {
log.Fatalf("Translation failed: %s", err)
log.Printf("Translation failed: %s", err)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"message": "Translation failed",
})
return
}
if result.Code == http.StatusOK {

View File

@ -15,17 +15,21 @@ package translate
import (
"compress/flate"
"compress/gzip"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/andybalholm/brotli"
"github.com/imroc/req/v3"
@ -56,6 +60,26 @@ const (
impersonatedChromeMajor = "120"
chromeExtensionVersion = "1.86.0"
chromeExtensionID = "cofdbpoegempjloogbagkncekinflcnj"
// oneshot enforces a 1500-character hard cap on the total length of
// the `text` array (sum across all items). Source: the extension's
// own `G.notLoggedIn = 1500` constant in background.js. The server
// returns 400 `{"errors":{"text":["text exceeds maximum length"]}}`
// past this; bail early to spare the upstream and give the caller a
// faster, less ambiguous error.
maxFreeTextLength = 1500
// oneshotTimeout caps how long we wait on a single translate request.
// Without an explicit timeout, a hung upstream connection would
// dangle indefinitely and the caller (e.g. browser extension) would
// sit on a spinner forever — observed in the field.
oneshotTimeout = 20 * time.Second
// warmupTimeout caps the initial GET to www.deepl.com that seeds the
// cookie jar. Shorter than oneshotTimeout because warmup typically
// completes in well under a second; we'd rather skip a slow warmup
// (cookies are best-effort anyway) than block the first translation.
warmupTimeout = 5 * time.Second
)
// instanceID mirrors the UUID the extension persists in chrome.storage on
@ -75,6 +99,14 @@ var (
cookieWarmer sync.Once
)
// oneshotClients caches one req.Client per proxy URL so all translate
// calls share the underlying TCP / TLS / HTTP/2 connection pool.
// Creating a fresh req.Client per request meant a brand-new TLS
// handshake every time (~200-400ms of overhead on top of DeepL's own
// ~1.5s processing latency). Reusing the client lets keep-alive +
// session tickets cut that to near zero on the warm path.
var oneshotClients sync.Map // map[string]*req.Client
func sharedCookieJar() http.CookieJar {
cookieJarOnce.Do(func() {
j, _ := cookiejar.New(nil)
@ -86,10 +118,15 @@ func sharedCookieJar() http.CookieJar {
// warmCookies primes the shared jar by GETting www.deepl.com once.
// The Set-Cookie response (userCountry / verifiedBot) lands on .deepl.com,
// which is the eTLD+1 of oneshot-free.www.deepl.com, so subsequent POSTs
// to the oneshot endpoint will carry those cookies automatically.
// to the oneshot endpoint will carry those cookies automatically. The
// same request doubles as a TLS-handshake warmup: it leaves a live
// HTTP/2 connection to www.deepl.com in the client pool, which the
// first oneshot POST then resumes via TLS session tickets.
func warmCookies(client *req.Client) {
cookieWarmer.Do(func() {
_, _ = client.R().Get("https://www.deepl.com/translator")
ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
defer cancel()
_, _ = client.R().SetContext(ctx).Get("https://www.deepl.com/translator")
})
}
@ -104,26 +141,108 @@ func newInstanceID() string {
return fmt.Sprintf("%s-%s-%s-%s-%s", s[0:8], s[8:12], s[12:16], s[16:20], s[20:32])
}
// langCodeToOneshot translates DeepL's uppercase codes (DE, EN, ZH, ...)
// to the lowercase BCP-47-ish codes the oneshot endpoint requires (de,
// en-US, zh-Hans, ...). Unknown codes fall through lowercased.
var langCodeToOneshot = map[string]string{
// Language code tables mirror the bundled list in the extension's
// background.js (arrays `y` ~offset 6000 for the full target-capable
// set, `A` for source-only aliases). Keys are the uppercase forms
// callers pass; values are the lowercase BCP-47-ish forms the oneshot
// endpoint expects ("de", "en-US", "zh-Hans", ...).
//
// targetLangMap is what the API accepts as `target_lang`. EN and PT
// are intentionally absent — DeepL deprecated them as target codes in
// favour of EN-US/EN-GB and PT-BR/PT-PT, and the extension's y array
// reflects that. We accept EN/PT as a backward-compat convenience and
// resolve them to the regional default (en-US, pt-BR).
var targetLangMap = map[string]string{
"AR": "ar", "BG": "bg", "CS": "cs", "DA": "da", "DE": "de", "EL": "el",
"EN": "en-US", "EN-GB": "en-GB", "EN-US": "en-US",
"ES": "es", "ET": "et", "FI": "fi", "FR": "fr", "HU": "hu",
"ID": "id", "IT": "it", "JA": "ja", "KO": "ko", "LT": "lt", "LV": "lv",
"NB": "nb", "NL": "nl", "PL": "pl",
"PT": "pt-BR", "PT-BR": "pt-BR", "PT-PT": "pt-PT",
"EN-GB": "en-GB", "EN-US": "en-US",
"ES": "es", "ES-419": "es-419", "ET": "et", "FI": "fi", "FR": "fr",
"HE": "he", "HU": "hu", "ID": "id", "IT": "it", "JA": "ja", "KO": "ko",
"LT": "lt", "LV": "lv", "NB": "nb", "NL": "nl", "PL": "pl",
"PT-BR": "pt-BR", "PT-PT": "pt-PT",
"RO": "ro", "RU": "ru", "SK": "sk", "SL": "sl", "SV": "sv",
"TR": "tr", "UK": "uk",
"TR": "tr", "UK": "uk", "VI": "vi",
"ZH": "zh-Hans", "ZH-HANS": "zh-Hans", "ZH-HANT": "zh-Hant",
// Convenience aliases for legacy callers.
"EN": "en-US",
"PT": "pt-BR",
}
func toOneshotLang(code string) string {
if v, ok := langCodeToOneshot[strings.ToUpper(code)]; ok {
return v
// sourceLangMap is what the API accepts as `source_lang`. It is a
// superset of targetLangMap: EN and PT are first-class source codes
// (extension array `A`) mapping to the generic "en"/"pt" — used when
// the caller knows the input is English/Portuguese but does not want
// to commit to a regional variant.
var sourceLangMap = func() map[string]string {
m := make(map[string]string, len(targetLangMap)+2)
for k, v := range targetLangMap {
m[k] = v
}
return strings.ToLower(code)
m["EN"] = "en"
m["PT"] = "pt"
return m
}()
// resolveTargetLang validates and normalizes a user-supplied target
// language code. Returns "" and a non-nil error if the code is empty,
// "auto", or otherwise not in the supported set.
func resolveTargetLang(code string) (string, error) {
if code == "" {
return "", fmt.Errorf("target_lang is required")
}
if strings.EqualFold(code, "auto") {
return "", fmt.Errorf("target_lang cannot be \"auto\"; pick one of: %s", supportedTargetLangsList())
}
if v, ok := targetLangMap[strings.ToUpper(code)]; ok {
return v, nil
}
return "", fmt.Errorf("unsupported target_lang %q; valid codes: %s", code, supportedTargetLangsList())
}
// resolveSourceLang validates and normalizes a user-supplied source
// language code. An empty string or "auto" is allowed and returns
// ("", nil) so the caller omits source_lang and lets the server
// autodetect.
func resolveSourceLang(code string) (string, error) {
if code == "" || strings.EqualFold(code, "auto") {
return "", nil
}
if v, ok := sourceLangMap[strings.ToUpper(code)]; ok {
return v, nil
}
return "", fmt.Errorf("unsupported source_lang %q; valid codes: %s (or \"auto\")", code, supportedSourceLangsList())
}
// supportedTargetLangsList / supportedSourceLangsList return a sorted,
// comma-separated rendering of the supported codes for use in error
// messages. Cached at first call.
var (
targetLangsListOnce sync.Once
targetLangsList string
sourceLangsListOnce sync.Once
sourceLangsList string
)
func supportedTargetLangsList() string {
targetLangsListOnce.Do(func() {
targetLangsList = sortedKeys(targetLangMap)
})
return targetLangsList
}
func supportedSourceLangsList() string {
sourceLangsListOnce.Do(func() {
sourceLangsList = sortedKeys(sourceLangMap)
})
return sourceLangsList
}
func sortedKeys(m map[string]string) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
// appInformation matches the snake_case shape produced by background.js
@ -156,8 +275,33 @@ type oneshotRequest struct {
// headers (pragma, cache-control, upgrade-insecure-requests, sec-fetch-user)
// that a fetch() never emits — wipe those so the WAF cannot tell us apart
// on that axis.
// getOneshotClient returns a process-wide cached client for the given
// proxy URL, creating it on first use. Sharing the client across
// requests is the single biggest latency win we have on the warm path:
// it keeps the TLS / HTTP/2 connection in the pool so subsequent
// requests skip the handshake entirely. Kicks off cookie-jar warmup
// in the background on first creation so that the first real translate
// call lands on an already-established connection.
func getOneshotClient(proxyURL string) (*req.Client, error) {
if c, ok := oneshotClients.Load(proxyURL); ok {
return c.(*req.Client), nil
}
c, err := newOneshotClient(proxyURL)
if err != nil {
return nil, err
}
if actual, loaded := oneshotClients.LoadOrStore(proxyURL, c); loaded {
return actual.(*req.Client), nil
}
// First time we've seen this proxy. Kick warmup off in the
// background so the very first translate call can run in parallel
// with the TLS handshake to www.deepl.com.
go warmCookies(c)
return c, nil
}
func newOneshotClient(proxyURL string) (*req.Client, error) {
client := req.C().ImpersonateChrome().SetCookieJar(sharedCookieJar())
client := req.C().ImpersonateChrome().SetCookieJar(sharedCookieJar()).SetTimeout(oneshotTimeout)
for _, h := range []string{
"Pragma",
"Cache-Control",
@ -187,11 +331,10 @@ func newOneshotClient(proxyURL string) (*req.Client, error) {
// exactly. Omitting that header instead would put the request on a
// different server-side auth branch.
func callOneshot(endpoint string, body []byte, bearerToken, proxyURL string) (gjson.Result, int, error) {
client, err := newOneshotClient(proxyURL)
client, err := getOneshotClient(proxyURL)
if err != nil {
return gjson.Result{}, 0, err
}
warmCookies(client) // no-op after the first translation in the process
authValue := "None"
if bearerToken != "" {
@ -251,9 +394,32 @@ func TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string,
}, nil
}
resolvedTarget, err := resolveTargetLang(targetLang)
if err != nil {
return DeepLXTranslationResult{
Code: http.StatusBadRequest,
Message: err.Error(),
}, nil
}
resolvedSource, err := resolveSourceLang(sourceLang)
if err != nil {
return DeepLXTranslationResult{
Code: http.StatusBadRequest,
Message: err.Error(),
}, nil
}
if n := utf8.RuneCountInString(text); n > maxFreeTextLength {
return DeepLXTranslationResult{
Code: http.StatusRequestEntityTooLarge,
Message: fmt.Sprintf("text exceeds maximum length: %d characters (anonymous oneshot limit is %d)", n, maxFreeTextLength),
}, nil
}
reqStruct := oneshotRequest{
Text: []string{text},
TargetLang: toOneshotLang(targetLang),
TargetLang: resolvedTarget,
SourceLang: resolvedSource, // empty = autodetect; omitempty drops the field
UsageType: "Translate",
AppInformation: appInformation{
OS: "brex_macOS",
@ -263,9 +429,6 @@ func TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string,
InstanceID: instanceID,
},
}
if sourceLang != "" && !strings.EqualFold(sourceLang, "auto") {
reqStruct.SourceLang = toOneshotLang(sourceLang)
}
bodyBytes, _ := json.Marshal(reqStruct)
endpoint := oneshotFreeEndpoint
@ -276,6 +439,16 @@ func TranslateByDeepLX(sourceLang, targetLang, text string, tagHandling string,
id := time.Now().UnixMilli()
result, status, err := callOneshot(endpoint, bodyBytes, dlSession, proxyURL)
if err != nil {
// Map upstream timeouts to 504 so callers can distinguish "DeepL
// took too long" from other 503 failure modes (DNS, TLS, etc.).
var ue *url.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ue) && ue.Timeout()) {
return DeepLXTranslationResult{
ID: id,
Code: http.StatusGatewayTimeout,
Message: fmt.Sprintf("upstream DeepL request timed out after %s", oneshotTimeout),
}, nil
}
return DeepLXTranslationResult{
ID: id,
Code: http.StatusServiceUnavailable,