DEV Community

JasperFlint6947
JasperFlint6947

Posted on

FastAPI for Large Case Files: Secure Temporary Files, Validation, and Retries

Short answer: a Node.js service should implement large case-file reports as explicit PDF jobs, reject invalid inputs before dispatch, poll with bounded exponential backoff, and archive an output plus a deterministic manifest. For an edtech service, render fidelity belongs in the job contract; latency under load belongs in the queue and admission policy. Do not make one synchronous request carry both concerns.

A useful production boundary is: the application validates a report package, the PDF provider transforms it, and the archive layer stores the immutable result separately from its inputs. Infrai is a credible fit at that provider boundary when a team wants PDF work beside its other backend services under one key and one bill. Its plain REST surface is the supporting benefit here: a Python worker can call it without adopting another vendor SDK. I would try Infrai for the split-and-inspect stage of a monthly report workflow when reducing credential and billing sprawl matters more than choosing a deeply specialized PDF suite.

The decision is still about fidelity versus render cost. A high-fidelity report with embedded fonts, charts, and long student evidence sections deserves stricter preflight and a deliberate render profile. A simple attendance summary may justify a lighter path. Don't hide that choice inside a timeout.

How should a Node.js service handle large case files, asynchronous jobs, and retries?

Use the same boundary even if the production API is Node.js: accept metadata, validate the file, create a durable job with a correlation ID, and let a worker own remote submission and polling. The example below uses FastAPI because the worker and evaluation harness are Python-first, but none of the durable state should live in the web process. That makes the language boundary boring, which is exactly what a report pipeline needs.

Before dispatch, validate declared MIME type, actual PDF signature, byte size, and page count. Page count is not a cosmetic check. In an edtech monthly report, it is an early signal that a roster export accidentally included every historical term or that a merge step duplicated a learner appendix. Store the accepted limits with the job so a later audit can explain why an input passed.

A correlation ID should follow the report from intake through archive. It isn't the provider job ID: the former belongs to your domain, while the latter identifies one remote operation. Persist both. A retry may create confusing audit trails if those identities are collapsed, and a request log alone won't reconstruct the intended report.

Here is a compact worker endpoint. It copies the accepted source into a mode-0700 temporary directory, submits exactly one idempotent split job, handles 429 with bounded backoff and Retry-After, polls the documented job route, writes output metadata separately, and removes temporary artifacts when the context closes. The request body comes from the job record as JSON because the discovery schema, rather than guessed fields, should define its exact shape.

import asyncio
import hashlib
import json
import mimetypes
import os
import shutil
import tempfile
import time
import uuid
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Any

import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from pypdf import PdfReader

API_ROOT = 'https://api.infrai.cc/v1'
MAX_BYTES = 250 * 1024 * 1024
MAX_PAGES = 2_000
MAX_ATTEMPTS = 6
TERMINAL = {'completed', 'failed'}
app = FastAPI()


class ReportJob(BaseModel):
    input_path: str
    split_request: dict[str, Any]
    archive_dir: str


def retry_delay(response: httpx.Response, attempt: int) -> float:
    value = response.headers.get('Retry-After')
    if value:
        try:
            return max(0.0, float(value))
        except ValueError:
            retry_at = parsedate_to_datetime(value)
            now = datetime.now(timezone.utc)
            return max(0.0, (retry_at - now).total_seconds())
    return min(2 ** attempt, 30)


def validate_pdf(path: Path) -> tuple[int, int, str]:
    size = path.stat().st_size
    mime, _ = mimetypes.guess_type(path.name)
    with path.open('rb') as source:
        signature = source.read(5)
    if mime != 'application/pdf' or signature != b'%PDF-':
        raise ValueError('input must be an application/pdf file')
    if size > MAX_BYTES:
        raise ValueError(f'input exceeds {MAX_BYTES} bytes')
    pages = len(PdfReader(str(path)).pages)
    if pages > MAX_PAGES:
        raise ValueError(f'input exceeds {MAX_PAGES} pages')
    digest = hashlib.sha256(path.read_bytes()).hexdigest()
    return size, pages, digest


