Compare commits

..

18 Commits

Author SHA1 Message Date
Julien Neuhart
e29b7cb4f5 build(makefile): fix lint-todo for golangci-lint v2 2026-09-03 19:23:14 +02:00
Julien Neuhart
23d59f3133 refactor: adopt strings.Cut and strings.SplitSeq
Applies what go fix now proposes, so make fmt is a no-op on a clean tree
instead of dirtying these two files on every run. Both rewrites are
equivalent: Cut's first result matches SplitN(s, sep, 2)[0], and SplitSeq
iterates the same substrings without building the intermediate slice.
2026-09-03 19:22:23 +02:00
Julien Neuhart
8a0de7d5d7 build(makefile): disable two go fix modernizers that emit broken code
go fix runs the modernize suite since Go 1.26, and two of its analyzers
rewrite this codebase into code that does not compile, so make fmt broke
the build and then make lint. CONTRIBUTING tells every contributor to run
both before opening a PR.

embedlit folded telemetryCfg.LogLevel into a literal that already set that
key, and errorsastype rewrote errors.As to errors.AsType[HttpError] even
though HttpError does not embed error. Disable both, with a TODO covering
when each can come back.
2026-09-03 19:22:23 +02:00
Julien Neuhart
3c691cbebc test(health): assert conversions succeed across a planned restart 2026-09-03 17:55:35 +02:00
Julien Neuhart
0e83f737b4 docs(supervisor): point maybeRestartAfterTask at the timeout constant 2026-09-03 17:52:52 +02:00
Julien Neuhart
57b048c611 fix(supervisor): reset the request counter on a failed relaunch
restart() reset reqCounter only after a successful Launch, so a failed
one left it at the limit. maybeRestartAfterTask then re-fired on every
subsequent task, producing back-to-back restarts and, with planned
restarts now reporting healthy, a node that keeps restarting while
claiming health.

Reset on the attempt instead. A process that will not start is recovered
by ensureHealthy, which restarts synchronously and reports the failure.
2026-09-03 17:52:01 +02:00
Julien Neuhart
d79e174c6f fix(supervisor): bound the eager restart with a deadline
maybeRestartAfterTask ran its restart on a bare background context while
the drain loop in doRestartLocked selects only on ctx.Done(). A task that
never completed blocked the drain forever, pinning isRestarting. Since a
planned restart now reports healthy, that left the node claiming health
for good. LibreOffice escaped it because its concurrency of 1 drains no
slots, but Chromium defaults to 6.

Give that restart its own deadline. On expiry it aborts and the next task
retries it.
2026-09-03 16:10:16 +02:00
Julien Neuhart
88ddaed09b fix(supervisor): report healthy during planned process restarts
The eager restart fired after --chromium-restart-after or
--libreoffice-restart-after conversions made Healthy() report false,
so a client probing /health between two conversions got a 503 from an
otherwise serving node. Tasks arriving during that window are requeued
by acquireSlot, not rejected.

Track whether the in-flight restart is planned and keep reporting
healthy for those. Unplanned restarts still report unhealthy so load
balancers get honest information.

Closes #1648
2026-09-03 16:04:20 +02:00
Julien Neuhart
8944db131c fix(api): bound downloadFrom concurrency and entry count 2026-09-02 14:57:29 +02:00
Johannes Klein
676570074a fix(Dockerfile): re-add libreoffice-math so DOCX math formulas render
The v8.30.0 image slimming replaced the libreoffice metapackage with an
explicit component list that omits libreoffice-math. Writer imports OMML
equations as Math objects, so without the component every formula is
silently dropped from converted PDFs.

