PDF API
Solo system
A public FastAPI service that compresses, converts, OCRs, redacts, protects and repairs PDFs. Heavy tools run as subprocesses with timeouts, and repair runs in a separate process under a memory limit, so a hostile file cannot take the worker down. It ships to Cloud Run through a keyless pipeline whose smoke test demands a 401.
- Type
- API / Documents
- Status
- Deployed
One upload, contained. In order: POST /v2/<tool>, request id · one error envelope; then API key, fail-closed in production; then Chunked read, stops at the size cap; then Thread pool, blocking work off the event loop; then Subprocess tools, each with a timeout, each in a temporary directory; then Repair worker, a separate process under a memory limit and a wall clock.
Side lane — the pipeline: Lint; Tests, 213 of them; Keyless deploy, OIDC, no stored credential; Smoke test, demands a 401.
- 1Cost tracks vector paths, not pages: a 0.03 s count refuses first.
- 2Redaction also clears the bookmarks, where titles leak.
- 3The timeout budget is itself a test: every tool must finish under the proxy’s deadline.
- Problem
- Four production timeouts from one 1.4 MB file: every retry died near the 55-second mark.
- Constraint
- One container, one request at a time, a 60-second ceiling — and uploads from strangers.
- Decision
- Measure what the cost tracks: vector paths, not pages or bytes. Count them first, in 0.03 s, and refuse with an answer the user can act on.
- Outcome
- The same file now gets a clear 'split it' at once. 213 tests guard the service, among them a purpose-built corpus of corrupt files.
General notes
- 01The Ghostscript version is gated at build against the distro's security floor.
- 02Concurrency 1 and 2 GiB are a documented floor, not a default.
- 03Redaction also clears the bookmarks, where titles leak.
- 04The timeout budget is itself a test.
- 05Spreadsheet cells are neutralised against formula injection.
Bill of quantities
| Item | Quantity |
|---|---|
| Endpointsgrep -c '@router.post' app/router_v2.py | 15 |
| Application lineswc -l over git ls-files app/*.py | 3,035 |
| Test filesgit ls-files tests | grep -c '.py$' | 33 |
| Test functionsgrep -rhoE 'def test_[a-zA-Z0-9_]+' tests | wc -l | 213 |
| Binary fixturesls tests/fixtures | wc -l | 11 |
| OCR languagesgrep -ohE 'tesseract-ocr-[a-z]{3}' Dockerfile | sort -u | wc -l | 8 |
| CI jobsawk over the jobs: block of .github/workflows/deploy.yml | 2 |
| Commits, default branch, non-mergegit rev-list --count --no-merges origin/main, 2026-09-18 | 59 |
Detail 1:1
# pdf2docx inspects every vector path hunting for table borders, so # its cost tracks path count — not pages, not bytes. Measured on # 10-page files against the 45s subprocess budget: # # vector items | 2 010 | 15 010 | 30 010 | 40 010 | 50 010 # convert time | 0.6 s | 2.5 s | 10.0 s | 19.4 s | 30.7 s # # Superlinear, and on hardware faster than the 2-vCPU container. # A larger file of 180 plain-text pages carries zero paths and # converts in 7.8 s, which is why this counts paths and not size. # # This is the shape behind four 504s on 2026-08-13: one 1.4 MB PDF, # four retries in five minutes, every one dying at ~55 s. Counting # costs 0.03 s, so saying no quickly is nearly free. vector_items = sum( len(drawing.get("items", ())) for page in doc for drawing in page.get_cdrawings() ) if vector_items > get_settings().max_vector_items: raise ApiError( status_code=422, code="too_complex_pdf", message=( "Este PDF tem demasiados elementos gráficos para converter " "para Word dentro do tempo limite. Divida-o primeiro com a " "ferramenta Dividir PDF e converta cada parte." ), )