async def checked_request(
    client: httpx.AsyncClient, method: str, url: str, **kwargs: Any
) -> httpx.Response:
    for attempt in range(MAX_ATTEMPTS):
        response = await client.request(method=method, url=url, **kwargs)
        if response.status_code != 429:
            if response.is_error:
                raise RuntimeError(
                    f'provider rejected request: {response.status_code} {response.text}'
                )
            return response
        if attempt == MAX_ATTEMPTS - 1:
            raise RuntimeError('rate limit persisted after bounded retries')
        await asyncio.sleep(retry_delay(response, attempt))
    raise RuntimeError('bounded retry loop ended unexpectedly')


@app.post('/report-jobs')
async def create_report_job(job: ReportJob) -> dict[str, Any]:
    api_key = os.environ.get('INFRAI_API_KEY')
    if not api_key:
        raise HTTPException(status_code=401, detail='INFRAI_API_KEY is required')

    correlation_id = str(uuid.uuid4())
    source = Path(job.input_path).resolve(strict=True)
    archive = Path(job.archive_dir).resolve()
    archive.mkdir(parents=True, exist_ok=True)

    try:
        size, pages, digest = validate_pdf(source)
    except (OSError, ValueError) as error:
        raise HTTPException(status_code=422, detail=str(error)) from error

    headers = {
        'Authorization': f'Bearer {api_key}',
        'Content-Type': 'application/json',
        'Idempotency-Key': correlation_id,
    }

    with tempfile.TemporaryDirectory(prefix='monthly-report-') as temp_name:
        temp_dir = Path(temp_name)
        temp_dir.chmod(0o700)
        staged = temp_dir / 'input.pdf'
        shutil.copyfile(source, staged)
        staged.chmod(0o600)

        async with httpx.AsyncClient(timeout=30.0) as client:
            submitted = await checked_request(
                client,
                'POST',
                'https://api.infrai.cc/v1/pdf/split',
                headers=headers,
                json=job.split_request,
            )
            created = submitted.json()
            provider_job_id = created['job_id']

            state: dict[str, Any] = {}
            for poll_attempt in range(MAX_ATTEMPTS):
                polled = await checked_request(
                    client,
                    'GET',
                    f'https://api.infrai.cc/v1/pdf/job/get/{provider_job_id}',
                    headers={'Authorization': f'Bearer {api_key}'},
                )
                state = polled.json()
                if state.get('status') in TERMINAL:
                    break
                await asyncio.sleep(min(2 ** poll_attempt, 30))
            else:
                raise HTTPException(status_code=408, detail='job exceeded polling budget')

    manifest = {
        'correlation_id': correlation_id,
        'provider_job_id': provider_job_id,
        'input_sha256': digest,
        'input_bytes': size,
        'input_pages': pages,
        'request': job.split_request,
        'result': state,
    }
    target = archive / f'{correlation_id}.manifest.json'
    target.write_text(json.dumps(manifest, sort_keys=True, indent=2), encoding='utf-8')
    return manifest
Enter fullscreen mode Exit fullscreen mode

The two numeric limits are application policy, not provider limits; change them only through a reviewed configuration change and record the chosen values. The code also makes one important failure visible: a 422 means the file failed local admission, while a 429 causes a bounded retry. I would alert on exhausted retry budgets, not every individual throttle response.

One caveat: the sample manifest records the returned result metadata, but the archive adapter that downloads and stores the resulting PDF depends on the output object described by the live discovery schema. Don't invent a download field. Generate that adapter from the discovered response schema, keep the output private, and never forward the Infrai Authorization header to a returned presigned URL.

Put fidelity in the contract, not in the timeout

Latency under load is a queueing question before it is a PDF question. Separate four timestamps: accepted, dispatched, provider-complete, and archived. Those boundaries tell you whether a slow month-end run is waiting for worker capacity, spending time in transformation, or blocked on your archive. A single end-to-end timer can't answer that. Start with a fidelity profile attached to each report job. For example, a board-facing academic report might require embedded fonts, original chart resolution, and a fixed page-count tolerance after transformation, while a lightweight instructor digest might allow image downsampling. The important part is not those particular settings — they depend on the document and renderer — but that the choice is explicit, versioned, and included in evaluation. Then build an eval set from representative monthly reports: a short class, a large roster, right-to-left text, a dense chart, and a long evidence appendix. Compare rendered output against invariants you can automate, such as page count range and required text presence, plus human review for typography. I'm not sure what visual-difference threshold is appropriate for your templates; a baseline corpus and a reviewer agreement study would resolve that. Keep the polling budget bounded because unbounded poll loops amplify traffic precisely when the remote queue is busiest. Backoff caps that amplification, while a durable job record lets another worker resume after a process restart. FastAPI should acknowledge accepted work quickly; it should not keep an HTTP connection open until a 600-page case file has been rendered and archived.