Fixes #1644
2026-09-02 11:55:30 +02:00
dependabot[bot]
11179cb271 chore(deps): bump google.golang.org/grpc from 1.83.0 to 1.83.1 (#1646)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.83.0 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.83.0...v1.83.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 10:51:17 +02:00
Julien Neuhart
c2a85f92d0 chore(deps): update unoconverter to v0.5.0 2026-08-31 19:01:18 +02:00
Muhammad Haseeb
7dbff18e65 fix(chromium): fail fast with 503 when Chromium crashes (#1641) 2026-08-31 16:48:21 +02:00
Julien Neuhart
923e5f71eb style(api): remove redundant parentheses in type switch 2026-08-31 13:38:05 +02:00
Julien Neuhart
334f859d95 chore(deps): update golangci-lint to v2.13.2 2026-08-31 13:36:02 +02:00
Julien Neuhart
0819514b7b chore(deps): update Go to 1.27.0 2026-08-31 13:36:02 +02:00
Julien Neuhart
c636a52666 fix(otel): keep telemetry off unless an exporter is configured 2026-08-31 13:27:05 +02:00
dependabot[bot]
0b16b0ab34 chore(deps): bump github.com/stretchr/testify from 1.11.1 to 1.12.1 (#1636)
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.1 to 1.12.1.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.11.1...v1.12.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 11:57:55 +02:00
25 changed files with 911 additions and 83 deletions

View File

@@ -31,7 +31,7 @@ jobs:
- name: Run linters
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
version: v2.13.2
lint-prettier:
name: Lint non-Golang codebase

View File

@@ -38,6 +38,8 @@ API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false
API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false
API_DOWNLOAD_FROM_MAX_RETRY=4
API_DOWNLOAD_FROM_MAX_CONCURRENCY=10
API_DOWNLOAD_FROM_MAX_ENTRIES=0
API_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
@@ -210,11 +212,22 @@ lint-prettier: ## Lint non-Golang codebase
.PHONY: lint-todo
lint-todo: ## Find TODOs in Golang codebase
golangci-lint run --no-config --disable-all --enable godox
golangci-lint run --no-config --default=none --enable godox
# TODO: restore a plain "go fix ./..." once both modernizers stop rewriting this
# codebase into code that does not compile. Re-check by dropping a flag and
# running "make fmt && make lint". Removing an analyzer upstream makes go fix
# fail with "flag provided but not defined", so this cannot rot silently.
# embedlit folds a post-literal field assignment into a literal that already
# sets that key, producing a duplicate field name (cmd/gotenberg.go). Fixed in
# Go 1.27.1, so drop that flag once the toolchain moves past 1.27.0.
# See https://github.com/golang/go/issues/81101.
# errorsastype rewrites errors.As to errors.AsType[T] without checking that T
# satisfies error, which breaks on api.HttpError since it does not embed
# error. Still broken as of Go 1.27.1.
.PHONY: fmt
fmt: ## Format Golang codebase and "optimize" the dependencies
go fix ./...
go fix -embedlit=false -errorsastype=false ./...
golangci-lint fmt
go mod tidy

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them.
ARG GOLANG_VERSION=1.26.5
ARG GOLANG_VERSION=1.27.0
# ----------------------------------------------
# pdfcpu binary build stage
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
WORKDIR /downloads
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.4.0/unoconv -o unoconverter \
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.5.0/unoconv -o unoconverter \
&& chmod +x unoconverter
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
@@ -259,7 +259,7 @@ RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/so
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw libreoffice-math python3-uno \
# unoconverter will look for the Python binary, which has to be at version 3.
&& ln -s /usr/bin/python3 /usr/bin/python \
# Cleanup.
@@ -386,7 +386,7 @@ RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/so
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw libreoffice-math python3-uno \
# unoconverter will look for the Python binary, which has to be at version 3.
&& ln -s /usr/bin/python3 /usr/bin/python \
# Cleanup.

View File

@@ -35,6 +35,8 @@ services:
- "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}"
- "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}"
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
- "--api-download-from-max-concurrency=${API_DOWNLOAD_FROM_MAX_CONCURRENCY}"
- "--api-download-from-max-entries=${API_DOWNLOAD_FROM_MAX_ENTRIES}"
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"

17
go.mod
View File

@@ -1,11 +1,12 @@
module github.com/gotenberg/gotenberg/v8
go 1.26.5
go 1.27.0
require (
github.com/alexliesenfeld/health v0.8.1
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
github.com/coreos/go-oidc/v3 v3.20.0
github.com/cucumber/godog v0.16.0
github.com/dlclark/regexp2 v1.12.0
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984
@@ -20,10 +21,11 @@ require (
github.com/prometheus/client_golang v1.24.1
github.com/shirou/gopsutil/v4 v4.26.7
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/testcontainers/testcontainers-go v0.44.0
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/log v0.21.0
go.opentelemetry.io/otel/metric v1.45.0
@@ -37,11 +39,6 @@ require (
golang.org/x/text v0.41.0
)
require (
github.com/coreos/go-oidc/v3 v3.20.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
@@ -64,7 +61,6 @@ require (
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cucumber/gherkin/go/v42 v42.0.1 // indirect
github.com/cucumber/messages/go/v34 v34.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.8.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
@@ -106,7 +102,6 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.28 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
@@ -136,6 +131,7 @@ require (
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
@@ -143,7 +139,6 @@ require (
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

21
go.sum
View File

@@ -55,7 +55,6 @@ github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgX
github.com/cucumber/messages/go/v34 v34.2.1 h1:qBPEl+HhNJuRX8Kjaw1Pm60KOODZf2/4WQAi0SR/neE=
github.com/cucumber/messages/go/v34 v34.2.1/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
@@ -130,10 +129,6 @@ github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
@@ -192,7 +187,6 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
@@ -206,8 +200,6 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
@@ -228,8 +220,8 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
@@ -303,6 +295,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
@@ -333,15 +327,12 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 h1:
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"sync/atomic"
"go.opentelemetry.io/contrib/bridges/otelslog"
@@ -74,6 +75,24 @@ func buildResource(ctx context.Context, logger *slog.Logger, serviceName, servic
return merged
}
// OTEL_*_EXPORTER select the exporter for each signal. autoexport treats an
// unset or empty value as a request for the OTLP exporter, which then fails
// against the default localhost:4318 endpoint when nothing listens there and,
// for metrics, keeps retrying on the periodic reader's timer. Gotenberg keeps
// telemetry opt-in: a signal with no exporter configured is built without one
// and stays inert. See https://github.com/gotenberg/gotenberg/issues/1643.
const (
tracesExporterEnvKey = "OTEL_TRACES_EXPORTER"
metricsExporterEnvKey = "OTEL_METRICS_EXPORTER"
logsExporterEnvKey = "OTEL_LOGS_EXPORTER"
)
// exporterConfigured reports whether the operator selected an exporter for the
// signal owning envKey. An unset or blank value keeps that signal off.
func exporterConfigured(envKey string) bool {
return strings.TrimSpace(os.Getenv(envKey)) != ""
}
// InitTracerProvider initializes the OpenTelemetry tracer provider.
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
initOtelLogger(logger)
@@ -86,13 +105,14 @@ func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string)
trace.WithResource(res),
}
traceExporter, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneSpanExporter(traceExporter) {
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
if exporterConfigured(tracesExporterEnvKey) {
traceExporter, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneSpanExporter(traceExporter) {
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
}
}
traceProvider := trace.NewTracerProvider(traceOpts...)
@@ -119,13 +139,14 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
}
metricOpts = append(metricOpts, exemplarFilterOptions()...)
metricReader, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneMetricReader(metricReader) {
metricOpts = append(metricOpts, metric.WithReader(metricReader))
if exporterConfigured(metricsExporterEnvKey) {
metricReader, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneMetricReader(metricReader) {
metricOpts = append(metricOpts, metric.WithReader(metricReader))
}
}
meterProvider := metric.NewMeterProvider(metricOpts...)
@@ -157,13 +178,14 @@ func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string)
log.WithResource(res),
}
logExporter, err := autoexport.NewLogExporter(ctx)
if err != nil {
return nil, nil, err
}
if !autoexport.IsNoneLogExporter(logExporter) {
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
if exporterConfigured(logsExporterEnvKey) {
logExporter, err := autoexport.NewLogExporter(ctx)
if err != nil {
return nil, nil, err
}
if !autoexport.IsNoneLogExporter(logExporter) {
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
}
}
loggerProvider := log.NewLoggerProvider(logOpts...)

View File

@@ -89,6 +89,49 @@ func TestInitTracerProvider_HonorsSamplerEnv(t *testing.T) {
}
}
// TestExporterConfigured pins the opt-in gate: an unset or blank
// OTEL_*_EXPORTER keeps the signal off, so Gotenberg never wires the OTLP
// exporter that autoexport would otherwise default to and fail to reach at
// localhost:4318. See https://github.com/gotenberg/gotenberg/issues/1643.
func TestExporterConfigured(t *testing.T) {
const key = "OTEL_METRICS_EXPORTER"
orig, had := os.LookupEnv(key)
t.Cleanup(func() {
if had {
os.Setenv(key, orig)
return
}
os.Unsetenv(key)
})
for _, tc := range []struct {
scenario string
unset bool
value string
want bool
}{
{"unset", true, "", false},
{"empty", false, "", false},
{"whitespace only", false, " ", false},
{"none", false, "none", true},
{"otlp", false, "otlp", true},
{"padded value", false, " otlp ", true},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.unset {
os.Unsetenv(key)
} else {
os.Setenv(key, tc.value)
}
if got := exporterConfigured(key); got != tc.want {
t.Errorf("exporterConfigured(%q) = %v, want %v", key, got, tc.want)
}
})
}
}
func TestExemplarFilterOptions(t *testing.T) {
t.Run("default pins trace-based", func(t *testing.T) {
if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {

View File

@@ -59,8 +59,9 @@ type ProcessSupervisor interface {
// Healthy checks and returns the health status of the managed [Process].
//
// A non-started process is considered healthy (startup is deferred until
// the first request). Returns false if the process is currently restarting
// or is reported unhealthy by the underlying [Process].
// the first request), as is one going through a planned restart, since it
// keeps serving traffic. Returns false during an unplanned restart or when
// the underlying [Process] reports unhealthy.
Healthy() bool
// Run executes a provided task while managing the state of the [Process].
@@ -103,6 +104,28 @@ const healthCheckCacheTTL = 2 * time.Second
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
const healthFailureThreshold = 2
// Restart reasons, also reported as the gotenberg.process.start.reason span
// attribute by [processSupervisor.tracedLaunch]. Only
// [restartReasonMaxRequests] is a planned restart: it fires on a healthy
// process that reached its conversion limit, so the node keeps serving
// traffic throughout. The others signal a process that cannot serve.
const (
restartReasonFirstStart = "first_start"
restartReasonUnhealthy = "unhealthy"
restartReasonMaxRequests = "max_requests"
)
// defaultEagerRestartTimeout bounds the restart triggered after the maximum
// request limit. That restart runs on a background context, unlike the one from
// ensureHealthy which inherits the request deadline, so without a deadline of
// its own the drain loop in [processSupervisor.doRestartLocked] would wait
// forever on a task that never completes. That would pin isRestarting and,
// with it, the health reported by [processSupervisor.Healthy]. Sized well above
// --api-timeout (30s by default) plus the engine start timeouts (20s by
// default) so it never fires while tasks are merely slow. The eager restart is
// opportunistic: on expiry it aborts, and the next task retries it.
const defaultEagerRestartTimeout = 2 * time.Minute
type processSupervisor struct {
logger *slog.Logger
engine string
@@ -118,11 +141,16 @@ type processSupervisor struct {
// transient failure (such as a cold-start timeout) must not poison the
// supervisor for the rest of the container's lifetime. See
// https://github.com/gotenberg/gotenberg/issues/1538.
firstStartMu sync.Mutex
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
firstStartMu sync.Mutex
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
// restartPlanned records whether the in-flight restart is a planned one
// (see [restartReasonMaxRequests]). Written before isRestarting and never
// cleared, so a reader that observed isRestarting always sees the matching
// kind. See [processSupervisor.Healthy].
restartPlanned atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
idleShutdownTimeout time.Duration
@@ -135,6 +163,9 @@ type processSupervisor struct {
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
idleMu sync.Mutex // protects idleStopChan
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
// eagerRestartTimeout bounds the restart from maybeRestartAfterTask.
// Defaults to [defaultEagerRestartTimeout]; only tests shorten it.
eagerRestartTimeout time.Duration
}
// NewProcessSupervisor initializes a new [ProcessSupervisor]. engine names the
@@ -158,6 +189,7 @@ func NewProcessSupervisor(logger *slog.Logger, engine string, process Process, m
maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
idleShutdownTimeout: idleShutdownTimeout,
eagerRestartTimeout: defaultEagerRestartTimeout,
}
b.reqCounter.Store(0)
b.reqQueueSize.Store(0)
@@ -212,12 +244,18 @@ func (s *processSupervisor) restart() error {
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
}
// Reset the counter on the attempt, not on its outcome. Leaving it at the
// limit after a failed launch re-triggers maybeRestartAfterTask on every
// subsequent task, producing back-to-back restarts. Recovering a process
// that will not start is ensureHealthy's job: it restarts synchronously
// before running a task, and reports the failure to the caller.
s.reqCounter.Store(0)
err = s.Launch()
if err != nil {
return fmt.Errorf("restart process: %w", err)
}
s.reqCounter.Store(0)
s.restartsCounter.Add(1)
s.logger.DebugContext(context.Background(), "process successfully restarted")
@@ -234,9 +272,17 @@ func (s *processSupervisor) Healthy() bool {
}
if s.isRestarting.Load() {
// A restarting process is not yet healthy. This gives load balancers
// honest information so they can avoid routing traffic to this node.
return false
// A planned restart is routine maintenance: the process reached the
// limit set by --chromium-restart-after (env CHROMIUM_RESTART_AFTER) or
// --libreoffice-restart-after (env LIBREOFFICE_RESTART_AFTER) while
// healthy. Tasks arriving during it are requeued by acquireSlot, not
// rejected, so the node still serves traffic and must report healthy. A
// probe sent between two conversions used to fail here.
// See https://github.com/gotenberg/gotenberg/issues/1648.
//
// An unplanned restart keeps reporting unhealthy, which gives load
// balancers honest information so they can avoid routing traffic here.
return s.restartPlanned.Load()
}
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
@@ -484,7 +530,7 @@ func (s *processSupervisor) ensureStarted(ctx context.Context) error {
return nil
}
err := s.tracedLaunch(ctx, "first_start", func() error {
err := s.tracedLaunch(ctx, restartReasonFirstStart, func() error {
return s.runWithDeadline(ctx, s.Launch)
})
if err != nil {
@@ -525,7 +571,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
if err := s.doRestart(ctx, "unhealthy"); err != nil {
if err := s.doRestart(ctx, restartReasonUnhealthy); err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
@@ -533,9 +579,10 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
}
// maybeRestartAfterTask checks if the maximum request limit has been reached
// and, if so, triggers an asynchronous restart. If a restart is initiated, it
// takes ownership of the caller's semaphore slot (the caller must not release
// it). Returns true if ownership was taken.
// and, if so, triggers an asynchronous restart bounded by
// [defaultEagerRestartTimeout]. If a restart is initiated, it takes ownership
// of the caller's semaphore slot (the caller must not release it). Returns true
// if ownership was taken.
func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
return false
@@ -548,7 +595,10 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
go func() {
restartErr := s.doRestartLocked(context.Background(), "max_requests")
ctx, cancel := context.WithTimeout(context.Background(), s.eagerRestartTimeout)
defer cancel()
restartErr := s.doRestartLocked(ctx, restartReasonMaxRequests)
s.restartMutex.Unlock()
if restartErr != nil {
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
@@ -571,6 +621,10 @@ func (s *processSupervisor) doRestart(ctx context.Context, reason string) error
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
func (s *processSupervisor) doRestartLocked(ctx context.Context, reason string) error {
// Publish the kind before raising the flag. [processSupervisor.Healthy]
// reads restartPlanned only after it observes isRestarting, so this
// ordering keeps it from pairing a new restart with a stale kind.
s.restartPlanned.Store(reason == restartReasonMaxRequests)
s.isRestarting.Store(true)
defer s.isRestarting.Store(false)

View File

@@ -160,13 +160,49 @@ func TestProcessSupervisor_restart(t *testing.T) {
}
}
// TestProcessSupervisor_restart_ResetsCounterOnFailedLaunch verifies that a
// restart whose launch fails still clears the request counter. Leaving it at
// the limit makes maybeRestartAfterTask re-fire on every subsequent task.
func TestProcessSupervisor_restart_ResetsCounterOnFailedLaunch(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
const maxReqLimit = 5
process := &ProcessMock{
StartMock: func(_ *slog.Logger) error { return errors.New("start error") },
StopMock: func(_ *slog.Logger) error { return nil },
HealthyMock: func(_ *slog.Logger) bool { return true },
}
ps := NewProcessSupervisor(logger, "test", process, maxReqLimit, 0, 1, 0).(*processSupervisor)
ps.reqCounter.Store(maxReqLimit)
err := ps.restart()
if err == nil {
t.Fatal("expected error but got none")
}
if got := ps.reqCounter.Load(); got != 0 {
t.Fatalf("expected the request counter to be reset but got %d", got)
}
if got := ps.restartsCounter.Load(); got != 0 {
t.Fatalf("expected the restarts counter to stay at 0 but got %d", got)
}
if ps.maybeRestartAfterTask(logger) {
t.Fatal("expected no further eager restart to be triggered")
}
}
func TestProcessSupervisor_Healthy(t *testing.T) {
for _, tc := range []struct {
scenario string
initiallyStarted bool
initiallyRestarting bool
processHealthy bool
expectHealthy bool
scenario string
initiallyStarted bool
initiallyRestarting bool
initiallyRestartPlanned bool
processHealthy bool
expectHealthy bool
}{
{
scenario: "non-started process is healthy",
@@ -179,6 +215,13 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
initiallyRestarting: true,
expectHealthy: false,
},
{
scenario: "process going through a planned restart is healthy",
initiallyStarted: true,
initiallyRestarting: true,
initiallyRestartPlanned: true,
expectHealthy: true,
},
{
scenario: "process reports as healthy",
initiallyStarted: true,
@@ -208,6 +251,9 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
if tc.initiallyRestarting {
ps.isRestarting.Store(true)
}
if tc.initiallyRestartPlanned {
ps.restartPlanned.Store(true)
}
healthy := ps.Healthy()
@@ -257,6 +303,204 @@ func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
}
}
// TestProcessSupervisor_Healthy_PlannedRestart reproduces
// https://github.com/gotenberg/gotenberg/issues/1648. It drives the real
// Run() path until the maximum request limit triggers the eager restart, then
// asserts the supervisor reports healthy while that restart is in flight.
// Tasks arriving during it are requeued by acquireSlot, not rejected, so the
// node still serves traffic.
func TestProcessSupervisor_Healthy_PlannedRestart(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
const maxReqLimit = 10
restarting := make(chan struct{})
release := make(chan struct{})
var (
starts atomic.Int64
signalOne sync.Once
)
process := &ProcessMock{
StartMock: func(_ *slog.Logger) error {
// Hold the restart open so the assertions below run inside the
// window that used to report unhealthy.
if starts.Add(1) > 1 {
signalOne.Do(func() { close(restarting) })
<-release
}
return nil
},
StopMock: func(_ *slog.Logger) error { return nil },
HealthyMock: func(_ *slog.Logger) bool { return true },
}
ps := NewProcessSupervisor(logger, "test", process, maxReqLimit, 0, 1, 0).(*processSupervisor)
for i := range maxReqLimit {
err := ps.Run(context.Background(), logger, func() error { return nil })
if err != nil {
t.Fatalf("task %d: expected no error but got: %v", i+1, err)
}
}
select {
case <-restarting:
case <-time.After(10 * time.Second):
t.Fatalf("expected an eager restart after %d tasks", maxReqLimit)
}
if !ps.isRestarting.Load() {
t.Fatal("expected the supervisor to be restarting")
}
if !ps.restartPlanned.Load() {
t.Fatal("expected the restart to be flagged as planned")
}
if !ps.Healthy() {
t.Fatal("expected a planned restart to report healthy")
}
close(release)
}
// TestProcessSupervisor_Healthy_UnplannedRestart verifies the counterpart of
// [TestProcessSupervisor_Healthy_PlannedRestart]: a restart triggered by an
// unhealthy process keeps reporting unhealthy, so load balancers get honest
// information.
func TestProcessSupervisor_Healthy_UnplannedRestart(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
restarting := make(chan struct{})
release := make(chan struct{})
var signalOne sync.Once
process := &ProcessMock{
StartMock: func(_ *slog.Logger) error {
signalOne.Do(func() { close(restarting) })
<-release
return nil
},
StopMock: func(_ *slog.Logger) error { return nil },
HealthyMock: func(_ *slog.Logger) bool { return false },
}
ps := NewProcessSupervisor(logger, "test", process, 0, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
go func() {
_ = ps.ensureHealthy(context.Background())
}()
select {
case <-restarting:
case <-time.After(10 * time.Second):
t.Fatal("expected an unhealthy restart to be triggered")
}
if !ps.isRestarting.Load() {
t.Fatal("expected the supervisor to be restarting")
}
if ps.restartPlanned.Load() {
t.Fatal("expected the restart not to be flagged as planned")
}
if ps.Healthy() {
t.Fatal("expected an unplanned restart to report unhealthy")
}
close(release)
}
// TestProcessSupervisor_doRestartLocked_DrainDeadline verifies that a drain
// unable to acquire every slot gives up on the context deadline and clears
// isRestarting. Without a deadline on the eager restart, a task that never
// completes would pin the flag and, since a planned restart reports healthy,
// leave the supervisor claiming health forever.
func TestProcessSupervisor_doRestartLocked_DrainDeadline(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var starts atomic.Int64
process := &ProcessMock{
StartMock: func(_ *slog.Logger) error {
starts.Add(1)
return nil
},
StopMock: func(_ *slog.Logger) error { return nil },
HealthyMock: func(_ *slog.Logger) bool { return true },
}
// A concurrency of 2 makes the drain acquire one slot on top of the one the
// triggering task hands over. Fill the semaphore so it never can, mimicking
// a concurrent task that never completes.
ps := NewProcessSupervisor(logger, "test", process, 1, 0, 2, 0).(*processSupervisor)
ps.semaphore <- struct{}{}
ps.semaphore <- struct{}{}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := ps.doRestartLocked(ctx, restartReasonMaxRequests)
if err == nil {
t.Fatal("expected the drain to fail on the context deadline")
}
if ps.isRestarting.Load() {
t.Fatal("expected isRestarting to be cleared after a failed drain")
}
if starts.Load() != 0 {
t.Fatalf("expected no restart attempt after a failed drain but got %d", starts.Load())
}
}
// TestProcessSupervisor_maybeRestartAfterTask_Bounded verifies that the eager
// restart runs under a deadline. A concurrent task that never completes blocks
// the drain, and without a bound the restart goroutine would wait forever with
// isRestarting pinned, leaving Healthy() reporting a planned restart for good.
func TestProcessSupervisor_maybeRestartAfterTask_Bounded(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(_ *slog.Logger) error { return nil },
StopMock: func(_ *slog.Logger) error { return nil },
HealthyMock: func(_ *slog.Logger) bool { return true },
}
ps := NewProcessSupervisor(logger, "test", process, 1, 0, 2, 0).(*processSupervisor)
ps.eagerRestartTimeout = 100 * time.Millisecond
ps.firstStart.Store(true)
// Wedge one slot so the drain, which needs one on top of the slot the
// triggering task hands over, can never complete.
ps.semaphore <- struct{}{}
err := ps.Run(context.Background(), logger, func() error { return nil })
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
waitFor := func(what string, want bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for ps.isRestarting.Load() != want {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for the eager restart to %s", what)
}
time.Sleep(5 * time.Millisecond)
}
}
waitFor("start", true)
waitFor("give up on its deadline", false)
}
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
// successful probe is cached for [healthCheckCacheTTL] so subsequent
// supervisor.Healthy() calls do not re-issue the underlying process

View File

@@ -67,6 +67,8 @@ type downloadFromConfig struct {
denyPublicIPs bool
enableEnvironmentProxy bool
maxRetry int
maxConcurrency int
maxEntries int
disable bool
}
@@ -212,6 +214,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
fs.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Int("api-download-from-max-concurrency", 10, "Set the maximum number of downloadFrom entries fetched concurrently per request - bounds the outbound fan-out. Set to 0 to disable this feature")
fs.Int("api-download-from-max-entries", 0, "Set the maximum number of downloadFrom entries allowed per request. Set to 0 to disable this feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route")
@@ -255,6 +259,8 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
maxConcurrency: flags.MustInt("api-download-from-max-concurrency"),
maxEntries: flags.MustInt("api-download-from-max-entries"),
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
@@ -404,6 +410,18 @@ func (a *Api) Validate() error {
}
}
if a.downloadFromCfg.maxConcurrency < 0 {
err = errors.Join(err,
fmt.Errorf("download from max concurrency must not be negative, got %d; set --api-download-from-max-concurrency (env API_DOWNLOAD_FROM_MAX_CONCURRENCY) to 0 to disable the limit", a.downloadFromCfg.maxConcurrency),
)
}
if a.downloadFromCfg.maxEntries < 0 {
err = errors.Join(err,
fmt.Errorf("download from max entries must not be negative, got %d; set --api-download-from-max-entries (env API_DOWNLOAD_FROM_MAX_ENTRIES) to 0 to disable the limit", a.downloadFromCfg.maxEntries),
)
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = errors.Join(err,
errors.New("both TLS certificate and key files must be set"),

View File

@@ -222,6 +222,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
)
}
// Reject oversized arrays at the trust boundary, before allocating
// the results slice or spawning any goroutine, so a compact request
// cannot inflate into an unbounded fan-out.
if downloadFromCfg.maxEntries > 0 && len(dls) > downloadFromCfg.maxEntries {
return nil, cancel, WrapError(
fmt.Errorf("too many downloadFrom entries: got %d, max %d", len(dls), downloadFromCfg.maxEntries),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: too many entries, the maximum is %d", downloadFromCfg.maxEntries)),
)
}
// Each goroutine writes to its own results slot. The main
// goroutine merges into ctx.files, ctx.diskToOriginal, and
// ctx.filesByField after eg.Wait() to avoid concurrent map
@@ -232,6 +242,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
results := make([]downloadFromResult, len(dls))
eg, _ := errgroup.WithContext(ctx)
// Bound the number of in-flight downloads. Each entry allocates a
// retryable client, an outbound transport, a span, and logger state,
// so an unbounded array would otherwise exhaust process memory. A
// value of 0 keeps the fan-out unbounded.
if downloadFromCfg.maxConcurrency > 0 {
eg.SetLimit(downloadFromCfg.maxConcurrency)
}
for i, dl := range dls {
eg.Go(func() error {
deadline, ok := ctx.Deadline()

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"mime/multipart"
@@ -11,6 +12,7 @@ import (
"net/http/httptest"
"os"
"sync"
"sync/atomic"
"testing"
"time"
@@ -209,6 +211,137 @@ func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
}
}
// An oversized downloadFrom array must be rejected at the trust boundary with
// a 400, before any download goroutine is spawned.
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
func TestNewContext_DownloadFromMaxEntries(t *testing.T) {
var hits atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Disposition", `attachment; filename="download.txt"`)
_, _ = w.Write([]byte("downloaded"))
}))
defer server.Close()
dls := make([]downloadFrom, 3)
for i := range dls {
dls[i] = downloadFrom{Url: fmt.Sprintf("%s/file?i=%d", server.URL, i)}
}
payload, err := json.Marshal(dls)
if err != nil {
t.Fatalf("marshal downloadFrom payload: %v", err)
}
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
err = writer.WriteField("downloadFrom", string(payload))
if err != nil {
t.Fatalf("write downloadFrom field: %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
logger := slog.New(slog.DiscardHandler)
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
downloadFromCfg := downloadFromConfig{maxEntries: 2}
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
if cancel != nil {
defer cancel()
}
if err == nil {
t.Fatal("newContext returned no error, want a 400 for too many entries")
}
var httpErr HttpError
if !errors.As(err, &httpErr) {
t.Fatalf("error %v is not an HttpError", err)
}
if status, _ := httpErr.HttpError(); status != http.StatusBadRequest {
t.Fatalf("HTTP status = %d, want %d", status, http.StatusBadRequest)
}
if got := hits.Load(); got != 0 {
t.Fatalf("server hits = %d, want 0 (rejected before any download)", got)
}
}
// The number of in-flight downloadFrom fetches must never exceed the
// configured concurrency limit, regardless of array length.
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
func TestNewContext_DownloadFromMaxConcurrency(t *testing.T) {
const (
downloads = 8
maxConcurrency = 2
)
var current, peak atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
inFlight := current.Add(1)
for {
observed := peak.Load()
if inFlight <= observed || peak.CompareAndSwap(observed, inFlight) {
break
}
}
time.Sleep(20 * time.Millisecond)
current.Add(-1)
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte("downloaded"))
}))
defer server.Close()
dls := make([]downloadFrom, downloads)
for i := range dls {
dls[i] = downloadFrom{Url: fmt.Sprintf("%s/file?i=%d", server.URL, i)}
}
payload, err := json.Marshal(dls)
if err != nil {
t.Fatalf("marshal downloadFrom payload: %v", err)
}
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
err = writer.WriteField("downloadFrom", string(payload))
if err != nil {
t.Fatalf("write downloadFrom field: %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
logger := slog.New(slog.DiscardHandler)
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
downloadFromCfg := downloadFromConfig{maxConcurrency: maxConcurrency}
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
if err != nil {
t.Fatalf("newContext returned error: %v", err)
}
defer cancel()
if got := len(ctx.files); got != downloads {
t.Fatalf("downloaded files = %d, want %d", got, downloads)
}
if got := peak.Load(); got > maxConcurrency {
t.Fatalf("peak concurrency = %d, want <= %d", got, maxConcurrency)
}
}
func TestSanitizeFilename(t *testing.T) {
for _, tc := range []struct {
scenario string

View File

@@ -612,7 +612,7 @@ func (form *FormData) mustValue(key string, target any, defaultValue any) *FormD
val, ok := form.values[key]
if !ok || val[0] == "" {
switch t := (target).(type) {
switch t := target.(type) {
case *string:
*t = defaultValue.(string)
case *bool:
@@ -659,7 +659,7 @@ func (form *FormData) mustMandatoryField(key string, target any) *FormData {
func (form *FormData) mustAssign(key, value string, target any) *FormData {
var err error
switch t := (target).(type) {
switch t := target.(type) {
case *string:
*t = value
case *bool:

View File

@@ -523,8 +523,45 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
cancelOnMainPageError: taskCancel,
})
var (
crashed error
crashedMu sync.RWMutex
)
// See https://github.com/gotenberg/gotenberg/issues/1640.
listenForEventTargetCrashed(taskCtx, logger, eventTargetCrashedOptions{
crashed: &crashed,
crashedMu: &crashedMu,
cancel: taskCancel,
})
runErr := chromedp.Run(taskCtx, tasks...)
// A crashed renderer is the root cause of every other failure this
// conversion may have recorded, so check it first.
// See https://github.com/gotenberg/gotenberg/issues/1640.
crashedMu.RLock()
defer crashedMu.RUnlock()
if crashed != nil {
return fmt.Errorf("handle tasks: %w", crashed)
}
// The browser context is only ever canceled when the browser process
// dies or is stopped, never on a request timeout. If the run failed
// and the browser context is done, the conversion failed because the
// browser went away mid-flight; fail fast with the same crash error
// instead of letting the error fall through as a generic context
// cancellation. The check is gated on runErr so a successful
// conversion is never discarded by a browser death that lands right
// after it.
// See https://github.com/gotenberg/gotenberg/issues/1640.
if runErr != nil {
if err := b.ctx.Err(); err != nil {
return fmt.Errorf("handle tasks: %w", ErrChromiumCrashed)
}
}
// Check event-driven errors first — they take priority over chromedp.Run
// errors because they carry the actual root cause (e.g., HTTP 500 from
// the main page). When we cancel taskCtx on a main page error,

View File

@@ -71,6 +71,10 @@ var (
// ErrResourceLoadingFailed happens when one or more resources failed to load.
ErrResourceLoadingFailed = errors.New("resource loading failed")
// ErrChromiumCrashed happens when the Chromium renderer crashes during a
// conversion.
ErrChromiumCrashed = errors.New("chromium crashed")
// PDF specific.
// ErrOmitBackgroundWithoutPrintBackground happens if
@@ -1106,6 +1110,8 @@ func chromiumErrorType(err error, queueReason string) string {
errors.Is(err, ErrInvalidEvaluationExpression),
errors.Is(err, ErrInvalidSelectorQuery):
return gotenberg.ErrorTypeInvalidInput
case errors.Is(err, ErrChromiumCrashed):
return "chromium_unavailable"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
return queueReason
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):

View File

@@ -21,6 +21,7 @@ func TestChromiumErrorType(t *testing.T) {
{"invalid resource http status", ErrInvalidResourceHttpStatusCode, "chromium_unavailable", "invalid_input"},
{"loading failed", ErrLoadingFailed, "chromium_unavailable", "invalid_input"},
{"resource loading failed", ErrResourceLoadingFailed, "chromium_unavailable", "invalid_input"},
{"crashed", ErrChromiumCrashed, "chromium_unavailable", "chromium_unavailable"},
{"invalid evaluation expression", ErrInvalidEvaluationExpression, "chromium_unavailable", "invalid_input"},
{"invalid selector query", ErrInvalidSelectorQuery, "chromium_unavailable", "invalid_input"},
{"pdf queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_unavailable", "chromium_unavailable"},

View File

@@ -14,6 +14,7 @@ import (
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/inspector"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
@@ -521,6 +522,38 @@ func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, con
})
}
type eventTargetCrashedOptions struct {
crashed *error
crashedMu *sync.RWMutex
cancel context.CancelFunc
}
// listenForEventTargetCrashed listens for the Inspector.targetCrashed event,
// which Chromium sends when the renderer serving the conversion's tab
// crashes. chromedp enables the Inspector domain on every target but does
// not handle this event: left alone, the in-flight CDP command never
// receives a response and the conversion blocks until the request deadline.
// Record the crash and cancel the task context so the conversion fails fast
// instead.
// See https://github.com/gotenberg/gotenberg/issues/1640.
func listenForEventTargetCrashed(ctx context.Context, logger *slog.Logger, options eventTargetCrashedOptions) {
chromedp.ListenTarget(ctx, func(ev any) {
if _, ok := ev.(*inspector.EventTargetCrashed); ok {
logger.DebugContext(ctx, "event EventTargetCrashed fired")
options.crashedMu.Lock()
defer options.crashedMu.Unlock()
*options.crashed = ErrChromiumCrashed
// Cancel the task context so the in-flight CDP command aborts
// immediately instead of waiting for a response the crashed
// renderer can never send.
options.cancel()
}
})
}
// waitForEventDomContentEventFired registers a listener for the
// DomContentEventFired event and returns a waiter that blocks until the
// event fires or ctx is done. The listener registers at call time, not

View File

@@ -1020,6 +1020,16 @@ func handleChromiumError(err error, options Options) error {
return nil
}
if errors.Is(err, ErrChromiumCrashed) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusServiceUnavailable,
"Chromium crashed while processing the request. Retry, or reduce the workload if the problem persists.",
),
)
}
if errors.Is(err, ErrInvalidEvaluationExpression) {
if options.WaitForExpression == "" {
// We do not expect the 'waitWindowStatus' form field to return

View File

@@ -0,0 +1,51 @@
package chromium
import (
"fmt"
"net/http"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
// TestHandleChromiumError_Crashed pins the mapping of a Chromium renderer
// crash to a 503 Service Unavailable. When the renderer crashes mid-conversion,
// the request must fail fast with 503 rather than hang until the deadline and
// surface as a generic timeout.
// See https://github.com/gotenberg/gotenberg/issues/1640.
func TestHandleChromiumError_Crashed(t *testing.T) {
// Mirror the wrapping done by [chromiumBrowser.do].
err := handleChromiumError(fmt.Errorf("handle tasks: %w", ErrChromiumCrashed), Options{})
if err == nil {
t.Fatal("expected an error, got none")
}
status, message := api.ParseError(err)
if status != http.StatusServiceUnavailable {
t.Errorf("status = %d, want %d (message: %s)", status, http.StatusServiceUnavailable, message)
}
want := "Chromium crashed while processing the request. Retry, or reduce the workload if the problem persists."
if message != want {
t.Errorf("message = %q, want %q", message, want)
}
}
// TestHandleChromiumError_CrashedTakesPrecedence guards the ordering in
// [handleChromiumError]: a crash is a server-side failure and must map to 503
// even when the error chain also carries a marker that another branch would
// map to a client-error status.
func TestHandleChromiumError_CrashedTakesPrecedence(t *testing.T) {
err := handleChromiumError(
fmt.Errorf("handle tasks: %w; %w", ErrChromiumCrashed, ErrInvalidHttpStatusCode),
Options{},
)
if err == nil {
t.Fatal("expected an error, got none")
}
status, _ := api.ParseError(err)
if status != http.StatusServiceUnavailable {
t.Errorf("status = %d, want %d", status, http.StatusServiceUnavailable)
}
}

View File

@@ -361,7 +361,7 @@ func TestSofficeProxyEnv_OverridesExisting(t *testing.T) {
// Old proxy values must be gone, not duplicated. Count exact-case keys.
counts := map[string]int{}
for _, kv := range out {
key := strings.SplitN(kv, "=", 2)[0]
key, _, _ := strings.Cut(kv, "=")
counts[key]++
}
for _, key := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} {

View File

@@ -180,7 +180,7 @@ func (engine *PdfCpu) listImages(ctx context.Context, inputPath string) ([]pdfcp
// their second column is not a numeric object number.
func parseImagesList(output string) []pdfcpuImage {
var images []pdfcpuImage
for _, line := range strings.Split(output, "\n") {
for line := range strings.SplitSeq(output, "\n") {
cols := strings.Split(line, "│")
if len(cols) < 9 {
continue

View File

@@ -1401,3 +1401,19 @@ Feature: /forms/chromium/convert/url
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 page(s)
# chrome://crash makes the renderer crash deterministically, the same
# failure class as a renderer crash triggered by the page content. The
# request must fail fast with a 503 instead of hanging until the API
# timeout.
# See https://github.com/gotenberg/gotenberg/issues/1640.
Scenario: POST /forms/chromium/convert/url (Chromium crash fails fast with 503)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | chrome://crash | field |
Then the response status code should be 503
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
Chromium crashed while processing the request
"""

View File

@@ -1,6 +1,5 @@
# TODO:
# 1. Check if down for each module.
# 2. Restarting modules do not make health check fail.
@health
Feature: /health
@@ -106,7 +105,19 @@ Feature: /health
When I make a "HEAD" request to Gotenberg at the "/foo/health" endpoint
Then the response status code should be 200
# A planned restart, the eager cycle after LIBREOFFICE_RESTART_AFTER
# conversions, must not fail the health check: requests arriving during it
# are requeued, not rejected. Setting the limit to 1 restarts LibreOffice
# after every conversion, so each probe lands right on a restart.
# See https://github.com/gotenberg/gotenberg/issues/1648.
Scenario: GET /health (Planned LibreOffice Restart)
Given I have a Gotenberg container with the following environment variable(s):
| LIBREOFFICE_RESTART_AFTER | 1 |
When I make 5 sequential "POST" requests to Gotenberg at the "/forms/libreoffice/convert" endpoint, probing "/health" after each, with the following form data and header(s):
| files | testdata/page_1.docx | file |
Then all sequential response status codes should be 200
Then all probe response status codes should be 200
# TODO:
# 1. Check if down for each module.
# 2. Restarting modules do not make health check fail.
# 1. Check if down for each module.

View File

@@ -88,6 +88,8 @@ func findScenarioLine(filePath, name string) int {
type scenario struct {
resp *httptest.ResponseRecorder
concurrentResps []*httptest.ResponseRecorder
probeResps []*httptest.ResponseRecorder
sequentialResps []*httptest.ResponseRecorder
workdir string
teststoreDir string
gotenbergContainer testcontainers.Container
@@ -99,6 +101,8 @@ type scenario struct {
func (s *scenario) reset(ctx context.Context) error {
s.resp = httptest.NewRecorder()
s.concurrentResps = nil
s.probeResps = nil
s.sequentialResps = nil
err := os.RemoveAll(s.workdir)
if err != nil {
@@ -470,6 +474,130 @@ func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count
return nil
}
// iMakeSequentialRequestsToGotenbergProbing mirrors the client loop from
// https://github.com/gotenberg/gotenberg/issues/1648: a conversion, then a
// probe, repeated. It records both the conversion and the probe responses so a
// scenario can assert that a planned process restart neither fails the probe
// nor breaks the conversions. Requests are sequential on purpose, since the bug
// only surfaces between two conversions.
func (s *scenario) iMakeSequentialRequestsToGotenbergProbing(ctx context.Context, count int, method, endpoint, probeEndpoint string, dataTable *godog.Table) error {
if s.gotenbergContainer == nil {
return errors.New("no Gotenberg container")
}
fields := make(map[string][]string)
files := make(map[string][]string)
headers := make(map[string]string)
for _, row := range dataTable.Rows {
name := row.Cells[0].Value
value := row.Cells[1].Value
kind := row.Cells[2].Value
switch kind {
case "field":
fields[name] = append(fields[name], value)
case "file":
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get current directory: %w", err)
}
value = fmt.Sprintf("%s/%s", wd, value)
files[name] = append(files[name], value)
case "header":
headers[name] = value
default:
return fmt.Errorf("unexpected %q %q", kind, value)
}
}
base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
if err != nil {
return fmt.Errorf("get container HTTP endpoint: %w", err)
}
record := func(resp *http.Response) (*httptest.ResponseRecorder, error) {
defer resp.Body.Close()
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("read response body: %w", readErr)
}
rec := httptest.NewRecorder()
rec.Code = resp.StatusCode
for key, values := range resp.Header {
for _, v := range values {
rec.Header().Add(key, v)
}
}
_, writeErr := rec.Body.Write(body)
if writeErr != nil {
return nil, fmt.Errorf("write response body: %w", writeErr)
}
return rec, nil
}
s.probeResps = make([]*httptest.ResponseRecorder, 0, count)
s.sequentialResps = make([]*httptest.ResponseRecorder, 0, count)
for i := range count {
resp, reqErr := doFormDataRequest(method, fmt.Sprintf("%s%s", base, endpoint), fields, files, headers)
if reqErr != nil {
return fmt.Errorf("request %d: do request: %w", i+1, reqErr)
}
rec, recErr := record(resp)
if recErr != nil {
return fmt.Errorf("request %d: %w", i+1, recErr)
}
s.resp = rec
s.sequentialResps = append(s.sequentialResps, rec)
probeResp, probeErr := doRequest(http.MethodGet, fmt.Sprintf("%s%s", base, probeEndpoint), nil, nil)
if probeErr != nil {
return fmt.Errorf("probe %d: do request: %w", i+1, probeErr)
}
probeRec, probeRecErr := record(probeResp)
if probeRecErr != nil {
return fmt.Errorf("probe %d: %w", i+1, probeRecErr)
}
s.probeResps = append(s.probeResps, probeRec)
}
return nil
}
func (s *scenario) allProbeResponseStatusCodesShouldBe(expected int) error {
if len(s.probeResps) == 0 {
return errors.New("no probe responses recorded")
}
for i, resp := range s.probeResps {
if resp.Code != expected {
return fmt.Errorf("probe %d: expected status %d, got %d %q", i+1, expected, resp.Code, resp.Body.String())
}
}
return nil
}
func (s *scenario) allSequentialResponseStatusCodesShouldBe(expected int) error {
if len(s.sequentialResps) == 0 {
return errors.New("no sequential responses recorded")
}
for i, resp := range s.sequentialResps {
if resp.Code != expected {
return fmt.Errorf("sequential response %d: expected status %d, got %d %q", i+1, expected, resp.Code, resp.Body.String())
}
}
return nil
}
func (s *scenario) allConcurrentResponseStatusCodesShouldBe(expected int) error {
if len(s.concurrentResps) == 0 {
return errors.New("no concurrent responses recorded")
@@ -1644,10 +1772,13 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders)
ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders)
ctx.When(`^I make (\d+) concurrent "(POST)" requests to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeConcurrentRequestsToGotenberg)
ctx.When(`^I make (\d+) sequential "(POST)" requests to Gotenberg at the "([^"]*)" endpoint, probing "([^"]*)" after each, with the following form data and header\(s\):$`, s.iMakeSequentialRequestsToGotenbergProbing)
ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook)
ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries)
ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe)
ctx.Then(`^all concurrent response status codes should be (\d+)$`, s.allConcurrentResponseStatusCodesShouldBe)
ctx.Then(`^all probe response status codes should be (\d+)$`, s.allProbeResponseStatusCodesShouldBe)
ctx.Then(`^all sequential response status codes should be (\d+)$`, s.allSequentialResponseStatusCodesShouldBe)
ctx.Then(`^all concurrent responses should have (\d+) PDF\(s\)$`, s.allConcurrentResponsesShouldHavePdfs)
ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
ctx.Then(`^the webhook request header "([^"]*)" should carry trace id "([^"]*)"$`, s.theWebhookRequestHeaderShouldCarryTraceID)