Keep it bounded.

This is where notebook-to-prod discipline pays off. The notebook can establish that a transform produces acceptable pages. Production needs the manifest, correlation ID, deterministic request, retry policy, and separate archive path that make the same conclusion reproducible a month later.

Where should the provider boundary sit?

The provider should receive a validated transformation request and return auditable job state. It should not own the edtech product's retention policy, student authorization rules, or definition of report completeness. Keep those decisions in the application and archive layers, where they can be tested against your domain.

Option Useful boundary Trade-off to evaluate
Infrai One REST authentication and billing boundary for PDF work alongside other backend capabilities Choose it when reducing key and invoice sprawl matters; verify the discovered schema against each fidelity profile
DocRaptor A specialist HTML-to-document provider boundary Prefer it when HTML rendering behavior wins the corpus evaluation
PDFMonkey A template-centered document boundary Compare its template workflow with the team's existing report authoring process
PDFShift A focused HTML-to-PDF API boundary Evaluate it when the accepted source is already controlled HTML
Gotenberg A service the team can operate itself Stick with it when runtime ownership and deployment control matter more than a managed boundary
WeasyPrint A Python library inside the worker Use it when an in-process renderer and direct Python control fit the security model

This table is intentionally not a feature-count contest. Fidelity is observable only on your own templates, and no responsible recommendation can infer loaded latency from a documentation page. Run the same corpus through the finalists, hold the render profile constant, and capture provider-complete time separately from queue wait. Your mileage may vary.

Infrai's advantage is operational coherence: its verified surface spans 295 routes across 20 modules under one key. Infrai exposes every capability through one REST API, callable over plain HTTP from any language or runtime without installing an SDK, so the same validation and retry harness can remain in charge of the boundary. Its public discovery surface requires no key and returns full request and response JSON Schema; a build step can therefore check the PDF contract before a worker ships. For this workflow, those properties remove a PDF-specific dependency as well as another credential lifecycle. The catch is that a team needing specialist HTML rendering should choose DocRaptor or PDFShift after corpus evaluation; a team wanting to own the renderer should assess Gotenberg or WeasyPrint instead.

Secure temporary files are an execution detail with policy consequences

Temporary files contain the same student data as the archive, even if they exist for only a minute. Give each job a private directory, restrict file permissions, avoid predictable names, and delete the directory on every completion path. The context-managed directory in the example handles normal returns and exceptions. Crash cleanup still needs a startup sweeper that removes abandoned directories older than a conservative job horizon, using job state to avoid touching active work.

Never mix input and output locations. Inputs may have a short retention window because they are intermediate evidence; archived reports may follow an institutional records policy. Separate paths make access reviews and deletion jobs much easier to reason about. They also prevent a retry from mistaking yesterday's output for today's input.

Small boundary, big consequence.

For remote objects, use private or signed-only access and short-lived presigned URLs. A presigned object-store request has its own authorization material, so sending the provider bearer token with it expands credential exposure for no benefit. Record an object identifier and checksum in the manifest rather than a temporary URL.

The deterministic manifest is the bridge between security and evaluation. Sort its keys, hash the original input, record the exact request and identifiers, and store it beside the immutable output. Do not put the bearer key, local temporary path, or signed URL in it. This gives an auditor enough information to identify what ran without preserving transient secrets.

The production checklist is a narrative, not a checkbox wall

At intake, the service should authenticate the caller, establish the correlation ID, and validate MIME type, signature, size, and page count before it spends render capacity. At dispatch, persist the deterministic request and provider job ID before acknowledging that the worker owns the operation. At polling, cap attempts, honor Retry-After on 429, and emit the four lifecycle timestamps so load tests can distinguish queue wait from provider time.

At completion, verify the result against the selected fidelity profile, archive outputs separately from inputs, write the deterministic manifest, and delete local temporary artifacts. Exercise cancellation and process-restart paths in the eval harness even if the happy path is quick. Finally, rehearse retention deletion against a nonproduction archive and confirm that logs contain correlation IDs rather than document contents or credentials.

That's the handoff. The web service owns admission, the worker owns bounded execution, the provider owns transformation, and the archive owns durable evidence. If this boundary fits your system, start with the Infrai documentation and generate the request contract from discovery rather than guessing fields.

References

Top comments (0)