Skip to content

feat(worker): Phase 2 hybrid routing — TikTok/Instagram/X via browser-downloader - #141

Open
tomkabel wants to merge 31 commits into
mainfrom
fix/insta-and-tiktok-failing
Open

feat(worker): Phase 2 hybrid routing — TikTok/Instagram/X via browser-downloader#141
tomkabel wants to merge 31 commits into
mainfrom
fix/insta-and-tiktok-failing

Conversation

@tomkabel

Copy link
Copy Markdown
Owner

Summary

Phase 2 of issue #140. Wires the existing Phase 0 browser-downloader microservice (packages/browser-downloader/) into the worker so /api/v1/downloads can serve TikTok/Instagram/Twitter-X URLs end-to-end.

Before: every job goes to yt-dlp; TikTok/Instagram/X fail (yt-dlp cannot construct blob: media).
After: platform-aware dispatch — TikTok/Instagram/X go to the microservice, YouTube stays on yt-dlp.

Behavior

  • Safe default: browser_downloader_enabled=False. The worker behaves byte-for-byte identical to main until an operator sets BROWSER_DOWNLOADER_ENABLED=true. Default BROWSER_DOWNLOADER_ENDPOINT=http://browser-downloader:3000 resolves in-cluster.
  • Platform detection: hostname suffix match on tiktok.com / tiktokv.com / instagram.com / instagr.am / twitter.com / x.com / t.co (with www.-stripping and FQDN-trailing-dot handling). YouTube and unknown hosts continue to yt-dlp.
  • Throttle predictor and progress callback are skipped for browser-routed jobs (the microservice is single-shot HTTP; no progress stream).
  • Errors map to existing ErrorCategory codes so the existing retry/DLQ pipeline handles them:
    • drm_detected / anti_bot_block → BLOCKED
    • network_error / ConnectError / 5xx / non-JSON → TRANSIENT
    • timeout → TIMEOUT
    • no_media_found / 404 → NOT_FOUND
    • HTTP 4xx → BLOCKED (except 429 rate-limit → TRANSIENT)
  • Dedicated browser_downloader circuit breaker (Redis-distributed opt-in via BROWSER_DOWNLOADER_CB_USE_REDIS).

Files

File Change
worker/browser_executor.py NEW — httpx client + breaker + error mapping
worker/job_executor.py routing helper + dispatch branch in execute()
core/config.py 4 new settings + validator
pyproject.toml httpx>=0.27 promoted from test extras to runtime
tests/test_worker/test_browser_executor.py NEW — error-code matrix, transport failures, breaker integration
tests/test_worker/test_job_executor_routing.py NEW — per-URL dispatch + end-to-end through execute()
tests/test_browser_downloader_config.py NEW — settings defaults + validator failure modes

Verification

  • hatch run lint:check → exit 0
  • hatch run type:check → no issues
  • hatch run test:unit → 875 passed, 6 skipped, 0 regressions

Deferred to P4/P5 (documented in _bmad-output/implementation-artifacts/deferred-work.md)

  • Prometheus metrics for the browser executor
  • aclose() on the httpx client at worker shutdown
  • t.co redirect resolution (currently routes to browser even for YouTube targets)
  • Browser→yt-dlp fallback when the breaker is open
  • Per-hostname / percentage rollout flag
  • Node.js contract test against the actual microservice

Out of scope (P1, P3, P4)

  • No gVisor sandbox (P1)
  • No fingerprint profiles or behavioral simulation (P3)
  • No live smoke tests (P5)
  • No docker-compose service definition (P4)
  • packages/browser-downloader/ unchanged — Phase 0 contract frozen

Spec

Full design + I/O matrix + review order: _bmad-output/implementation-artifacts/spec-gh-140-p2-worker-integration.md

tomkabel added 23 commits June 26, 2026 03:51
…listeners

Remove the redundant Content-Security-Policy header from nginx reverse
proxy configs (production and local-ssl) that was being sent in addition
to the application CSP. Browsers enforce the intersection of multiple CSP
headers, so the restrictive nginx CSP (script-src 'self') was stripping
the app's nonces, hashes, and external font sources.

Replace hx-on:htmx:after-request and hx-on:htmx:response-error attributes
on the download form with nonced script event listeners. This eliminates
the need for unsafe-eval in the CSP and keeps the form functional when
JavaScript is properly loaded via CSP-compliant nonced script tags.
… responses

Replace string equality check with secrets.compare_digest() in
validate_csrf_token to prevent timing side-channel attacks on CSRF
token comparison. Add the missing import for the secrets module.

Remove rotate_csrf_token calls from HTMX partial response handlers
in web_downloads.py (create_download_form and delete_download_form).
Token rotation on partial DOM updates caused subsequent form
submissions to fail with 403 because the page's meta tag and hidden
inputs still held the old token while the cookie was already rotated.
The cookie is set once on full page load (dashboard_page) and does
not need per-request rotation on HTMX endpoints.

Remove the now-unused rotate_csrf_token import from web_downloads.py.
… JWT

Prefix authentication cookies with __Host- to enforce same-site binding
and reject non-secure origins, per RFC 6265bis cookie prefix guidelines:
- access_token  → __Host-access_token
- refresh_token → __Host-refresh_token

Update all cookie readers across:
- app/auth.py: set_token_cookies, clear_token_cookies
- app/api/routes/auth.py: login, refresh, logout endpoints
- app/api/routes/web/web_auth.py: demo_login, logout, login_form
- app/main.py: root redirect auth check
- app/api/dependencies/__init__.py: get_current_user_from_cookie

Remove email field from create_access_token JWT payload. Email is
already accessible via the /api/v1/me endpoint and should not be
duplicated in the access token claims, reducing token size and
sensitive data exposure in the encoded payload.

Update all 13 test files to use the new cookie names and match
the adjusted auth token expectations.
Add model_config = ConfigDict(extra="forbid") to DownloadCreate,
TokenRefresh, and UserCreate Pydantic models. This prevents
mass-assignment attacks by rejecting unexpected fields in request
payloads, requiring clients to send only explicitly defined fields.
Add flake8-bandit (S) and flake8-async (ASYNC) to the ruff select list
for automated security and async-safety linting. Add per-file ignores
for test files (S101 assert, S104-S108 test secrets/defaults) and
migration files (ASYNC). Configure mypy strict=true and line-ending=lf.
Pin all Docker base images by SHA256 digest to prevent supply chain
attacks through tag replacement:
- python:3.12-slim → python@sha256:6c4d...
- ghcr.io/astral-sh/uv:0.6 → ghcr.io/astral-sh/uv@sha256:4a6c...
- node:20-alpine → node@sha256:fb4c...

Verify the NodeSource GPG signing key with sha256sum before importing
it, preventing key substitution attacks during apt repository setup.

Replace set -e with set -euo pipefail in entrypoint.sh for stricter
shell error detection.

Generate random DB password and secret key per CI run instead of using
hardcoded test values. Add worker entrypoint script to shellcheck.
…ompliance

Add explicit return type annotations and parameter type hints across
18 source files to satisfy mypy strict mode requirements:

API routes: Add HTMLResponse, RedirectResponse, TemplateResponse,
  JSONResponse, dict, and union return types to all route handlers.
Middleware: Add Callable[[Request], Awaitable[Response]] dispatch
  signatures and proper starlette response imports.
Services: Add redis.asyncio.Redis type hints to error_classifier,
  structlog BoundLogger to user_service, and optional None checks
  for stdout/stderr in yt_dlp_service.
Core: Add AsyncEngine, async_sessionmaker[AsyncSession], AsyncGenerator
  types to database module. Add ColumnElement[bool] return to user
  model filter. Add aioredis.Redis type support with cast() for the
  lazy singleton pattern in redis_client and queue.
Validators: Add parameter types to SSRF redirect handler methods.

Add response_model=None to 11 route decorators whose return type
annotations use non-Pydantic union types (HTMLResponse |
RedirectResponse, dict[str, Any] | JSONResponse) per FastAPI
requirements.
Add request nonce propagation and update CSP to include strict-dynamic and style nonces. This enables safer inline styles/scripts by adding nonce attributes to templates and injecting the nonce into generated docs. Improves content security posture for browser-rendered templates.
When refresh endpoint issues new tokens, blacklist the consumed refresh token jti to prevent reuse. Use secrets.compare_digest for CSRF token comparison to mitigate timing attacks. Minor signature and call-site cleanups for token cookie helper and password verification.
Import and apply rate limiter to the downloads SSE endpoint to limit connections; refactor rate limit parsing helper for robustness and minor API surface typing improvements.
Replace random with secrets.SystemRandom for jitter calculation, use re.IGNORECASE for robust pattern matching, and ensure job_max_retries defaults correctly. These reduce jitter predictability and make error classification more resilient to varied casing.
…ages

Add trailing commas to mapped_column calls, ensure consistent tuple/line breaks, and minor punctuation fixes in config logging/error messages. These are formatting and typing improvements that keep the ORM mappings consistent and lint-clean.
Add trailing commas for SQLAlchemy method chains, normalize raising exceptions (raise X vs raise X()), and tighten docstrings. These changes are stylistic and reduce accidental tuple/line continuation bugs in queries and exception propagation.
… fixes

Add trailing commas for SQLAlchemy chain continuity, honor WORKER_HEALTH_HOST env for health server, and small robustness/formatting fixes across worker job handling and outbox relay. These are non-behavioral improvements to prevent subtle syntax/tuple errors and improve deploy-time configurability.
Minor cleanups: add trailing commas and normalize function signatures/return annotations across exception handlers, middleware dispatch, startup lifespan, health and downloads routes, and web route handlers. These are non-functional style changes to keep codebase consistent and lint-clean.
Improve circuit breaker and yt-dlp subprocess handling with clearer fallbacks, safer file reading, better error message fallbacks, and sanitization of titles. Also tighten path traversal error messages and fix redis client type casts. Minor validators/demo_urls docstring tweaks.
Add .betterleaks files, SARIF, SOTA audit report, fail-*.md and HAR files, and notegpt-clone/ to .gitignore to avoid committing local scan/artifact outputs.
…on-youtube scripts

Expose module-level  as a SystemRandom instance so tests can patch  while preserving cryptographic RNG; change calculate_delay to call random.uniform. Generate yt-dlp extract script without embedding YouTube-only options for non-YouTube platforms (inject options only when platform=='youtube' at build time).
…entries

Fix _get_platform to use suffix matching and return 'unknown' for subdomain-bypass URLs like youtube.com.evil.com. Embed youtube-specific ydl options as dict entries inside ydl_opts definition so tests see JSON-style keys (prefer_free_formats, check_formats) rather than Python assignment statements.
Unknown domains like example.com still default to 'youtube', but hostnames containing a platform keyword that failed suffix matching (subdomain bypass) return 'unknown' for all platforms, not just YouTube.
Fix E402 lint errors by relocating random assignment after all import statements.
…-downloader

- core/config.py: add 4 settings (enabled=false by default, endpoint, timeout, cb_use_redis)
  with validator
- worker/browser_executor.py: NEW httpx client + named circuit breaker + structured
  error-code → ErrorCategory mapping (drm/anti_bot → BLOCKED, network → TRANSIENT,
  no_media → NOT_FOUND, http_429 → TRANSIENT, 4xx → BLOCKED, 5xx → TRANSIENT)
- worker/job_executor.py: select_executor + _resolve_executor_kind helper, dispatch
  block in execute() that skips throttle predictor and progress callback for browser
  branch (microservice is single-shot HTTP, no progress stream)
- pyproject.toml: promote httpx>=0.27 from test extras to runtime deps
- 3 new test files (875 total unit tests, 6 skipped, all green)

The new worker behavior is a true no-op until BROWSER_DOWNLOADER_ENABLED=true is set.
Defaults route everything to yt-dlp. Phase 0 microservice contract unchanged.
875 pass / 6 skipped unit tests. lint:check + type:check clean.
Copilot AI review requested due to automatic review settings June 28, 2026 03:35
@github-project-automation github-project-automation Bot moved this to Backlog in Vooglaadija Jun 28, 2026
@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added browser-based media downloading with automatic routing for supported platforms.
    • Added configuration options for browser-based downloading.
    • Added support for additional video platforms and cookie-based extraction settings.
  • Security

    • Hardened authentication cookies, token invalidation, CSRF checks, and security headers.
    • Improved container, startup, and request-validation safeguards.
  • Bug Fixes

    • Fixed password verification and improved download queue reliability.
    • Refined rate-limit timing and media-extraction error handling.
  • Tests

    • Expanded coverage for authentication, web flows, browser downloads, and worker routing.

Walkthrough

This PR adds a browser-downloader service, worker executor routing, platform-aware yt-dlp extraction, host-prefixed authentication cookies, outbox retention, build-input pinning, security-header changes, and stricter linting and typing.

Changes

Browser downloader

Layer / File(s) Summary
Downloader service and HTTP API
packages/browser-downloader/*
Adds Playwright interception, DOM fallback, streamlink/HLS fallback, SSRF and path validation, error classification, concurrency limits, HTTP endpoints, container packaging, and Vitest coverage.
Worker routing and platform extraction
worker/browser_executor.py, worker/job_executor.py, app/services/yt_dlp_service.py, core/config.py
Routes selected platforms to the browser downloader when enabled. Uses platform-specific yt-dlp formats, extractor options, and cookie settings for other platforms.

Security and authentication

Layer / File(s) Summary
Authentication and CSRF
app/auth.py, app/api/routes/auth.py, app/api/routes/web/*, app/api/dependencies/*, app/main.py
Uses __Host- cookies, removes email claims from access tokens, blacklists consumed refresh tokens, and compares CSRF tokens with secrets.compare_digest.
CSP and request validation
app/api/middleware/security_headers.py, app/api/docs.py, app/templates/*, app/schemas/*
Adds nonce-based CSP handling, moves dashboard event handling into a nonce-protected script, and forbids extra fields in selected Pydantic models.

Persistence, CI, and tooling

Layer / File(s) Summary
Outbox retention and metrics
core/models/outbox.py, app/services/outbox_service.py, app/services/download_service.py, worker/outbox_relay.py, worker/retry_scheduler.py, core/metrics.py
Retains processed outbox rows, enforces one pending row per job, handles duplicate inserts, and records pending-age metrics.
Build, CI, and typing controls
Dockerfile, .github/workflows/fastapi-test.yml, pyproject.toml, entrypoint.sh, infra/nginx/*
Pins build inputs, verifies the Nodesource key, generates CI secrets, strengthens shell settings, updates response typing, and enables strict Ruff and mypy checks.

Estimated code review effort: 5 (Critical) | ~120 minutes

Poem

🐇 New browser paths now hop through the queue,
Host-prefixed cookies keep sessions true.
Digests guard images; strict types align,
Outbox rows record each processed sign.
Tests cover each path from request to file,
The rabbit reviews the download pipeline.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: Phase 2 worker routing sends TikTok, Instagram, and X URLs through the browser-downloader.
Description check ✅ Passed The description directly explains the browser-downloader integration, routing behavior, configuration, error handling, tests, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • ✅ Generated successfully - (🔄 Check to regenerate)
  • Commit on current branch
🛠️ Fix failing CI checks
  • Create stacked PR
  • Commit on current branch

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements Phase 2 of hybrid routing in the worker so TikTok/Instagram/X downloads can be dispatched to the existing browser-downloader microservice while keeping YouTube (and unknown hosts) on the existing yt-dlp path behind a feature flag. It also hardens several security and operational aspects (CSP nonce strategy, __Host-* auth cookies, stricter typing/linting, and container pinning) that support running the new routing safely in production.

Changes:

  • Add a browser-downloader HTTP client + circuit breaker + error-category mapping, and route jobs to it when enabled and the URL matches supported hosts.
  • Expand platform detection + cookies/format-chain behavior in yt-dlp to better separate YouTube-only options from other platforms.
  • Strengthen security posture (CSP nonce/strict-dynamic, __Host-* cookie names, CSRF compare_digest) and tighten repo tooling (ruff/bandit/async rules, mypy strict, dependency/runtime adjustments).

Reviewed changes

Copilot reviewed 79 out of 80 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
worker/zombie_sweeper.py SQLAlchemy statement formatting adjustments.
worker/retry_scheduler.py Small retry policy expression simplification + formatting.
worker/processor.py SQLAlchemy update formatting tweaks.
worker/outbox_relay.py SQLAlchemy query formatting + logger arg trailing comma.
worker/main.py SQLAlchemy select formatting + logger arg trailing commas.
worker/job_executor.py Add Phase 2 executor routing + browser executor dispatch branch.
worker/job_claimer.py SQLAlchemy execution_options formatting + value formatting.
worker/health.py Add configurable health bind host env var for worker.
worker/dlq_manager.py SQLAlchemy execution_options formatting.
worker/browser_executor.py New browser-downloader HTTP client, routing, breaker + error mapping.
tests/test_worker/test_job_executor_routing.py New routing tests for executor selection + execute() dispatch.
tests/test_worker/test_browser_executor.py New tests for HTTP/error-mapping matrix + breaker integration.
tests/test_story_8_6_accessibility_audit.py Update auth cookie name to __Host-* in tests.
tests/test_story_8_5_missing_ui_states.py Update auth cookie name to __Host-* in tests.
tests/test_story_8_3_javascript_bugs_performance.py Update auth cookie name to __Host-* in tests.
tests/test_story_3_6_main_decomposition.py Update auth cookie name to __Host-* in tests.
tests/test_story_3_3_web_remaining_extraction.py Update auth cookie name to __Host-* in tests.
tests/test_story_3_2_web_downloads_extraction.py Update auth cookie name to __Host-* in tests.
tests/test_story_3_1_web_auth_extraction.py Assert __Host-* cookies are set on login/register.
tests/test_services/test_yt_dlp.py Add platform detection + platform-specific script assertions.
tests/test_env_contract.py Adjust token creation contract (remove email claim expectation).
tests/test_browser_downloader_config.py New tests for browser-downloader settings defaults + validation.
tests/test_auth_module.py Update cookie key assertions to __Host-* in cookie helpers.
tests/test_api/test_web_routes.py Update web auth cookie handling to __Host-* throughout.
tests/test_api/test_demo_login.py Update demo-login cookie handling to __Host-*.
tests/test_api/test_auth.py Update cookie-auth test to use __Host-access_token.
pyproject.toml Promote httpx runtime dep, bump bcrypt/pytest-cov, enable ruff S/ASYNC, mypy strict, formatting line endings.
infra/nginx/nginx.ssl.local.conf Remove nginx-level CSP header (CSP now set in app middleware).
infra/nginx/nginx.production.conf Remove nginx-level CSP header (CSP now set in app middleware).
entrypoint.sh Harden shell settings (set -euo pipefail).
Dockerfile Pin base images by digest; verify NodeSource GPG key via sha256.
core/utils/security.py Add trailing commas in multi-line ValueErrors (formatting).
core/redis_client.py Add typing + explicit return annotations + casts; minor doc formatting.
core/queue.py Add typing for lazy redis client wrapper.
core/models/user.py Improve typing for not_deleted() and add trailing commas.
core/models/outbox.py Add trailing comma in mapped_column args.
core/models/failed_job.py Add trailing comma in mapped_column args.
core/models/download_job.py Add trailing commas for relationship/mapped_column args.
core/logging_config.py Docstring spacing tweaks.
core/database.py Add typing/annotations for engine + session factory.
core/config.py Add browser-downloader settings + validation hook.
app/utils/validators.py Add typing to redirect handler; minor coercion fix.
app/utils/demo_urls.py Fix module docstring formatting.
app/templates/slides/presentation.html Move inline styles to CSS + add CSP nonce to <style>.
app/templates/dashboard.html Replace inline HTMX handlers with nonce’d script listeners.
app/services/yt_dlp_service.py Add platform detection, cookies opts, platform-specific format chains/extractor args.
app/services/user_service.py Add logger typing; raise exception classes (no-arg) consistently.
app/services/pubsub_service.py Docstring spacing + logger formatting.
app/services/outbox_service.py SQLAlchemy where-clause formatting.
app/services/job_factory.py Docstring formatting fix.
app/services/error_classifier.py Use SystemRandom via secrets; regex flag cleanup; typing for Redis.
app/services/download_service.py Reorder expired/missing checks; raise class style changes; minor formatting.
app/services/circuit_breaker.py Docstring formatting + typing for execute().
app/services/auth_service.py Add trailing comma in run_in_executor call.
app/schemas/user.py Forbid extra fields in UserCreate schema.
app/schemas/token.py Forbid extra fields in TokenRefresh schema.
app/schemas/download.py Forbid extra fields in DownloadCreate schema.
app/main.py Read auth token from __Host-access_token cookie.
app/auth.py Switch to __Host-* cookie keys; remove email claim from access token creation.
app/api/startup.py Type/formatting tweak to lifespan signature.
app/api/routes/web/web_settings.py Add response typing + response_model=None where applicable.
app/api/routes/web/web_helpers.py Use compare_digest for CSRF comparisons; typing tweaks; nonce in template context.
app/api/routes/web/web_downloads.py Add response typing; adjust CSRF rotation behavior; remove rotate import.
app/api/routes/web/web_dashboard.py Add TemplateResponse typing + pass nonce to slides template.
app/api/routes/web/web_auth.py Add response typing + response_model=None; adjust demo-login token creation; __Host cookies on logout blacklisting.
app/api/routes/web/web_auth_helpers.py SQLAlchemy select formatting + error-response formatting.
app/api/routes/sse.py Add typing, rate limit, adjust polling interval, and annotate pubsub/session types.
app/api/routes/health.py Docstring formatting + error_response_doc formatting.
app/api/routes/downloads.py error_response_doc formatting + minor typing tweaks.
app/api/routes/chaos.py Add typing + response_model=None; ignore typing mismatch for zadd.
app/api/routes/auth.py Remove email claim from access token; __Host refresh cookie fallback; blacklist old refresh jti.
app/api/rate_limit_config.py Improve typing and minor string handling improvements.
app/api/middleware/security_headers.py Strengthen CSP with strict-dynamic + nonce-based styles + object-src none.
app/api/middleware/request_body_size.py Add typing for middleware dispatch signature.
app/api/middleware/prometheus.py Add typing for middleware dispatch signature.
app/api/exceptions.py Add trailing commas + cast typing strings.
app/api/docs.py Add return types and ensure nonce injection replacement includes comma.
app/api/dependencies/init.py Switch cookie auth lookup to __Host-access_token.
.gitignore Ignore local scan/audit artifacts.
.github/workflows/fastapi-test.yml Add ShellCheck target + generate secrets dynamically + pass DB_PASS to init script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/services/download_service.py Outdated
Comment on lines +218 to +226
safe_path = self._validate_download_path(job.file_path)
if not os.path.isfile(safe_path):
safe_job_id = str(job_id).replace("\r", "").replace("\n", "")
logger.error("file_missing_from_disk", job_id=safe_job_id, file_path=safe_path)
raise DownloadFileMissingError("File not found on disk", code="missing_on_disk")

if job.expires_at and self._as_utc(job.expires_at) < datetime.now(UTC):
raise DownloadFileExpiredError

Comment on lines +294 to +308
# Subdomain-bypass detection: a hostname like youtube.com.evil.com
# contains a platform domain but doesn't end with a valid suffix.
# Truly unknown domains (e.g. example.com) default to "youtube"
# since yt-dlp handles most URLs.
all_platform_domains = (
youtube_all
| _VIMEO_HOSTS
| _DAILYMOTION_HOSTS
| _TWITCH_HOSTS
| _TIKTOK_HOSTS
| _INSTAGRAM_HOSTS
)
for domain in all_platform_domains:
if domain in hostname:
return "unknown"
Comment thread worker/browser_executor.py Outdated
Comment on lines +301 to +313
def _parse_failure_response(response: httpx.Response) -> tuple[str, str, str | None]:
"""Parse a non-200 response, mapping HTTP status + JSON error code to a category."""
signal = f"http_{response.status_code}"
try:
payload = response.json()
except (json.JSONDecodeError, ValueError):
# Non-JSON error body — treat as transient
logger.warning(
"browser_downloader_non_json_error", status=response.status_code,
)
raise BrowserExecutorError(
category=ErrorCategory.TRANSIENT, signal=signal
)
Comment thread worker/browser_executor.py Outdated
category=ErrorCategory.TRANSIENT, signal=signal
)

return _parse_failure_payload(payload)
Comment thread worker/browser_executor.py Outdated
Comment on lines +361 to +366
# Generic 4xx (other than the BLOCKED codes above) → BLOCKED,
# except 429 (rate-limit) which is transient.
if code.startswith("http_4"):
return ErrorCategory.BLOCKED
if code.startswith("http_5"):
return ErrorCategory.TRANSIENT
Comment thread worker/job_executor.py
Comment on lines 109 to 112
resp = templates.TemplateResponse(
request, "partials/_download_item.html", get_template_context(request, job=job)
request, "partials/_download_item.html", get_template_context(request, job=job),
)
rotate_csrf_token(resp)
return resp
Comment thread app/services/yt_dlp_service.py Outdated
}},
}},
}}
for i, format_spec in enumerate(fallback_chain):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: The fallback extraction loop is now nested inside _progress_hook()

Because this Python snippet is executed verbatim by python -c, moving the for i, format_spec in enumerate(fallback_chain): block under _progress_hook() leaves no top-level code that ever constructs yt_dlp.YoutubeDL(...). The helper reaches the final All formats failed block immediately, so every extraction through this path can fail before a download even starts.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread app/auth.py
) -> None:
response.set_cookie(
key="access_token",
key="__Host-access_token",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: __Host- cookies are rejected when Secure is false

These names require the Secure attribute in every browser. The new key names are used everywhere, but the call sites still pass secure=settings.cookie_secure and cookie_secure defaults to False, so local/default logins will silently fail to persist auth cookies.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread app/api/middleware/security_headers.py Outdated
response.headers["Content-Security-Policy"] = (
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' 'unsafe-hashes' "
f"script-src 'self' 'nonce-{nonce}' 'strict-dynamic' 'unsafe-hashes' "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The new nonce-based script-src blocks the slide deck controller

/slides still renders an inline <script> without a nonce, while this policy now only allows inline scripts that carry nonce-{...}. Once this header ships, the presentation's keyboard/click navigation code is blocked and the deck becomes non-interactive in the browser.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread worker/browser_executor.py Outdated
service=exc.service_name,
reset_timeout=exc.reset_timeout,
)
raise BrowserExecutorError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Wrapping CircuitBreakerOpenError here bypasses deferred-job handling

worker.processor._handle_execution_result() only routes raw CircuitBreakerOpenError through _handle_circuit_open(). Converting it into BrowserExecutorError("circuit_open") makes browser-downloader outages go through normal retry scheduling instead of the dedicated deferred-until-recovery path that the rest of the worker uses for open circuits.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jun 28, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 3
SUGGESTION 0
Issue Details (click to expand)

CRITICAL

File Line Issue
app/services/yt_dlp_service.py 478 Generated helper script nests the fallback extraction loop inside _progress_hook(), so extraction can fail before any yt-dlp attempt runs.

WARNING

File Line Issue
app/auth.py 128 __Host- auth cookies are set even when cookie_secure=False, which browsers reject.
app/api/middleware/security_headers.py 21 Nonce-only script-src now blocks the slide deck's inline controller script.
worker/browser_executor.py 219 Open browser-downloader circuits are wrapped into BrowserExecutorError, bypassing deferred-until-recovery handling.
Files Reviewed (80 files)
  • .github/workflows/fastapi-test.yml - 0 issues
  • .gitignore - 0 issues
  • Dockerfile - 0 issues
  • app/api/dependencies/__init__.py - 0 issues
  • app/api/docs.py - 0 issues
  • app/api/exceptions.py - 0 issues
  • app/api/middleware/prometheus.py - 0 issues
  • app/api/middleware/request_body_size.py - 0 issues
  • app/api/middleware/security_headers.py - 1 issue
  • app/api/rate_limit_config.py - 0 issues
  • app/api/routes/auth.py - 0 issues
  • app/api/routes/chaos.py - 0 issues
  • app/api/routes/downloads.py - 0 issues
  • app/api/routes/health.py - 0 issues
  • app/api/routes/sse.py - 0 issues
  • app/api/routes/web/web_auth.py - 0 issues
  • app/api/routes/web/web_auth_helpers.py - 0 issues
  • app/api/routes/web/web_dashboard.py - 0 issues
  • app/api/routes/web/web_downloads.py - 0 issues
  • app/api/routes/web/web_helpers.py - 0 issues
  • app/api/routes/web/web_settings.py - 0 issues
  • app/api/startup.py - 0 issues
  • app/auth.py - 1 issue
  • app/main.py - 0 issues
  • app/schemas/download.py - 0 issues
  • app/schemas/token.py - 0 issues
  • app/schemas/user.py - 0 issues
  • app/services/auth_service.py - 0 issues
  • app/services/circuit_breaker.py - 0 issues
  • app/services/download_service.py - 0 issues
  • app/services/error_classifier.py - 0 issues
  • app/services/job_factory.py - 0 issues
  • app/services/outbox_service.py - 0 issues
  • app/services/pubsub_service.py - 0 issues
  • app/services/user_service.py - 0 issues
  • app/services/yt_dlp_service.py - 1 issue
  • app/templates/dashboard.html - 0 issues
  • app/templates/slides/presentation.html - 0 issues
  • app/utils/demo_urls.py - 0 issues
  • app/utils/validators.py - 0 issues
  • core/config.py - 0 issues
  • core/database.py - 0 issues
  • core/logging_config.py - 0 issues
  • core/models/download_job.py - 0 issues
  • core/models/failed_job.py - 0 issues
  • core/models/outbox.py - 0 issues
  • core/models/user.py - 0 issues
  • core/queue.py - 0 issues
  • core/redis_client.py - 0 issues
  • core/utils/security.py - 0 issues
  • entrypoint.sh - 0 issues
  • infra/nginx/nginx.production.conf - 0 issues
  • infra/nginx/nginx.ssl.local.conf - 0 issues
  • pyproject.toml - 0 issues
  • tests/test_api/test_auth.py - 0 issues
  • tests/test_api/test_demo_login.py - 0 issues
  • tests/test_api/test_web_routes.py - 0 issues
  • tests/test_auth_module.py - 0 issues
  • tests/test_browser_downloader_config.py - 0 issues
  • tests/test_env_contract.py - 0 issues
  • tests/test_services/test_yt_dlp.py - 0 issues
  • tests/test_story_3_1_web_auth_extraction.py - 0 issues
  • tests/test_story_3_2_web_downloads_extraction.py - 0 issues
  • tests/test_story_3_3_web_remaining_extraction.py - 0 issues
  • tests/test_story_3_6_main_decomposition.py - 0 issues
  • tests/test_story_8_3_javascript_bugs_performance.py - 0 issues
  • tests/test_story_8_5_missing_ui_states.py - 0 issues
  • tests/test_story_8_6_accessibility_audit.py - 0 issues
  • tests/test_worker/test_browser_executor.py - 0 issues
  • tests/test_worker/test_job_executor_routing.py - 0 issues
  • worker/browser_executor.py - 1 issue
  • worker/dlq_manager.py - 0 issues
  • worker/health.py - 0 issues
  • worker/job_claimer.py - 0 issues
  • worker/job_executor.py - 0 issues
  • worker/main.py - 0 issues
  • worker/outbox_relay.py - 0 issues
  • worker/processor.py - 0 issues
  • worker/retry_scheduler.py - 0 issues
  • worker/zombie_sweeper.py - 0 issues

Fix these issues in Kilo Cloud


Reviewed by gpt-5.4-2026-03-05 · Input: 123.6K · Output: 66K · Cached: 2.3M

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/auth.py (1)

125-149: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

__Host- cookies need Secure enabled
Browsers reject __Host-* cookies without Secure, so any call path that passes secure=False will silently drop both auth cookies and break cookie-backed login. Either require Secure here or use non-__Host- names when insecure cookies are enabled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/auth.py` around lines 125 - 149, The cookie-setting logic in
set_token_cookies currently allows secure=False while still using
__Host-access_token and __Host-refresh_token, which browsers will reject. Update
set_token_cookies to either always enforce Secure for the __Host-* cookies or
switch to non-__Host names when insecure cookies are intentionally supported,
and keep clear_token_cookies aligned with the chosen naming scheme.
app/api/docs.py (1)

53-69: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add nonces to the self-hosted docs scripts.

app/api/docs.py:53-69,75-83
The middleware now sends script-src 'strict-dynamic' with a nonce, but the self-hosted /static/swagger/...js and /static/redoc/...js tags are still parser-inserted scripts without a nonce. With strict-dynamic, host allowlists are ignored, so /docs and /redoc will stop booting when the local assets are present. Add the request nonce to those <script src> tags or emit a docs-specific CSP for the self-hosted branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/docs.py` around lines 53 - 69, The self-hosted docs pages still emit
parser-inserted Swagger/ReDoc script tags without a nonce, so they will fail
under the nonce-based CSP. Update the HTML rewriting in app/api/docs.py so the
self-hosted /static/swagger/...js and /static/redoc/...js tags include the
request nonce, or generate a separate CSP for the local-assets branch. Use the
existing _inject_inline_script_nonce flow and the docs response handling in the
docs route to keep the fix consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/fastapi-test.yml:
- Around line 203-221: The database setup in the workflow is using a generated
DB_PASS in the schema initialization step, but the Postgres service still starts
with a fixed POSTGRES_PASSWORD, so authentication will fail. Update the workflow
so the service and the later schema-init connection in the create database
schema step use the same password source, and reference the existing service
definition and the init_db/create_async_engine logic to keep them aligned.

In `@app/api/middleware/security_headers.py`:
- Around line 21-22: The CSP built in security_headers.py is too strict for the
dashboard because `script-src` in the security header includes both a nonce and
`strict-dynamic`, which prevents parser-inserted bundles like the existing
dashboard JS/SSE scripts from loading. Update the logic that constructs the
header so the dashboard path still allows those existing script tags, either by
removing `strict-dynamic` for this response or by otherwise ensuring the
`/static/js/dashboard.js` and `/static/js/sse.js` loads are compatible with
`build_security_headers` and its `script-src` generation.

In `@app/api/rate_limit_config.py`:
- Line 54: The Retry-After calculation in `_parse_retry_after()` is still using
the request count multiplier, which makes values like “5 per 1 minute” far too
large. Update `_parse_retry_after()` to use the parsed window duration from the
rate-limit config rather than multiplying by the number of requests, and verify
the logic around the `unit = unit.removesuffix("s")` parsing so the computed
`Retry-After` matches the actual window length.

In `@app/api/routes/auth.py`:
- Around line 148-152: The auth cookie writes in the token issuance flow are
incorrectly gated by settings.cookie_secure, which breaks __Host-* cookies when
Secure is false. Update the call site in the auth route that invokes
set_token_cookies so these cookies are always written with secure=True, or
switch away from the __Host- prefix if non-HTTPS support is required. Use the
existing create_access_token, create_refresh_token, and set_token_cookies flow
to locate the fix.

In `@app/api/routes/sse.py`:
- Around line 453-457: The SSE rate limit on download_status_stream is too
restrictive for EventSource auto-reconnects and can block legitimate clients
after brief disconnects. Update the limiter on download_status_stream to use a
much larger burst budget or exempt this endpoint from rate limiting entirely,
while keeping the rest of the route behavior unchanged.

In `@app/api/routes/web/web_auth.py`:
- Around line 7-9: The route handlers in web_auth.py are using Starlette’s
private _TemplateResponse as TemplateResponse in public annotations, which is
brittle. Update the affected handler annotations to use a public response type
instead, and remove the private import from starlette.templating. Keep the fix
localized to the route functions that currently reference TemplateResponse so
the API no longer depends on an internal Starlette symbol.

In `@app/api/routes/web/web_dashboard.py`:
- Line 4: The route handlers in web_dashboard use the private
starlette.templating._TemplateResponse in their annotations, so update the
affected handler signatures to use a public response type instead. Replace the
TemplateResponse alias/import and adjust the return annotations on the relevant
web dashboard endpoints to a public type such as HTMLResponse while keeping the
actual response behavior unchanged.

In `@app/api/routes/web/web_downloads.py`:
- Line 8: Avoid relying on Starlette’s private _TemplateResponse in the
web_downloads route module by switching the import and any related annotations
to a public response type such as HTMLResponse or Response. Update the relevant
handler signatures and references in the web_downloads route so they use the
public type consistently and no longer depend on the internal TemplateResponse
alias.
- Around line 109-112: The HTMX create response in web_downloads no longer
rotates CSRF, which breaks the existing contract expected by
test_create_download_htmx_returns_canonical_row_and_rotates_csrf. Update the
response path around the TemplateResponse in the create download flow to re-add
CSRF rotation by calling rotate_csrf_token before returning, and restore the
missing rotate_csrf_token import at the top of the module so the fresh
csrf_token cookie is set again.

In `@app/api/routes/web/web_settings.py`:
- Line 7: The route annotation is using Starlette’s internal _TemplateResponse
private API, which should be replaced with a public response type. Update the
return annotation in the web settings route to use Response or HTMLResponse
instead, and adjust the import in web_settings.py accordingly so the endpoint no
longer depends on TemplateResponse internals.

In `@app/services/download_service.py`:
- Around line 218-225: The expiration check in download handling is currently
happening after the disk existence probe in DownloadService, which causes
expired downloads whose files were already removed to be treated as missing
instead of expired. Move the `job.expires_at` / `_as_utc(...) <
datetime.now(UTC)` check ahead of the `os.path.isfile(safe_path)` branch in
`download_service.py`, keeping the existing `DownloadFileExpiredError` path in
the `DownloadService` flow so expired links are returned before any file-missing
logic.

In `@app/services/yt_dlp_service.py`:
- Around line 262-263: _keep _get_platform() and the platform host tables in
yt_dlp_service.py aligned with select_executor(), so TikTok and Instagram
aliases don’t fall through to YouTube. Update the host sets used by
_get_platform() to include the missing router aliases such as tiktokv.com and
instagr.am, and make sure any direct callers like resolve_video_title() and
extract_media_url() resolve those hosts to the correct platform before choosing
format chains or extractor args._
- Around line 478-493: The fallback download loop is accidentally nested inside
`_progress_hook`, so the top-level flow never reaches the `for i, format_spec in
enumerate(fallback_chain)` block or constructs `YoutubeDL(...)`. Move that loop
and the `ydl_opts` setup back out to the main download path in
`yt_dlp_service.py`, keeping `_progress_hook` as a separate callback, and ensure
the subsequent `ydl_opts.update(cookies_opts)` and `extractor_args` handling
remain in the outer scope.

In `@core/models/user.py`:
- Around line 43-45: The `created_at` and `updated_at` fields in the user model
are annotated with SQLAlchemy’s `DateTime` type instead of Python’s
`datetime.datetime`, which makes type checking inaccurate. Update the
annotations in the model that defines `created_at`/`updated_at` to use the
Python datetime type while keeping the existing `mapped_column` database
configuration unchanged.

In `@core/queue.py`:
- Around line 38-41: The close() method on _LazyRedisClient is shutting down the
shared Redis instance directly, which leaves the process-wide singleton in
core.redis_client in a closed state. Update _LazyRedisClient.close() to delegate
to the shared Redis shutdown helper used by get_redis_client() instead of
calling self._client.close() directly, and make sure the global client reference
is cleared so future get_redis_client() calls create a fresh client.

In `@core/redis_client.py`:
- Around line 74-76: reset_redis_client() currently clears the singleton without
shutting down the existing Redis connection, which can leak the client’s pool
across test cycles. Update the reset_redis_client helper in core/redis_client.py
to close the active client first by delegating to close_redis_client() (or
otherwise ensuring the async shutdown completes) before setting
_redis_state["client"] back to None.

In `@tests/test_api/test_web_routes.py`:
- Around line 2924-2927: The logout/assertion in this test only checks
__Host-access_token, so it can miss a regression where __Host-refresh_token is
not cleared. Update the assertion near the existing response.cookies check to
also verify __Host-refresh_token is absent or empty, matching the success path
behavior that clears both auth cookies.

In `@worker/browser_executor.py`:
- Around line 301-324: The HTTP-status fallback is being lost in
_parse_failure_response because it computes signal as http_<status> but then
calls _parse_failure_payload without passing that fallback, so JSON bodies
without an error field are misclassified. Update _parse_failure_response and the
helper _parse_failure_payload to accept and use the status-derived
signal/category fallback when the payload is JSON but missing error, ensuring
400/404-style responses still map based on HTTP status instead of defaulting to
unknown_error/TRANSIENT.
- Around line 211-221: The browser executor is swallowing the dedicated
circuit-open signal by catching CircuitBreakerOpenError in browser_executor.py
and re-wrapping it as BrowserExecutorError, which prevents worker/processor.py
from marking the job deferred. Update the breaker.execute path in
browser_executor.py so CircuitBreakerOpenError is not converted here; keep the
warning log if needed, but re-raise the original exception so the processor’s
circuit-open deferral logic can handle it. Use the CircuitBreakerOpenError
handling block and BrowserExecutorError creation as the key locations to adjust.

In `@worker/retry_scheduler.py`:
- Around line 58-59: The retry limit calculation in retry_scheduler.py is
treating an explicit max_retries=0 as if it were missing, because the retry
logic uses job.max_retries or 3. Update the logic in the scheduler path that
computes job_max_retries and effective_max so the default is only applied when
the value is None, not when it is zero. Keep the
CATEGORY_POLICIES[category].max_retries cap unchanged, and make sure the code
path in the retry scheduler respects a deliberate “no retries” setting on the
job model.

---

Outside diff comments:
In `@app/api/docs.py`:
- Around line 53-69: The self-hosted docs pages still emit parser-inserted
Swagger/ReDoc script tags without a nonce, so they will fail under the
nonce-based CSP. Update the HTML rewriting in app/api/docs.py so the self-hosted
/static/swagger/...js and /static/redoc/...js tags include the request nonce, or
generate a separate CSP for the local-assets branch. Use the existing
_inject_inline_script_nonce flow and the docs response handling in the docs
route to keep the fix consistent.

In `@app/auth.py`:
- Around line 125-149: The cookie-setting logic in set_token_cookies currently
allows secure=False while still using __Host-access_token and
__Host-refresh_token, which browsers will reject. Update set_token_cookies to
either always enforce Secure for the __Host-* cookies or switch to non-__Host
names when insecure cookies are intentionally supported, and keep
clear_token_cookies aligned with the chosen naming scheme.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f8bbc856-697c-4a59-8321-16241fb345ea

📥 Commits

Reviewing files that changed from the base of the PR and between 4dc1500 and 1c38ad3.

📒 Files selected for processing (80)
  • .github/workflows/fastapi-test.yml
  • .gitignore
  • Dockerfile
  • app/api/dependencies/__init__.py
  • app/api/docs.py
  • app/api/exceptions.py
  • app/api/middleware/prometheus.py
  • app/api/middleware/request_body_size.py
  • app/api/middleware/security_headers.py
  • app/api/rate_limit_config.py
  • app/api/routes/auth.py
  • app/api/routes/chaos.py
  • app/api/routes/downloads.py
  • app/api/routes/health.py
  • app/api/routes/sse.py
  • app/api/routes/web/web_auth.py
  • app/api/routes/web/web_auth_helpers.py
  • app/api/routes/web/web_dashboard.py
  • app/api/routes/web/web_downloads.py
  • app/api/routes/web/web_helpers.py
  • app/api/routes/web/web_settings.py
  • app/api/startup.py
  • app/auth.py
  • app/main.py
  • app/schemas/download.py
  • app/schemas/token.py
  • app/schemas/user.py
  • app/services/auth_service.py
  • app/services/circuit_breaker.py
  • app/services/download_service.py
  • app/services/error_classifier.py
  • app/services/job_factory.py
  • app/services/outbox_service.py
  • app/services/pubsub_service.py
  • app/services/user_service.py
  • app/services/yt_dlp_service.py
  • app/templates/dashboard.html
  • app/templates/slides/presentation.html
  • app/utils/demo_urls.py
  • app/utils/validators.py
  • core/config.py
  • core/database.py
  • core/logging_config.py
  • core/models/download_job.py
  • core/models/failed_job.py
  • core/models/outbox.py
  • core/models/user.py
  • core/queue.py
  • core/redis_client.py
  • core/utils/security.py
  • entrypoint.sh
  • infra/nginx/nginx.production.conf
  • infra/nginx/nginx.ssl.local.conf
  • pyproject.toml
  • tests/test_api/test_auth.py
  • tests/test_api/test_demo_login.py
  • tests/test_api/test_web_routes.py
  • tests/test_auth_module.py
  • tests/test_browser_downloader_config.py
  • tests/test_env_contract.py
  • tests/test_services/test_yt_dlp.py
  • tests/test_story_3_1_web_auth_extraction.py
  • tests/test_story_3_2_web_downloads_extraction.py
  • tests/test_story_3_3_web_remaining_extraction.py
  • tests/test_story_3_6_main_decomposition.py
  • tests/test_story_8_3_javascript_bugs_performance.py
  • tests/test_story_8_5_missing_ui_states.py
  • tests/test_story_8_6_accessibility_audit.py
  • tests/test_worker/test_browser_executor.py
  • tests/test_worker/test_job_executor_routing.py
  • worker/browser_executor.py
  • worker/dlq_manager.py
  • worker/health.py
  • worker/job_claimer.py
  • worker/job_executor.py
  • worker/main.py
  • worker/outbox_relay.py
  • worker/processor.py
  • worker/retry_scheduler.py
  • worker/zombie_sweeper.py
💤 Files with no reviewable changes (2)
  • infra/nginx/nginx.production.conf
  • infra/nginx/nginx.ssl.local.conf
📜 Review details
⏰ Context from checks skipped due to timeout. (3)
  • GitHub Check: copilot-pull-request-reviewer
  • GitHub Check: Test Docker Compose Stack
  • GitHub Check: Kilo Code Review
⚠️ CI failures not shown inline (2)

GitHub Actions: FastAPI REST API Tests / 5_Lint (Python + JS + CSS + Markdown + YAML).txt: feat(worker): Phase 2 hybrid routing — TikTok/Instagram/X via browser-downloader

Conclusion: failure

View job details

##[group]Run hatch run lint:format-check
 �[36;1mhatch run lint:format-check�[0m
 shell: /usr/bin/bash -e {0}
 env:
   PYTHON_VERSION: 3.12
   HATCH_VERSION: 1.16.5
   UV_PYTHON_INSTALL_DIR: /home/runner/work/_temp/uv-python-dir
   UV_PYTHON: 3.12
   UV_CACHE_DIR: /home/runner/work/_temp/setup-uv-cache
 ##[endgroup]
 Would reformat: app/api/docs.py
 Would reformat: app/api/exceptions.py
 Would reformat: app/api/middleware/prometheus.py
 Would reformat: app/api/middleware/request_body_size.py
 Would reformat: app/api/rate_limit_config.py
 Would reformat: app/api/routes/auth.py
 Would reformat: app/api/routes/downloads.py
 Would reformat: app/api/routes/health.py
 Would reformat: app/api/routes/sse.py
 Would reformat: app/api/routes/web/web_auth.py
 Would reformat: app/api/routes/web/web_auth_helpers.py
 Would reformat: app/api/routes/web/web_downloads.py
 Would reformat: app/api/routes/web/web_helpers.py
 Would reformat: app/api/routes/web/web_settings.py
 Would reformat: app/api/startup.py
 Would reformat: app/auth.py
 Would reformat: app/services/download_service.py
 Would reformat: app/services/pubsub_service.py
 Would reformat: app/services/user_service.py
 Would reformat: app/services/yt_dlp_service.py
 Would reformat: core/database.py
 Would reformat: core/models/download_job.py
 Would reformat: core/models/failed_job.py
 Would reformat: core/models/outbox.py
 Would reformat: core/models/user.py
 25 files would be reformatted, 140 files already formatted
 ##[error]Process completed with exit code 1.

GitHub Actions: FastAPI REST API Tests / Lint (Python + JS + CSS + Markdown + YAML): feat(worker): Phase 2 hybrid routing — TikTok/Instagram/X via browser-downloader

Conclusion: failure

View job details

##[group]Run hatch run lint:format-check
 �[36;1mhatch run lint:format-check�[0m
 shell: /usr/bin/bash -e {0}
 env:
   PYTHON_VERSION: 3.12
   HATCH_VERSION: 1.16.5
   UV_PYTHON_INSTALL_DIR: /home/runner/work/_temp/uv-python-dir
   UV_PYTHON: 3.12
   UV_CACHE_DIR: /home/runner/work/_temp/setup-uv-cache
 ##[endgroup]
 Would reformat: app/api/docs.py
 Would reformat: app/api/exceptions.py
 Would reformat: app/api/middleware/prometheus.py
 Would reformat: app/api/middleware/request_body_size.py
 Would reformat: app/api/rate_limit_config.py
 Would reformat: app/api/routes/auth.py
 Would reformat: app/api/routes/downloads.py
 Would reformat: app/api/routes/health.py
 Would reformat: app/api/routes/sse.py
 Would reformat: app/api/routes/web/web_auth.py
 Would reformat: app/api/routes/web/web_auth_helpers.py
 Would reformat: app/api/routes/web/web_downloads.py
 Would reformat: app/api/routes/web/web_helpers.py
 Would reformat: app/api/routes/web/web_settings.py
 Would reformat: app/api/startup.py
 Would reformat: app/auth.py
 Would reformat: app/services/download_service.py
 Would reformat: app/services/pubsub_service.py
 Would reformat: app/services/user_service.py
 Would reformat: app/services/yt_dlp_service.py
 Would reformat: core/database.py
 Would reformat: core/models/download_job.py
 Would reformat: core/models/failed_job.py
 Would reformat: core/models/outbox.py
 Would reformat: core/models/user.py
 25 files would be reformatted, 140 files already formatted
 ##[error]Process completed with exit code 1.
🧰 Additional context used
🪛 ast-grep (0.44.0)
tests/test_browser_downloader_config.py

[warning] 27-27: Do not make http calls without encryption
Context: "http://browser-downloader:3000"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)

core/config.py

[warning] 79-79: Do not make http calls without encryption
Context: "http://browser-downloader:3000"
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(requests-http)

core/models/user.py

[warning] 41-41: Do not use text() as it leads to SQL injection
Context: text("1")
Note: [CWE-89] Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection').

(disable-sqlalchemy-text)

tests/test_services/test_yt_dlp.py

[info] 736-736: Do not hardcode temporary file or directory names
Context: "/tmp/out"
Note: [CWE-377] Insecure Temporary File.

(hardcoded-tmp-file)


[info] 758-758: Do not hardcode temporary file or directory names
Context: "/tmp/out"
Note: [CWE-377] Insecure Temporary File.

(hardcoded-tmp-file)

worker/job_executor.py

[info] 102-107: use jsonify instead of json.dumps for JSON output
Context: json.dumps(
{
"retry_count": 0,
"next_retry_at": datetime.now(UTC).isoformat(),
},
)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

worker/retry_scheduler.py

[info] 155-161: use jsonify instead of json.dumps for JSON output
Context: json.dumps(
{
"retry_count": job.retry_count + 1,
"category": decision.category.value,
"next_retry_at": decision.next_retry_at.isoformat(),
},
)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

app/services/yt_dlp_service.py

[info] 91-91: use jsonify instead of json.dumps for JSON output
Context: json.dumps(cookies_opts)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 411-411: use jsonify instead of json.dumps for JSON output
Context: json.dumps(platform)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 413-413: use jsonify instead of json.dumps for JSON output
Context: json.dumps(cookies_opts)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 415-415: use jsonify instead of json.dumps for JSON output
Context: json.dumps(fallback_chain)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 417-417: use jsonify instead of json.dumps for JSON output
Context: json.dumps(extractor_args)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 427-427: use jsonify instead of json.dumps for JSON output
Context: json.dumps(list(_OUTPUT_FIELDS))
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🪛 GitHub Actions: FastAPI REST API Tests / 5_Lint (Python + JS + CSS + Markdown + YAML).txt
app/api/startup.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/middleware/prometheus.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

core/models/failed_job.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

core/models/outbox.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/middleware/request_body_size.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

core/models/download_job.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/exceptions.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/routes/web/web_auth_helpers.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/routes/health.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/routes/web/web_settings.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/routes/downloads.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

core/models/user.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/auth.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/services/pubsub_service.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/rate_limit_config.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/services/user_service.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

core/database.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/docs.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/routes/web/web_auth.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/routes/web/web_downloads.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/routes/sse.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/routes/auth.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/services/download_service.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/api/routes/web/web_helpers.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

app/services/yt_dlp_service.py

[error] 1-1: lint:format-check reported formatting differences. Run the formatter (e.g., hatch format) to update this file.

🪛 GitHub Actions: FastAPI REST API Tests / Lint (Python + JS + CSS + Markdown + YAML)
app/api/startup.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/middleware/prometheus.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

core/models/failed_job.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

core/models/outbox.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/middleware/request_body_size.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

core/models/download_job.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/exceptions.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/routes/web/web_auth_helpers.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/routes/health.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/routes/web/web_settings.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/routes/downloads.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

core/models/user.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/auth.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/services/pubsub_service.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/rate_limit_config.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/services/user_service.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

core/database.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/docs.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/routes/web/web_auth.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/routes/web/web_downloads.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/routes/sse.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/routes/auth.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/services/download_service.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/api/routes/web/web_helpers.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

app/services/yt_dlp_service.py

[error] 1-1: lint:format-check failed (hatch). File would be reformatted.

🔇 Additional comments (45)
app/schemas/download.py (1)

5-11: LGTM!

app/schemas/token.py (1)

3-3: LGTM!

Also applies to: 13-14

app/schemas/user.py (1)

3-10: LGTM!

app/services/auth_service.py (1)

23-23: LGTM!

app/utils/validators.py (1)

6-6: LGTM!

Also applies to: 151-163, 179-179, 240-240

app/services/error_classifier.py (1)

19-29: LGTM!

Also applies to: 133-204, 309-309, 332-332

pyproject.toml (2)

20-20: 🩺 Stability & Availability

Re-check the removed upper bound on bcrypt.

This repo still carries passlib.* compatibility handling in the same file, so changing from a capped range to bare >=4.0 means future bcrypt releases can land without another review. Please verify that the old <4.1 guard is no longer needed before merging.

Also applies to: 108-108


30-30: LGTM!

Also applies to: 76-76, 488-489, 506-615

app/api/rate_limit_config.py (1)

5-6: LGTM!

Also applies to: 24-35, 69-90

app/api/routes/downloads.py (1)

83-83: LGTM!

Also applies to: 145-145, 204-204, 239-242, 251-251, 296-296

core/models/download_job.py (1)

25-32: LGTM!

Also applies to: 53-53

core/models/failed_job.py (1)

50-50: 🚀 Performance & Scalability

Ship the matching migration for this new index.

Adding index=True on the ORM model will not create the index on existing databases by itself. If DLQ expiry queries are expected to benefit from this, please make sure the corresponding migration is included as well.

core/models/user.py (1)

7-18: LGTM!

worker/dlq_manager.py (1)

86-90: LGTM!

Also applies to: 139-142

app/api/middleware/prometheus.py (1)

4-19: LGTM!

app/api/middleware/request_body_size.py (1)

3-20: LGTM!

app/api/routes/health.py (1)

77-82: LGTM!

Also applies to: 124-129

app/services/circuit_breaker.py (1)

428-467: LGTM!

Also applies to: 503-537

worker/job_claimer.py (1)

52-94: LGTM!

worker/outbox_relay.py (1)

27-102: LGTM!

worker/retry_scheduler.py (1)

139-162: LGTM!

app/api/exceptions.py (1)

58-60: LGTM!

Also applies to: 109-116

core/database.py (1)

8-16: LGTM!

Also applies to: 31-34, 58-65, 70-80

core/utils/security.py (1)

15-23: LGTM!

app/utils/demo_urls.py (1)

1-1: LGTM!

worker/processor.py (1)

79-79: LGTM!

Also applies to: 199-199

worker/zombie_sweeper.py (1)

56-56: LGTM!

Also applies to: 90-90, 108-108

worker/main.py (1)

96-97: LGTM!

Also applies to: 118-124, 135-136, 159-159, 303-303

app/services/pubsub_service.py (1)

40-41: LGTM!

Also applies to: 55-56, 67-69, 80-81, 106-108, 165-166, 222-223, 242-245, 261-263

app/services/user_service.py (1)

10-10: LGTM!

Also applies to: 97-99, 141-159, 175-175, 195-195, 214-214, 236-236

core/logging_config.py (1)

79-79: LGTM!

Also applies to: 176-176

entrypoint.sh (1)

2-2: LGTM!

.github/workflows/fastapi-test.yml (1)

52-52: LGTM!

Also applies to: 79-79

.gitignore (1)

184-194: LGTM!

tests/test_story_3_6_main_decomposition.py (1)

311-312: LGTM!

tests/test_story_8_3_javascript_bugs_performance.py (1)

168-168: LGTM!

Also applies to: 181-181, 201-201

tests/test_story_8_5_missing_ui_states.py (1)

67-67: LGTM!

Also applies to: 96-96

worker/health.py (1)

236-249: LGTM!

app/main.py (1)

136-136: LGTM!

app/api/routes/auth.py (1)

207-207: LGTM!

Also applies to: 263-270, 319-335

tests/test_story_3_3_web_remaining_extraction.py (1)

160-183: LGTM!

Also applies to: 205-216, 235-263

app/api/routes/web/web_helpers.py (1)

6-6: LGTM!

Also applies to: 49-56, 139-170, 202-202, 235-235

app/templates/dashboard.html (1)

47-55: LGTM!

Also applies to: 166-188

app/templates/slides/presentation.html (1)

12-12: LGTM!

Also applies to: 253-257, 289-289, 331-339

app/api/routes/web/web_auth_helpers.py (1)

37-37: LGTM!

Also applies to: 83-83, 140-147

Comment thread .github/workflows/fastapi-test.yml Outdated
Comment thread app/api/middleware/security_headers.py Outdated
Comment thread app/api/rate_limit_config.py
Comment thread app/api/routes/auth.py
Comment thread app/api/routes/sse.py
Comment on lines +453 to +457
@limiter.limit("5/minute")
async def download_status_stream(
request: Request,
current_user: CurrentUserFromCookie,
):
) -> EventSourceResponse:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The new SSE limit will lock out legitimate auto-reconnects.

EventSource reconnects automatically on transient disconnects. At 5/minute, a short deploy or flaky network can exhaust the bucket and leave the page without live status/progress updates for the rest of the window. This stream needs a much larger burst budget than normal GET routes, or an exemption entirely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/routes/sse.py` around lines 453 - 457, The SSE rate limit on
download_status_stream is too restrictive for EventSource auto-reconnects and
can block legitimate clients after brief disconnects. Update the limiter on
download_status_stream to use a much larger burst budget or exempt this endpoint
from rate limiting entirely, while keeping the rest of the route behavior
unchanged.

Comment thread core/redis_client.py
Comment on lines +74 to 76
def reset_redis_client() -> None:
"""Reset the singleton (for testing only)."""
_redis_state["client"] = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

reset_redis_client() drops a live client without closing it.

This helper clears the singleton slot but never closes the existing async Redis client first. In repeated test/setup cycles that leaks the old connection pool. Either make reset delegate to close_redis_client() or require callers to await shutdown before resetting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/redis_client.py` around lines 74 - 76, reset_redis_client() currently
clears the singleton without shutting down the existing Redis connection, which
can leak the client’s pool across test cycles. Update the reset_redis_client
helper in core/redis_client.py to close the active client first by delegating to
close_redis_client() (or otherwise ensuring the async shutdown completes) before
setting _redis_state["client"] back to None.

Comment on lines +2924 to +2927
assert (
"__Host-access_token" not in response.cookies
or response.cookies.get("__Host-access_token") == ""
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert the refresh cookie is cleared here too.

Line 2924 only verifies __Host-access_token, but the success path clears both auth cookies. This test will miss a regression that leaves __Host-refresh_token behind.

Suggested test update
         assert (
             "__Host-access_token" not in response.cookies
             or response.cookies.get("__Host-access_token") == ""
         )
+        assert (
+            "__Host-refresh_token" not in response.cookies
+            or response.cookies.get("__Host-refresh_token") == ""
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert (
"__Host-access_token" not in response.cookies
or response.cookies.get("__Host-access_token") == ""
)
assert (
"__Host-access_token" not in response.cookies
or response.cookies.get("__Host-access_token") == ""
)
assert (
"__Host-refresh_token" not in response.cookies
or response.cookies.get("__Host-refresh_token") == ""
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_api/test_web_routes.py` around lines 2924 - 2927, The
logout/assertion in this test only checks __Host-access_token, so it can miss a
regression where __Host-refresh_token is not cleared. Update the assertion near
the existing response.cookies check to also verify __Host-refresh_token is
absent or empty, matching the success path behavior that clears both auth
cookies.

Comment thread worker/browser_executor.py Outdated
Comment thread worker/browser_executor.py Outdated
Comment thread worker/retry_scheduler.py Outdated
CRITICAL fixes:
- yt_dlp_service.py: move fallback loop out of _progress_hook so extraction
  actually runs (was nested, causing immediate failure)
- yt_dlp_service.py: add missing tiktokv.com/instagr.am to platform hosts
- yt_dlp_service.py: tighten subdomain-bypass detection to trigger on
  domain.label patterns only (not substring matches like myyoutube.com)
- browser_executor.py: let CircuitBreakerOpenError propagate raw so
  processor's deferred-job path handles it correctly
- browser_executor.py: use HTTP status signal for non-JSON error bodies
  so 404/403/429 categorize correctly instead of all being TRANSIENT
- browser_executor.py: http_404 → NOT_FOUND (was BLOCKED)
- browser_executor.py: preserve HTTP status fallback when JSON body
  lacks explicit error field

HIGH fixes:
- download_service.py: expiry check before disk probe (was returning 404
  instead of 410 for expired files already cleaned from disk)
- retry_scheduler.py: use 'is not None' check for max_retries instead of
  'or 3' which overrides explicit 0
- rate_limit_config.py: Retry-After now uses window multiplier instead of
  request count (5 per 1 minute → 60s, not 300s)
- core/queue.py: use shared close_redis_client() instead of closing singleton
  directly (prevent poisoned singleton)
- auth.py: force secure=True for __Host- prefixed cookies regardless of
  settings.cookie_secure (browsers reject __Host- without Secure)
- models/user.py: use datetime.datetime type annotation instead of
  SQLAlchemy DateTime
- web_downloads.py: restore CSRF token rotation on HTMX download create
- job_executor.py: remove unused BrowserExecutorError import

Updated affected tests to match new behavior.
Carried over from Video DownloadHelper and FlowPick analysis:

DRM manifest heuristics (tier1-cdp.js):
- Scan HLS manifests for SAMPLE-AES* methods and non-identity KEYFORMAT
- Scan DASH manifests for ContentProtection elements
- DRM detected at manifest-parse time (milliseconds) instead of waiting
  for EME API polling
- Plain AES-128 with identity keyformat passes through

Auth header capture & replay:
- CDP Network.requestWillBeSent captures Referer/Origin for segment auth
- Injected page script monkey-patches fetch/XHR to capture auth headers
- Headers forwarded to streamlink-backend via --http-header flags

Streamlink backend improvements (streamlink-backend.js):
- Per-resource-kind size caps: manifest=8MiB, key=64KiB, segment=256MiB
- Context fallback: same-origin CORS → include CORS with dedup
- Segment-level retry with exponential backoff + jitter (3 retries)
- DRM detection uses precise regexp (SAMPLE-AES*/non-identity KEYFORMAT)
- HLS encrypted stream detection now throws drm_detected (not network_error)

108 tests pass across 8 test files.
…nt cleanup

- Outbox: add partial unique index on (job_id, status='pending') for
  real idempotency under concurrent writers; handle IntegrityError
  in outbox_service without aborting the outer transaction.
- Worker chaos helpers: log swallowed exceptions instead of bare
  try-except-pass (S110); annotate random.uniform with noqa (S311).
- BrowserExecutorError: annotate _CATEGORY_MARKERS with ClassVar
  (RUF012); add exception chaining via 'from err' (B904).
- Browser-downloader JS: fix useOptionalChain, noUnnecessaryContinue,
  noUnusedVariables (biome).
- Outbox relay: structured cleanup of stale entries and relay loop.
- Tier1 CDP: capture fetch headers for auth replay in streamlink;
  add referrer/origin spoofing for video segment requests.
Signed-off-by: tomkabel <[email protected]>
- S110: Replace bare try-except-pass with logger.debug in
  _update_circuit_deferred_depth
- ASYNC240: Suppress os.path.exists warning (local filesystem,
  negligible latency; project does not use anyio)
- Fix missing trailing newline in pnpm-workspace.yaml
Signed-off-by: tomkabel <[email protected]>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CVE-2025-59288 in playwright - high severity
Improper verification of cryptographic signature in Github: Playwright allows an unauthorized attacker to perform spoofing over an adjacent network.

Details

Remediation Aikido suggests bumping this package to version 1.55.1 to resolve this issue

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

}
let resolved;
try {
resolved = await realpath(dir);
if (cap != null && buf.length > cap) {
throw new DownloaderError('network_error', `${resourceKind} body exceeds size cap`);
}
await writeFile(destPath, buf);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 44

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
app/auth.py (1)

153-155: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Set Secure=True when deleting __Host- cookies.

Response.delete_cookie defaults to secure=False, so the logout cookies omit the required Secure attribute for __Host- cookies. Keep path="/" and omit domain, but pass secure=True for both deletions to avoid browsers rejecting the delete response and leaving the cookies set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/auth.py` around lines 153 - 155, Update clear_token_cookies so both
response.delete_cookie calls for __Host-access_token and __Host-refresh_token
pass secure=True, while retaining path="/" and omitting domain.
worker/job_executor.py (1)

171-192: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use snake_case event names for these structlog calls.

Lines 171, 184, and 192 pass a full sentence as the structlog event key, for example "zombie_sweep_recovery skipped (non-critical)". Every other call in this file uses a snake_case event name, such as "chaos_slow_processing" at Line 189. Sentences as event keys break grouping and alerting on the event field.

♻️ Proposed change
-        logger.debug("zombie_sweep_recovery skipped (non-critical)", exc_info=True)
+        logger.debug("chaos_zombie_check_skipped", exc_info=True)
@@
-        logger.debug("chaos_db_failover check skipped (non-critical)", exc_info=True)
+        logger.debug("chaos_db_failover_check_skipped", exc_info=True)
@@
-        logger.debug("chaos_slow_processing skipped (non-critical)", exc_info=True)
+        logger.debug("chaos_slow_processing_check_skipped", exc_info=True)

Line 341 has the same pattern: "extract_task cancel cleanup (non-critical)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/job_executor.py` around lines 171 - 192, Update the structlog calls in
the relevant exception handlers to use concise snake_case event keys instead of
sentence-like messages, including the calls near zombie_sweep_recovery,
chaos_db_failover, chaos_slow_processing, and extract_task cancellation cleanup.
Preserve the existing non-critical context through structured fields or an
appropriate snake_case event name, and ensure all affected event values support
consistent grouping and alerting.
app/services/yt_dlp_service.py (1)

434-443: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Pass the YouTube-only options as data instead of pre-indented source text.

youtube_opts embeds literal 12-space indentation inside a string, and Line 493 relies on that string ending with a newline so the following }} closes the dict. Any change to the indentation of the generated ydl_opts block silently produces an IndentationError or a SyntaxError in the subprocess, which surfaces only at runtime as a failed extraction.

Serialize the platform options as JSON and merge them in the script, the same way cookies_opts and extractor_args already work.

♻️ Proposed change
-    # Only inject youtube-specific options when building the script for YouTube.
-    # Tests assert that non-YouTube platform scripts do not contain YouTube-only keys.
-    # Embedding them as dict entries keeps the options inside the ydl_opts dict definition.
-    if platform == "youtube":
-        youtube_opts = (
-            '            "prefer_free_formats": True,\n            "check_formats": "missable",\n'
-        )
-    else:
-        youtube_opts = ""
+    # Only inject youtube-specific options when building the script for YouTube.
+    # Tests assert that non-YouTube platform scripts do not contain YouTube-only keys.
+    platform_opts = (
+        {"prefer_free_formats": True, "check_formats": "missable"}
+        if platform == "youtube"
+        else {}
+    )
+    platform_opts_json = json.dumps(platform_opts)

In the generated script, declare platform_opts = {platform_opts_json} next to cookies_opts, replace {youtube_opts} }} with }}, and add ydl_opts.update(platform_opts) beside the existing ydl_opts.update(cookies_opts).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/yt_dlp_service.py` around lines 434 - 443, Update the
YouTube-specific option handling in the script-generation flow to serialize
platform options as JSON data rather than embedding pre-indented source text. In
the code that builds the generated script and its ydl_opts block, declare
platform_opts alongside cookies_opts, remove the youtube_opts interpolation from
the dictionary literal, and merge platform_opts with ydl_opts.update next to the
existing cookies_opts update.
worker/browser_executor.py (1)

353-368: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use RATE_LIMITED for http_429 and handle http_408/http_425 without blocking retries.

RATE_LIMITED gets 5 retries with a 60s base delay, while TRANSIENT gets 3 retries with a 10s base delay. http_429 is rate limiting, so mapping it to TRANSIENT skips the dedicated retry policy and rate-limit metrics. Also move the generic 4xx fallback after explicit rate limit and timeout handling; http_408 and http_425 currently match http_4* and are treated non-retryable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/browser_executor.py` around lines 353 - 368, Update the error-category
mapping to return ErrorCategory.RATE_LIMITED for http_429, and explicitly
classify http_408 and http_425 as retryable before the generic http_4 fallback.
Preserve the existing NOT_FOUND mappings and ensure the generic 4xx handling
remains after these explicit cases so they are not marked BLOCKED.
♻️ Duplicate comments (2)
app/api/routes/web/web_downloads.py (2)

110-114: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Keep CSRF rotation consistent across successful HTMX mutations.

The create response reuses the current token, and the delete response omits token updates. Restore one fresh-token path for both successful mutations.

  • app/api/routes/web/web_downloads.py#L110-L114: replace the current-token cookie write with rotate_csrf_token(resp) or an equivalent fresh-token helper. Restore the removed import.
  • app/api/routes/web/web_downloads.py#L193-L194: apply the same rotation before returning the successful empty response if deletion shares this contract.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/routes/web/web_downloads.py` around lines 110 - 114, In
app/api/routes/web/web_downloads.py lines 110-114, update the successful create
response to use rotate_csrf_token(resp) or the equivalent fresh-token helper
instead of set_csrf_token_cookie with the current token, and restore its import.
In lines 193-194, apply the same CSRF rotation before returning the successful
deletion response.

8-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a public response type for annotations.

Line 8 aliases Starlette’s private _TemplateResponse and exposes it through the route annotations. Use HTMLResponse or Response after confirming the project’s declared Starlette version.

This avoids coupling the route module to a private library API.

#!/usr/bin/env bash
set -euo pipefail

rg -n 'starlette|fastapi' pyproject.toml
rg -n '_TemplateResponse|TemplateResponse' app/api/routes/web/web_downloads.py

Also applies to: 67-67

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/routes/web/web_downloads.py` at line 8, Replace the private
_TemplateResponse alias used by the route annotations with a public Starlette
response type, preferably HTMLResponse or Response, based on the project’s
declared Starlette version. Update both TemplateResponse annotation usages in
the web downloads route module while preserving the existing response behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/services/download_service.py`:
- Around line 389-399: In the fast path around enqueue_job, capture and retain
the original pending Outbox.id before delivery, then update only that row by ID
while preserving the status == "pending" guard; do not continue matching by
job_id alone. Add a regression test that inserts a second pending Outbox row
between queue delivery and the update and verifies the later row remains
pending.

In `@app/services/outbox_service.py`:
- Around line 51-59: Update the outbox insert flow around the db.flush()
IntegrityError handler to execute the add/flush inside db.begin_nested(), so
duplicate conflicts roll back only the savepoint and leave the outer transaction
active. On conflict, confirm the existing pending outbox row before returning
None, and re-raise IntegrityError instances that are not confirmed duplicates;
do not rely on db.expunge(outbox_entry) for recovery.

In `@core/models/outbox.py`:
- Around line 61-67: Add an Alembic migration for the model’s
uq_outbox_pending_job_id index, creating a unique job_id index filtered by
status = 'pending' for both PostgreSQL and SQLite, and include the corresponding
downgrade removal. Ensure the migration is linked to the current revision chain.
- Around line 61-67: Update write_job_to_outbox to execute the outbox insert and
flush inside an await db.begin_nested() savepoint, preserving its existing
concurrent-conflict handling so failures do not invalidate the enclosing
transaction. Add a concurrency test using two sessions that exercises the
partial unique index and verifies only one pending job is retained while both
sessions remain usable.

In `@packages/browser-downloader/Dockerfile`:
- Around line 13-30: Update the Dockerfile dependency installation to pin
streamlink to a fixed version, copy the root pnpm-lock.yaml alongside
package.json before installation, and run pnpm install --prod with
--frozen-lockfile while preserving --ignore-scripts and cleanup behavior.

In `@packages/browser-downloader/src/downloader.js`:
- Around line 96-109: Update the failure path in the downloader’s catch block to
remove the partially created outPath before returning the failed result,
covering both DownloaderError and classified errors while preserving existing
error classification and cleanup behavior.
- Around line 28-38: Replace the boolean stealthApplied state used by
launchStealthBrowser with a shared setup promise, assigning it before awaiting
the stealth plugin import so concurrent calls reuse the same initialization.
Ensure chromium.use(StealthPlugin()) executes only once, then await the memoized
setup before launching the browser.

In `@packages/browser-downloader/src/errors.js`:
- Around line 112-135: Move the TypeError-with-cause recursion in classifyError
outside the typeof code === 'string' block, while keeping direct string-code
timeout and network checks unchanged. Ensure fetch failures without their own
code recurse into err.cause so a cause such as ETIMEDOUT is classified as
ERROR_CODES.TIMEOUT.

In `@packages/browser-downloader/src/server.js`:
- Around line 65-72: Remove the duplicate full validation from the request
handler around validateUrl and validateOutputDir, while preserving download() as
the security boundary and retaining the 400 response mapping for invalid client
input. Either replace this block with a cheap pre-check or pass its normalized
URL and resolved directory into download() so downloader.js can reuse them
without revalidation; ensure downloadStream’s derived-URL validation remains
unchanged.
- Around line 88-116: Propagate an AbortSignal from the request handler through
download() and both tier functions into downloadStream, reusing its existing
opts.signal support. In the request timeout callback, abort the signal before
rejecting so active streamlink/CDP work stops; ensure the timeout path also
closes the Chromium instance before release(), keeping browser and semaphore
cleanup synchronized.
- Around line 21-29: Update the MAX_CONCURRENCY configuration to read from the
appropriate environment variable using the existing parseTimeout helper, while
retaining 2 as the safe default when the value is absent or invalid. Pass the
resulting configurable limit to createSemaphore.
- Around line 100-110: Update the request timeout rejection in the Promise race
to use the exported TIMEOUT error code instead of the plain "request_timeout"
message, ensuring safeClassify returns timeout for hung requests while
preserving the existing catch response flow.

In `@packages/browser-downloader/src/streamlink-backend.js`:
- Around line 446-465: Update the segment-validation loop in the playlist
processing flow to cache successful validateUrl results by hostname for the
duration of this call. Reuse the cached result for consecutive or repeated
segment hosts, and invoke validateUrl with { lookup } only when a hostname has
not yet been validated; continue pushing every resolved href into segUrls.
- Around line 133-211: The retry flow in fetchResWithRetry retries terminal
failures and fetchResOne drops supported context options. In
packages/browser-downloader/src/streamlink-backend.js lines 133-211, reduce
contextCandidates usage to header variants that actually differ, remove the
unused init object in fetchResOne, and immediately propagate non-retryable 4xx
responses, validateUrl rejections, and size-cap errors. In
packages/browser-downloader/tests/streamlink-backend.test.js lines 284-315,
update the redirect-SSRF and size-cap tests to confirm fail-fast behavior and
raise their testTimeout until the source fix eliminates the timing risk.
- Around line 317-337: Update fetchText and fetchToFile to consume res.body as a
stream with a running byte count, aborting or cancelling immediately when the
applicable cap is exceeded instead of calling res.text() or res.arrayBuffer().
Preserve the existing DownloaderError and resource-specific cap behavior; have
fetchText assemble only the permitted text and have fetchToFile write chunks
incrementally so the full segment is never materialized in memory.

In `@packages/browser-downloader/src/tier1-cdp.js`:
- Around line 163-165: Update requestHeaders handling in onRequestWillBeSent and
the line-256 read path to delete each requestId entry immediately after reading
it, then add a fixed capacity and oldest-entry eviction comparable to
tier2-dom.js’s BLOB_CAP behavior. Preserve candidate processing while ensuring
requestHeaders cannot retain unbounded request data or credentials.
- Around line 224-236: In the response-candidate handling around
Network.responseReceived and the Network.getResponseBody calls, reject
candidates using encodedDataLength or the Content-Length header before
materializing the body, while retaining the existing post-read caps as
backstops. Update the manifest check to use Buffer.byteLength(body) instead of
body.length so MANIFEST_CAP is enforced in bytes; apply the analogous pre-read
guard to the bodyCap path.
- Around line 49-50: Prevent credential leakage from the auth-header replay
path: update AUTH_HEADER_NAMES and the authHeaders flow used by interceptMedia,
downloader.js, and downloadStream so cookie and authorization values are not
passed as streamlink command-line arguments. Either restrict capture to referer
and origin when credentials are unnecessary, or pass required credentials
through a file or environment-based mechanism, and verify authHeaders are never
logged or included in HTTP response bodies.
- Around line 347-359: Fix block-page detection in the onDomReady page.evaluate
callback and the corresponding tier2-dom evaluation callbacks by passing the
block-pattern value into the browser context or defining an equivalent
page-local pattern, rather than referencing module-scoped BLOCK_RE. Preserve the
existing document text collection, blocked-result handling, and onTerminal(new
DownloaderError('anti_bot_block')) behavior, and update tests so the evaluate
callbacks are actually executed or otherwise verify the page-context pattern is
available.
- Around line 46-47: Update the DASH_DRM_RE pattern to detect ContentProtection
elements when followed by whitespace, a closing angle bracket, or a self-closing
slash, including both <ContentProtection> and <ContentProtection/> forms.
Preserve its use for identifying DRM-protected DASH manifests before routing to
streamlink.
- Around line 96-112: Normalize header names to lowercase in the object and
array branches of the init.headers handling near the existing auth extraction,
matching the Headers branch and the XHR hook. Ensure the subsequent referer,
origin, cookie, and authorization lookups in the auth object capture headers
regardless of caller casing.

In `@packages/browser-downloader/src/tier2-dom.js`:
- Around line 79-92: Update tryClickPlay so it only returns after a selector’s
click succeeds; when handle.click rejects, swallow the error and continue trying
later selectors. Replace the broad 'button' fallback in the selectors list with
a play-specific button selector such as button[aria-label*="play" i], while
preserving the existing selector iteration and error handling.
- Around line 168-173: Update the in-page media body handling around the
arrayBuffer callback to avoid serializing bytes with Array.from(new
Uint8Array(ab)); after checking ab.byteLength against cap, return a compact
base64 representation and decode it with Buffer.from(..., 'base64') in the
Node-side flow before constructing the result. Ensure the ab.byteLength cap
check occurs before any encoding or conversion, including the corresponding
handling around lines 186-195.
- Around line 25-34: Unify the blob-cap implementation so the browser-injected
HOOK_SRC is the tested source of truth instead of maintaining separate
pushBlobUrl and record logic. Make HOOK_SRC accept the cap argument, pass
BLOB_CAP through page.evaluate, remove the local CAP constant, and keep the
non-Blob guard and oldest-entry eviction behavior inline in the injected
function; update tests to exercise this injected path rather than only
pushBlobUrl.
- Around line 117-128: Update both page.evaluate callbacks in the block-page
checks to make the block pattern available inside the browser context, rather
than referencing the Node-scoped BLOCK_RE directly. Pass or construct the
pattern within each callback while preserving the existing text extraction and
anti-bot detection behavior.
- Around line 157-181: Update the page.evaluate invocation and its evaluated
callback so the URL, body cap, and timeout are passed as one object and
destructured inside the callback; ensure the abort timer uses bodyCap and
timeout, allowing fetch size checks to remain active and the request to receive
the intended bounded timeout.

In `@packages/browser-downloader/src/validate.js`:
- Around line 87-108: Extend isPrivateIp to reject the remaining reserved IPv4
ranges: 100.64.0.0/10, 192.0.0.0/24, 198.18.0.0/15, and multicast/broadcast
addresses (224.0.0.0/4 and 255.255.255.255). Preserve the existing checks for
RFC 1918, loopback, link-local, and 0.0.0.0/8.

In `@packages/browser-downloader/tests/downloader.test.js`:
- Around line 111-119: Update the download test around download() to avoid
dependence on inherited BD_DOWNLOAD_TIMEOUT_MS: either pass downloadTimeout:
120_000 explicitly in the options or remove and restore that environment
variable in setup. Preserve the assertion that streamlink receives a 120_000
timeout.
- Around line 168-189: Update the downloader test setup to stub
BD_DOWNLOAD_TIMEOUT_MS with Vitest’s vi.stubEnv('BD_DOWNLOAD_TIMEOUT_MS',
'60000') in beforeEach, and remove the manual env snapshot and process.env
replacement. Restore environment stubs in afterEach using vi.unstubAllEnvs(),
while preserving the existing mock setup and teardown.

In `@packages/browser-downloader/tests/errors.test.js`:
- Around line 36-41: Update classifyError so cause handling is evaluated
independently of the err.code branch, allowing fetch TypeError errors with an
ETIMEDOUT cause to classify as TIMEOUT while preserving existing network-cause
behavior. Extend the error classification tests with a TypeError carrying an
ETIMEDOUT cause and assert ERROR_CODES.TIMEOUT.

In `@packages/browser-downloader/tests/server.test.js`:
- Around line 169-179: Replace the fixed 30 ms sleep in the semaphore
concurrency test around the download mock with an awaitable barrier that becomes
ready after two download invocations, then await that barrier before issuing the
third request. In packages/browser-downloader/tests/server.test.js lines
169-179, update the download mock and test flow accordingly; in lines 189-221,
replace the 50 ms and 30 ms sleeps with await once(second, 'error') and await
once(server, 'error') respectively, preserving the existing assertions.
- Around line 169-179: Replace the fixed timeout in the concurrency test with a
deterministic barrier: have the mocks.download implementation signal each
invocation, await signals for both initial requests, then submit the third
request and retain the existing 503/concurrency_limit assertions.

In `@packages/browser-downloader/tests/streamlink-backend.test.js`:
- Around line 284-315: Update the retry classification in fetchResWithRetry so
terminal validation failures from validateUrl and
DownloaderError('network_error') caused by the body size cap are not retried.
Preserve retries for genuinely transient fetch failures, allowing the
redirect-SSRF and Content-Length rejection tests to fail immediately.
- Around line 90-106: Move vi.unstubAllGlobals() into the test suite’s afterEach
cleanup hook so global fetch stubs are removed even when assertions fail. Delete
the trailing vi.unstubAllGlobals() calls from each test body, including the test
around downloadManifestFallback.

In `@packages/browser-downloader/tests/tier1-cdp.test.js`:
- Around line 34-46: The page.evaluate fakes must fail loudly on unrecognized
probes instead of silently returning undefined. In
packages/browser-downloader/tests/tier1-cdp.test.js, update the evaluate mock’s
final fallback to throw an error containing the unmatched source; make the same
change in packages/browser-downloader/tests/tier2-dom.test.js while preserving
its explicit undefined return for the createObjectURL hook patch.

In `@packages/browser-downloader/tests/validate.test.js`:
- Around line 134-137: Update the non-existent-directory test around
validateOutputDir to pass the temporary base directory explicitly and enable
realpath resolution, then assert the rejection message for the missing
subdirectory specifically. Keep the test focused on the child path being absent
rather than allowing the default output-base lookup to trigger a different
validation branch.
- Around line 128-132: Update the test named “rejects a path outside the base
(path traversal)” so its mocked realpath resolves an input containing a
parent-directory segment or symlink-like path from within base to an outside
location, such as /etc. Ensure validateOutputDir receives that traversal path
and still rejects it, rather than testing an already absolute path unrelated to
base.

In `@tests/test_story_3_5_processor_retry_dlq_extraction.py`:
- Around line 403-404: Update the assertion in the pending-status test to
require an actual relay predicate matching Outbox.status == _PENDING_STATUS or
Outbox.status == "pending", rather than accepting only the _PENDING_STATUS
declaration. Preserve the source-based validation while ensuring the relay query
itself includes the pending-status filter.

In `@tests/test_story_5_6_model_index_consistency.py`:
- Around line 112-118: Update the outbox index assertions in the test covering
model index consistency to inspect the metadata for uq_outbox_pending_job_id,
verifying it is unique and has the status = 'pending' predicate through both the
model metadata and SQL inspection paths, rather than asserting only its name.

In `@tests/test_worker/test_browser_executor.py`:
- Around line 290-296: Rename
test_open_circuit_raises_transient_without_http_call to
test_open_circuit_propagates_circuit_breaker_open_without_http_call to reflect
the asserted exception, and move the CircuitBreakerOpenError import from inside
the test to the module-level imports.

In `@worker/browser_executor.py`:
- Around line 323-341: Update _parse_failure_payload to return NoReturn and
import NoReturn from typing, reflecting that it always raises
BrowserExecutorError. Remove the type: ignore by retrieving payload.get("error")
without an incorrect str annotation, then validate it with the existing
isinstance/non-empty check before applying fallback_code. Simplify the
_parse_success failure path from returning _parse_failure_payload(payload) to
calling it directly.

In `@worker/main.py`:
- Around line 271-273: Validate the new OUTBOX_SYNC_INTERVAL_SECONDS default and
configured interval against production worker counts in the outbox polling flow
around sync_outbox_to_queue(). Confirm the pending-outbox query and database
capacity support the increased polling frequency, and adjust the interval or
query/database configuration as needed while preserving the existing fallback
behavior for invalid values.

In `@worker/outbox_relay.py`:
- Around line 54-59: Update the pending-age calculation in the outbox relay to
fetch the minimum pending Outbox.created_at value and compute elapsed seconds in
Python, avoiding SQLite datetime subtraction. Preserve the existing zero value
when no pending record exists, and ensure the OUTBOX_OLDEST_PENDING_SECONDS
metric receives the computed age without relying on the exception-reset path.

In `@worker/retry_scheduler.py`:
- Around line 172-179: In the retry scheduling flow around the Outbox status
update and db.commit, ensure the exception handler calls await db.rollback()
when either operation fails after Redis accepts the retry. Keep the outbox row
pending and allow the subsequent select(DownloadJob) to execute without a failed
transaction.

---

Outside diff comments:
In `@app/auth.py`:
- Around line 153-155: Update clear_token_cookies so both response.delete_cookie
calls for __Host-access_token and __Host-refresh_token pass secure=True, while
retaining path="/" and omitting domain.

In `@app/services/yt_dlp_service.py`:
- Around line 434-443: Update the YouTube-specific option handling in the
script-generation flow to serialize platform options as JSON data rather than
embedding pre-indented source text. In the code that builds the generated script
and its ydl_opts block, declare platform_opts alongside cookies_opts, remove the
youtube_opts interpolation from the dictionary literal, and merge platform_opts
with ydl_opts.update next to the existing cookies_opts update.

In `@worker/browser_executor.py`:
- Around line 353-368: Update the error-category mapping to return
ErrorCategory.RATE_LIMITED for http_429, and explicitly classify http_408 and
http_425 as retryable before the generic http_4 fallback. Preserve the existing
NOT_FOUND mappings and ensure the generic 4xx handling remains after these
explicit cases so they are not marked BLOCKED.

In `@worker/job_executor.py`:
- Around line 171-192: Update the structlog calls in the relevant exception
handlers to use concise snake_case event keys instead of sentence-like messages,
including the calls near zombie_sweep_recovery, chaos_db_failover,
chaos_slow_processing, and extract_task cancellation cleanup. Preserve the
existing non-critical context through structured fields or an appropriate
snake_case event name, and ensure all affected event values support consistent
grouping and alerting.

---

Duplicate comments:
In `@app/api/routes/web/web_downloads.py`:
- Around line 110-114: In app/api/routes/web/web_downloads.py lines 110-114,
update the successful create response to use rotate_csrf_token(resp) or the
equivalent fresh-token helper instead of set_csrf_token_cookie with the current
token, and restore its import. In lines 193-194, apply the same CSRF rotation
before returning the successful deletion response.
- Line 8: Replace the private _TemplateResponse alias used by the route
annotations with a public Starlette response type, preferably HTMLResponse or
Response, based on the project’s declared Starlette version. Update both
TemplateResponse annotation usages in the web downloads route module while
preserving the existing response behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5b1993d4-0468-446b-a0d8-e4f36c513516

📥 Commits

Reviewing files that changed from the base of the PR and between 1c38ad3 and 6bc89f0.

📒 Files selected for processing (46)
  • app/api/rate_limit_config.py
  • app/api/routes/downloads.py
  • app/api/routes/web/web_downloads.py
  • app/auth.py
  • app/services/download_service.py
  • app/services/outbox_service.py
  • app/services/yt_dlp_service.py
  • core/metrics.py
  • core/models/outbox.py
  • core/models/user.py
  • core/queue.py
  • packages/browser-downloader/Dockerfile
  • packages/browser-downloader/package.json
  • packages/browser-downloader/src/concurrency.js
  • packages/browser-downloader/src/downloader.js
  • packages/browser-downloader/src/errors.js
  • packages/browser-downloader/src/server.js
  • packages/browser-downloader/src/streamlink-backend.js
  • packages/browser-downloader/src/tier1-cdp.js
  • packages/browser-downloader/src/tier2-dom.js
  • packages/browser-downloader/src/validate.js
  • packages/browser-downloader/tests/concurrency.test.js
  • packages/browser-downloader/tests/downloader.test.js
  • packages/browser-downloader/tests/errors.test.js
  • packages/browser-downloader/tests/server.test.js
  • packages/browser-downloader/tests/streamlink-backend.test.js
  • packages/browser-downloader/tests/tier1-cdp.test.js
  • packages/browser-downloader/tests/tier2-dom.test.js
  • packages/browser-downloader/tests/validate.test.js
  • packages/browser-downloader/vitest.config.js
  • pnpm-workspace.yaml
  • tests/test_services/test_download_service.py
  • tests/test_story_1_2_config_extraction.py
  • tests/test_story_1_3_database_metrics_extraction.py
  • tests/test_story_1_4_redis_logging_queue_extraction.py
  • tests/test_story_2_4_web_html_consolidation.py
  • tests/test_story_3_1_web_auth_extraction.py
  • tests/test_story_3_5_processor_retry_dlq_extraction.py
  • tests/test_story_5_6_model_index_consistency.py
  • tests/test_worker/test_browser_executor.py
  • tests/test_worker/test_outbox_recovery.py
  • worker/browser_executor.py
  • worker/job_executor.py
  • worker/main.py
  • worker/outbox_relay.py
  • worker/retry_scheduler.py
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: Test Docker Compose Stack
⚠️ CI failures not shown inline (2)

GitHub Actions: FastAPI REST API Tests / 5_Lint (Python + JS + CSS + Markdown + YAML).txt: feat(worker): Phase 2 hybrid routing — TikTok/Instagram/X via browser-downloader

Conclusion: failure

View job details

##[group]Run hatch run lint:check
 �[36;1mhatch run lint:check�[0m
 shell: /usr/bin/bash -e {0}
 env:
   PYTHON_VERSION: 3.12
   HATCH_VERSION: 1.16.5
   UV_PYTHON_INSTALL_DIR: /home/runner/work/_temp/uv-python-dir
   UV_PYTHON: 3.12
   UV_CACHE_DIR: /home/runner/work/_temp/setup-uv-cache
 ##[endgroup]
 Creating environment: lint
 Checking dependencies
 Syncing dependencies
 ##[error]app/api/routes/web/web_auth.py:93:11: PLR0917 Too many positional arguments (6 > 5)

GitHub Actions: FastAPI REST API Tests / Lint (Python + JS + CSS + Markdown + YAML): feat(worker): Phase 2 hybrid routing — TikTok/Instagram/X via browser-downloader

Conclusion: failure

View job details

##[group]Run hatch run lint:check
 �[36;1mhatch run lint:check�[0m
 shell: /usr/bin/bash -e {0}
 env:
   PYTHON_VERSION: 3.12
   HATCH_VERSION: 1.16.5
   UV_PYTHON_INSTALL_DIR: /home/runner/work/_temp/uv-python-dir
   UV_PYTHON: 3.12
   UV_CACHE_DIR: /home/runner/work/_temp/setup-uv-cache
 ##[endgroup]
 Creating environment: lint
 Checking dependencies
 Syncing dependencies
 ##[error]app/api/routes/web/web_auth.py:93:11: PLR0917 Too many positional arguments (6 > 5)
🧰 Additional context used
🪛 ast-grep (0.45.0)
packages/browser-downloader/tests/server.test.js

[warning] 175-175: Avoid using the initial state variable in setState
Context: setTimeout(r, 30)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 197-197: Avoid using the initial state variable in setState
Context: setTimeout(r, 50)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 213-213: Avoid using the initial state variable in setState
Context: setTimeout(r, 30)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

core/models/user.py

[warning] 43-43: Do not use text() as it leads to SQL injection
Context: text("1")
Note: [CWE-89] Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection').

(disable-sqlalchemy-text)

packages/browser-downloader/src/tier2-dom.js

[warning] 94-94: Avoid using the initial state variable in setState
Context: setTimeout(resolve, ms)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 159-159: Avoid using the initial state variable in setState
Context: setTimeout(() => ac.abort(), timeoutMs)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

worker/job_executor.py

[info] 187-187: use secrets package over random package
Context: random.uniform(5.0, 20.0)
Note: [CWE-330] Use of Insufficiently Random Values.

(avoid-random-python)

packages/browser-downloader/tests/tier1-cdp.test.js

[warning] 60-60: Avoid using the initial state variable in setState
Context: setTimeout(r, 0)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

packages/browser-downloader/src/server.js

[warning] 100-100: Avoid using the initial state variable in setState
Context: setTimeout(() => reject(new Error('request_timeout')), requestTimeout)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

packages/browser-downloader/src/tier1-cdp.js

[warning] 194-194: Avoid using the initial state variable in setState
Context: setTimeout(() => onFound(null), timeout)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 368-368: Avoid using the initial state variable in setState
Context: setInterval(checkDrm, 500)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

app/services/yt_dlp_service.py

[warning] 217-217: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(children_path)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

packages/browser-downloader/src/streamlink-backend.js

[warning] 26-26: Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process)


[error] 108-108: React's useState should not be directly called
Context: setTimeout(() => ac.abort(), timeout)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(usestate-direct-usage)


[error] 400-400: React's useState should not be directly called
Context: setTimeout(() => ac.abort(), dlTimeout)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(usestate-direct-usage)


[warning] 71-71: Avoid using the initial state variable in setState
Context: setTimeout(resolve, ms)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 108-108: Avoid using the initial state variable in setState
Context: setTimeout(() => ac.abort(), timeout)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 274-274: Avoid using the initial state variable in setState
Context: setTimeout(killNow, 2000)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 286-292: Avoid using the initial state variable in setState
Context: setTimeout(() => {
if (!killed) {
killed = true;
killGroup('SIGTERM');
killTimer = setTimeout(killNow, 2000);
}
}, timeout)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 290-290: Avoid using the initial state variable in setState
Context: setTimeout(killNow, 2000)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] 400-400: Avoid using the initial state variable in setState
Context: setTimeout(() => ac.abort(), dlTimeout)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)

🪛 GitHub Check: CodeQL
packages/browser-downloader/src/validate.js

[failure] 159-159: Uncontrolled data used in path expression
This path depends on a user-provided value.

packages/browser-downloader/src/streamlink-backend.js

[warning] 336-336: Network data written to file
Write to file system depends on Untrusted data.

🪛 Hadolint (2.14.0)
packages/browser-downloader/Dockerfile

[warning] 13-13: Pin versions in apt get install. Instead of apt-get install <package> use apt-get install <package>=<version>

(DL3008)


[warning] 13-13: Pin versions in pip. Instead of pip install <package> use pip install <package>==<version> or pip install --requirement <requirements file>

(DL3013)

🔇 Additional comments (48)
app/auth.py (1)

40-42: LGTM!

Also applies to: 124-150

tests/test_story_3_1_web_auth_extraction.py (1)

82-82: LGTM!

Also applies to: 117-123

app/api/rate_limit_config.py (1)

5-6: LGTM!

Also applies to: 24-36, 53-57, 68-73, 93-95

app/api/routes/web/web_downloads.py (1)

85-92: LGTM!

Also applies to: 120-140, 167-167, 203-203

core/queue.py (1)

8-17: LGTM!

Also applies to: 30-43

worker/main.py (1)

83-83: LGTM!

Also applies to: 96-98, 115-129, 141-144, 167-171, 315-320

packages/browser-downloader/tests/errors.test.js (1)

1-34: LGTM!

Also applies to: 44-68, 83-86

tests/test_story_2_4_web_html_consolidation.py (1)

149-149: LGTM!

core/models/user.py (1)

40-51: LGTM!

app/api/routes/downloads.py (1)

83-85: LGTM!

Also applies to: 118-120, 149-151, 210-212, 247-254, 263-265, 310-312

packages/browser-downloader/package.json (1)

4-4: playwright 1.49.1 carries a known advisory, and the base image is coupled to it.

A previous review flagged CVE-2025-59288 for playwright at this version. One additional constraint applies here: packages/browser-downloader/Dockerfile line 4 pins mcr.microsoft.com/playwright:v1.49.1-focal. The npm version and the base image tag must move together, otherwise the browser binaries shipped in the image will not match the client library.

packages/browser-downloader/Dockerfile (1)

34-48: LGTM!

packages/browser-downloader/src/concurrency.js (1)

7-43: LGTM!

packages/browser-downloader/src/streamlink-backend.js (1)

217-304: LGTM!

packages/browser-downloader/tests/concurrency.test.js (1)

5-52: LGTM!

packages/browser-downloader/tests/streamlink-backend.test.js (1)

40-87: LGTM!

pnpm-workspace.yaml (1)

1-2: LGTM!

worker/browser_executor.py (3)

211-223: LGTM!


293-321: LGTM!


49-75: LGTM!

Also applies to: 229-255, 258-290

app/services/yt_dlp_service.py (2)

264-267: LGTM!

Also applies to: 481-496


90-100: LGTM!

Also applies to: 270-313, 415-432

worker/job_executor.py (2)

224-256: LGTM!


309-341: 🩺 Stability & Availability

No change needed for worker timeout cancellation.

The download timeout is already longer than the default browser-downloader timeout; a worker retry does not stack abandoned microservice work by exceeding its timeout, so the saturated 503 failure mode described here does not follow from this code path.

			> Likely an incorrect or invalid review comment.
tests/test_worker/test_browser_executor.py (1)

297-329: LGTM!

packages/browser-downloader/src/server.js (3)

43-63: LGTM!


119-139: LGTM!


144-162: LGTM!

packages/browser-downloader/src/tier2-dom.js (1)

39-77: LGTM!

core/models/outbox.py (1)

18-53: LGTM!

core/metrics.py (1)

35-40: LGTM!

Also applies to: 173-173

worker/outbox_relay.py (1)

1-30: LGTM!

Also applies to: 64-91, 120-141, 150-165

worker/retry_scheduler.py (1)

8-8: LGTM!

Also applies to: 58-58, 142-142, 161-161

tests/test_services/test_download_service.py (1)

79-80: LGTM!

Also applies to: 100-102, 144-144

tests/test_worker/test_outbox_recovery.py (1)

95-106: LGTM!

Also applies to: 255-264, 301-303, 341-349, 385-394

tests/test_story_3_5_processor_retry_dlq_extraction.py (1)

149-152: LGTM!

Also applies to: 297-297, 341-344, 350-350, 389-391

app/services/download_service.py (1)

160-168: LGTM!

Also applies to: 184-188, 217-219, 256-266, 278-293, 348-348, 413-425, 435-435, 445-445

tests/test_story_1_2_config_extraction.py (1)

23-23: LGTM!

tests/test_story_1_3_database_metrics_extraction.py (1)

24-24: LGTM!

tests/test_story_1_4_redis_logging_queue_extraction.py (1)

22-22: LGTM!

packages/browser-downloader/src/validate.js (1)

111-142: LGTM!

Also applies to: 144-177, 179-222

packages/browser-downloader/src/downloader.js (1)

40-56: LGTM!

Also applies to: 64-95

packages/browser-downloader/tests/downloader.test.js (1)

191-213: LGTM!

packages/browser-downloader/tests/server.test.js (1)

223-254: LGTM!

packages/browser-downloader/tests/tier1-cdp.test.js (1)

63-216: LGTM!

Also applies to: 218-298, 300-350

packages/browser-downloader/tests/tier2-dom.test.js (1)

36-129: LGTM!

packages/browser-downloader/tests/validate.test.js (1)

16-58: LGTM!

Also applies to: 60-116, 139-154, 156-204

packages/browser-downloader/vitest.config.js (1)

1-10: LGTM!

Comment on lines 389 to +399
await enqueue_job(job_id)
cleanup_started = True
from sqlalchemy import update as sqlalchemy_update

await self.db.execute(
sqlalchemy_delete(Outbox).where(
sqlalchemy_update(Outbox)
.where(
Outbox.job_id == job_id,
Outbox.status == "pending",
)
.values(status="processed", processed_at=datetime.now(UTC)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update only the outbox row that started the fast path.

The update matches every pending row for job_id. After enqueue_job() succeeds, the relay or worker can process the original row and create a later pending retry event. This update can then mark that later event as processed without queueing it.

Select and retain the original Outbox.id before enqueue_job(). Update by that ID and status == "pending". Add a regression test that inserts a second pending outbox row between queue delivery and the update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/download_service.py` around lines 389 - 399, In the fast path
around enqueue_job, capture and retain the original pending Outbox.id before
delivery, then update only that row by ID while preserving the status ==
"pending" guard; do not continue matching by job_id alone. Add a regression test
that inserts a second pending Outbox row between queue delivery and the update
and verifies the later row remains pending.

Comment on lines +51 to +59
try:
await db.flush()
except IntegrityError:
# Concurrent writer beat us to the insert via the partial unique
# index. Roll back the just-flushed attempt only; the outer
# transaction (which may include a DownloadJob insert) must still
# commit. Detach our object so subsequent add/commit is clean.
db.expunge(outbox_entry)
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

SQLAlchemy 2.x documentation: After an IntegrityError from Session.flush(), does the Session require rollback before commit? What is the documented Session.begin_nested() pattern for handling duplicate-key insert races?

💡 Result:

In SQLAlchemy 2.x, if a Session.flush raises an IntegrityError (or any other exception), the underlying transaction is rolled back by the database, but the Session itself enters an inactive state [1][2][3]. You must explicitly call Session.rollback to reset the Session before it can be used for further operations [1][2][3]. Attempting to proceed without this explicit rollback will result in an error indicating the transaction has been rolled back due to a previous exception [3][4]. To handle scenarios like duplicate-key insert races without invalidating the entire transaction, the recommended pattern is to use Session.begin_nested [1][5][6]. This creates a SAVEPOINT, allowing you to isolate the operation that might fail [1][5]. If an IntegrityError occurs, you can catch it, roll back only to that savepoint, and continue the transaction [5][6]. The following pattern is documented for handling such races: with session.begin_nested: try: session.add(obj) session.flush except IntegrityError: # Handle the race condition (e.g., skip the insert, # or perform a query to get the existing object) print("Duplicate entry detected, skipping.") # The outer transaction remains active and can be committed or further modified. When Session.begin_nested is called, the Session performs an unconditional flush to ensure the database state is synchronized with the Session's in-memory state before the savepoint is established [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)outbox_service\.py$|DownloadService|requirements|pyproject|poetry.lock|uv.lock|Pipfile.lock' || true

echo
echo "== outbox_service outline/contents =="
fd -a 'outbox_service.py$' . | while read -r f; do
  echo "--- $f"
  wc -l "$f"
  cat -n "$f"
done

echo
echo "== usages of add_outbox_entry_outbox_entry or AsyncSession rollback/commit =="
rg -n "add_outbox_entry_outbox_entry|--outbox|download|commit\(|rollback\(|begin_nested|IntegrityError" app . -g '!*.pyc' || true

Repository: tomkabel/vooglaadija

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== git status/stat =="
git status --short
git diff --stat -- app || true

echo
echo "== candidate outbox/download files =="
git ls-files 'app/**' | rg 'outbox|download|service' | sort | sed -n '1,120p'

echo
echo "== focused usages =="
git ls-files 'app/**' | xargs rg -n "add_outbox_entry_outbox_entry|--outbox|create\(|commit\(|rollback\(|begin_nested|IntegrityError|DownloadService" -g '*.py' || true

Repository: tomkabel/vooglaadija

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repository entries excluding lock/html =="
git ls-files | awk '!/node_modules|dist|build|.*\.html$|.*\.min\.js$|.*\.json$/ {print}' | sed -n '1,160p'

echo
echo "== app python files =="
git ls-files 'app/**/*.py' | sed -n '1,160p'

echo
echo "== focused service searches =="
((rg -n "add_outbox_entry_outbox_entry|--outbox|Outbox|DownloadService|commit\(|rollback\(|begin_nested|IntegrityError" app --glob '*.py' || true) | sed -n '1,200p')

Repository: tomkabel/vooglaadija

Length of output: 10981


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== outbox_service.py =="
cat -n app/services/outbox_service.py

echo
echo "== core/models/outbox.py =="
cat -n core/models/outbox.py

echo
echo "== relevant download_service.py sections =="
sed -n '120,160p' app/services/download_service.py | cat -n
sed -n '180,215p' app/services/download_service.py | cat -n
sed -n '380,408p' app/services/download_service.py | cat -n

echo
echo "== db/session config =="
cat -n core/database.py

Repository: tomkabel/vooglaadija

Length of output: 14169


Recover duplicate outbox inserts inside a savepoint.

await db.flush() leaves the outer AsyncSession inactive after an IntegrityError; db.expunge(outbox_entry) does not make the transaction committable. DownloadService.create() and DownloadService.retry() then fail at commit() instead of handling the concurrent insert idempotently. Wrap the insert and flush in db.begin_nested(), return None only after confirming the pending outbox row exists, and re-raise other integrity failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/outbox_service.py` around lines 51 - 59, Update the outbox
insert flow around the db.flush() IntegrityError handler to execute the
add/flush inside db.begin_nested(), so duplicate conflicts roll back only the
savepoint and leave the outer transaction active. On conflict, confirm the
existing pending outbox row before returning None, and re-raise IntegrityError
instances that are not confirmed duplicates; do not rely on
db.expunge(outbox_entry) for recovery.

Comment thread core/models/outbox.py
Comment on lines +61 to +67
Index(
"uq_outbox_pending_job_id",
"job_id",
unique=True,
postgresql_where=(status == _PENDING_STATUS),
sqlite_where=(status == _PENDING_STATUS),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -HI -t f '.*(migration|alembic).*' .
rg -n -i -C4 'uq_outbox_pending_job_id|CREATE.*INDEX|outbox.*pending' \
  -g '*.py' -g '*.sql' .

Repository: tomkabel/vooglaadija

Length of output: 49317


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## alembic versions"
fd -HI -t f '.*\.py$' alembic/versions | sort

echo "## candidate migration file"
cat -n alembic/versions/009_add_outbox_pending_unique_index.py

echo "## model context"
sed -n '1,90p' core/models/outbox.py | cat -n

echo "## migration chain references"
rg -n -C3 '00[0-9]|revision|down_revision|alembic|ensure_migration_chain' alembic/versions scripts/ensure_migration_chain.py tests/test_ensure_migration_chain.py

Repository: tomkabel/vooglaadija

Length of output: 703


Add the uq_outbox_pending_job_id migration.

The model declares the partial unique index, but alembic/versions does not contain a migration for uq_outbox_pending_job_id. Existing deployed databases will miss this constraint unless a PostgreSQL and SQLite migration is added for the WHERE status = 'pending' DDL.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/models/outbox.py` around lines 61 - 67, Add an Alembic migration for the
model’s uq_outbox_pending_job_id index, creating a unique job_id index filtered
by status = 'pending' for both PostgreSQL and SQLite, and include the
corresponding downgrade removal. Ensure the migration is linked to the current
revision chain.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline app/services/outbox_service.py --items all
rg -n -C5 'async def write_job_to_outbox|begin_nested|await db\.flush|except IntegrityError' \
  app/services/outbox_service.py
rg -n -C4 'write_job_to_outbox|concurr|IntegrityError' tests

Repository: tomkabel/vooglaadija

Length of output: 18539


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- app/services/outbox_service.py ---\n'
cat -n app/services/outbox_service.py

printf '\n--- core/models/outbox.py relevant lines ---\n'
sed -n '1,120p' core/models/outbox.py | cat -n

printf '\n--- download_service references ---\n'
rg -n -C6 'write_job_to_outbox|class DownloadService|async def create|DownloadJob' app/services/download_service.py app/services || true

printf '\n--- tests mentioning outbox_service/create + integrity race ---\n'
rg -n -C5 'test_write_job_to_outbox_idempotent_skips_duplicate|IntegrityError|begin_nested|write_job_to_outbox' tests/test_services/test_outbox_service.py tests/app app || true

printf '\n--- deterministic SQLAlchemy session state probe (if installed/readable) ---\n'
python3 - <<'PY'
try:
    import sqlalchemy
    print("SQLAlchemy available:", sqlalchemy.__version__)
except Exception as e:
    print("SQLAlchemy unavailable:", repr(e))
PY

Repository: tomkabel/vooglaadija

Length of output: 249


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- app/services/outbox_service.py ---'
cat -n app/services/outbox_service.py

printf '%s\n' ''
printf '%s\n' '--- core/models/outbox.py relevant lines ---'
sed -n '1,140p' core/models/outbox.py | cat -n

printf '%s\n' ''
printf '%s\n' '--- download_service references ---'
rg -n -C6 'write_job_to_outbox|class DownloadService|async def create|DownloadJob' app/services/download_service.py app/services || true

printf '%s\n' ''
printf '%s股份有限公司' '--- tests mentioning outbox_service/create + integrity race ---'
rg -n -C5 'test_write_job_to_outbox_idempotent_skips_duplicate|IntegrityError|begin_nested|write_job_to_outbox' tests/test_services/test_outbox_service.py tests/app app || true

printf '%s\n' ''
printf '%s\n' '--- deterministic SQLAlchemy session state probe (if installed/readable) ---'
python3 - <<'PY'
try:
    import sqlalchemy
    print("SQLAlchemy version:", sqlalchemy.__version__)
except Exception as e:
    print("SQLAlchemy unavailable:", repr(e))
PY

Repository: tomkabel/vooglaadija

Length of output: 50376


🌐 Web query:

SQLAlchemy asyncio AsyncSession begin_nested rollback_savepoint flush after session failed state after IntegrityError

💡 Result:

In SQLAlchemy, when using AsyncSession.begin_nested(), the session creates a SAVEPOINT in the database, allowing you to perform operations that may fail—such as those triggering an IntegrityError—without rolling back the entire outer transaction [1][2][3]. When an IntegrityError occurs within the async with session.begin_nested(): context manager, the following behavior is standard: 1. Automatic Savepoint Rollback: The context manager automatically issues a ROLLBACK TO SAVEPOINT command to the database when an exception is raised within its block [1][2][4]. This ensures that only the operations within the nested scope are reverted [5][3]. 2. Session State: While the database SAVEPOINT is rolled back, the AsyncSession object itself remains in a usable state [3]. Unlike a standard flush failure on a top-level transaction, which invalidates the entire session and requires an explicit session.rollback() on the session itself, begin_nested() handles the internal session state specifically for the nested block [1][3][6]. 3. Correct Usage Pattern: You do not need to call await session.rollback() on the main AsyncSession if you are using begin_nested() to isolate the error [6]. In fact, calling await session.rollback() on the outer session will roll back the entire transaction, which is usually not the intended behavior when using nested transactions [3][6]. Example of the correct pattern: async def safe_operation(session: AsyncSession): try: async with session.begin_nested: session.add(some_object) await session.flush # IntegrityError might be raised here except IntegrityError: # The savepoint is already rolled back by the context manager. # The outer transaction is still active and safe to continue. pass Key Considerations: - Flush Behavior: When session.begin_nested() is called, SQLAlchemy unconditionally flushes all pending changes to the database [1][7]. - Invalidation: If you experience an InvalidRequestError stating "This Session's transaction has been rolled back due to a previous exception during flush," it typically indicates that an exception occurred during an operation outside of a begin_nested() block or that you manually rolled back the outer transaction [7][8]. Always ensure that potentially failing operations are wrapped in begin_nested() if you intend to recover from them without invalidating the session [3][6].

Citations:


Wrap the outbox insert in a savepoint.

write_job_to_outbox catches concurrent flush() failures and calls db.expunge(), but the exception still leaves the top-level transaction unreadable for the enclosing DownloadService.create path. Use await db.begin_nested() around the insert/flush and add a two-session concurrency test for the partial unique index.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/models/outbox.py` around lines 61 - 67, Update write_job_to_outbox to
execute the outbox insert and flush inside an await db.begin_nested() savepoint,
preserving its existing concurrent-conflict handling so failures do not
invalidate the enclosing transaction. Add a concurrency test using two sessions
that exercises the partial unique index and verifies only one pending job is
retained while both sessions remain usable.

Comment on lines +13 to +30
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
curl \
ca-certificates \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& pip3 install --no-cache-dir streamlink \
&& npm install -g [email protected]

WORKDIR /app

# Install production deps only. Skip scripts so the `playwright` package does
# not re-download browsers that the base image already ships.
COPY package.json ./
RUN pnpm install --prod --ignore-scripts \
&& rm -rf /root/.local/share/pnpm/store /tmp/pnpm/store

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate lockfiles and any compose/CI definition that builds this image.
fd -H -t f 'pnpm-lock.yaml|package-lock.json|yarn.lock'
rg -n -C4 'browser-downloader' --iglob '*compose*' --iglob '*.yml' --iglob '*.yaml' --iglob 'Makefile' | head -60

Repository: tomkabel/vooglaadija

Length of output: 224


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Repo files of interest:\n'
for f in pnpm-workspace.yaml pnpm-lock.yaml packages/browser-downloader/package.json packages/browser-downloader/Dockerfile README.md; do
  if [ -f "$f" ]; then
    printf '\n=== %s ===\n' "$f"
    wc -l "$f"
    sed -n '1,220p' "$f"
  else
    printf '%s missing\n' "$f"
  fi
done

printf '\nBuild references for browser-downloader:\n'
rg -n -C4 'browser-downloader|packages/browser-downloader|context:|docker build|DOCKERFILE|BUILDKIT|github\.com/actions/docker-build|buildkit' --iglob '*.yml' --iglob '*.yaml' --iglob 'Makefile' --iglob '*Dockerfile*' --iglob '*.md' | head -200

Repository: tomkabel/vooglaadija

Length of output: 18768


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read-only parse the Dockerfile instructions relevant to the comment:
# base image context, copied package JSON, lockfile presence in context, frozen-lock flag.
python3 - <<'PY'
from pathlib import Path
dockerfile = Path('packages/browser-downloader/Dockerfile').read_text()
lines = dockerfile.splitlines()

found = {
    'apt_unpinned': False,
    'streamlink_unpinned': False,
    'pnpm_10_33_2': False,
    'copy_package_json_only': False,
    'copy_copy_files': None,
    'pnpm_install': False,
    'has_frozen_lockfile': False,
    'packages_dir_exists': False,
    'root_lockfile_exists': False,
}

for line in lines:
    stripped = line.strip()
    if stripped.startswith('#'):
        continue
    if stripped.lower() == 'from ' and 'mcr.microsoft.com/playwright:v1.49.1-focal' in stripped:
        found['apt_unpinned'] = True
        found['streamlink_unpinned'] = False
        continue
    if 'apt-get install' in stripped:
        found['apt_unpinned'] = True
    if 'pip3 install --no-cache-dir streamlink' in stripped:
        found['streamlink_unpinned'] = True
    if 'npm install -g [email protected]' in stripped:
        found['pnpm_10_33_2'] = True
    if stripped.lower().startswith('copy ') and 'package.json' in stripped:
        found['copy_copy_files'] = stripped.split(None, 1)[1]
        found['copy_package_json_only'] = stripped.lower() == 'copy package.json ./'.replace('\t', ' ') or stripped.endswith('package.json ./')
    if stripped.lower().startswith('run pnpm install'):
        found['pnpm_install'] = True
        found['has_frozen_lockfile'] = '--frozen-lockfile' in stripped or '--frozen-lockfile' in stripped.lower()

found['packages_dir_exists'] = Path('packages/browser-downloader').is_dir()
found['root_lockfile_exists'] = Path('pnpm-lock.yaml').is_file()

for k, v in found.items():
    print(f'{k}={v}')
if found['copy_copy_files']:
    print(f'copy_line={found["copy_copy_files"]}')
PY

Repository: tomkabel/vooglaadija

Length of output: 400


Pin the build inputs for reproducible images.

pip3 install streamlink resolves to the available streamlink version at build time. pnpm install --prod has no lockfile in the Docker build step because only package.json is copied. Pin streamlink to a fixed version, and add the root pnpm-lock.yaml into the Docker build so pnpm install --prod can run with --frozen-lockfile.

🧰 Tools
🪛 Hadolint (2.14.0)

[warning] 13-13: Pin versions in apt get install. Instead of apt-get install <package> use apt-get install <package>=<version>

(DL3008)


[warning] 13-13: Pin versions in pip. Instead of pip install <package> use pip install <package>==<version> or pip install --requirement <requirements file>

(DL3013)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/browser-downloader/Dockerfile` around lines 13 - 30, Update the
Dockerfile dependency installation to pin streamlink to a fixed version, copy
the root pnpm-lock.yaml alongside package.json before installation, and run pnpm
install --prod with --frozen-lockfile while preserving --ignore-scripts and
cleanup behavior.

Source: Linters/SAST tools

Comment thread packages/browser-downloader/src/downloader.js
Comment on lines +290 to +296
async def test_open_circuit_raises_transient_without_http_call(self) -> None:
# When the breaker is OPEN, no HTTP call should be made. We use a
# transport that would raise if invoked, proving the call was skipped.
# Phase 2 fix: CircuitBreakerOpenError propagates raw so the processor's
# deferred-job path handles it (worker/processor.py:_handle_circuit_open).
from app.services.circuit_breaker import CircuitBreakerOpenError

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the test to match its new expectation.

The test now asserts CircuitBreakerOpenError, not a transient error, so test_open_circuit_raises_transient_without_http_call is misleading. Rename it to test_open_circuit_propagates_circuit_breaker_open_without_http_call.

Also move the CircuitBreakerOpenError import to the module top level, next to the other imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_worker/test_browser_executor.py` around lines 290 - 296, Rename
test_open_circuit_raises_transient_without_http_call to
test_open_circuit_propagates_circuit_breaker_open_without_http_call to reflect
the asserted exception, and move the CircuitBreakerOpenError import from inside
the test to the module-level imports.

Comment on lines +323 to +341
def _parse_failure_payload(
payload: dict[str, Any],
*,
fallback_code: str = "unknown_error",
) -> tuple[str, str, str | None]:
"""Map a structured failure payload to an error category.

Accepts both 200-with-failed-status and non-200 responses.
Raises BrowserExecutorError so the circuit breaker records a failure.

When the JSON body lacks an explicit ``error`` field, ``fallback_code``
(typically the synthesized ``http_<status>`` signal) is used so HTTP
404/403/429 still map to their correct categories.
"""
code: str = payload.get("error") # type: ignore[assignment]
if not isinstance(code, str) or not code:
code = fallback_code
category = _map_response_to_category(code, payload)
raise BrowserExecutorError(category=category, signal=code)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop the type: ignore and use NoReturn.

Line 337 annotates code: str while payload.get("error") can return None or a non-string, which forces the type: ignore. The function also never returns; it always raises. Both can be expressed accurately.

♻️ Proposed change
 def _parse_failure_payload(
     payload: dict[str, Any],
     *,
     fallback_code: str = "unknown_error",
-) -> tuple[str, str, str | None]:
+) -> NoReturn:
@@
-    code: str = payload.get("error")  # type: ignore[assignment]
-    if not isinstance(code, str) or not code:
-        code = fallback_code
+    raw_code = payload.get("error")
+    code = raw_code if isinstance(raw_code, str) and raw_code else fallback_code
     category = _map_response_to_category(code, payload)
     raise BrowserExecutorError(category=category, signal=code)

Import NoReturn from typing. Note that _parse_success at Line 280 uses return _parse_failure_payload(payload); with NoReturn that line still type-checks, and you can simplify it to a bare call.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _parse_failure_payload(
payload: dict[str, Any],
*,
fallback_code: str = "unknown_error",
) -> tuple[str, str, str | None]:
"""Map a structured failure payload to an error category.
Accepts both 200-with-failed-status and non-200 responses.
Raises BrowserExecutorError so the circuit breaker records a failure.
When the JSON body lacks an explicit ``error`` field, ``fallback_code``
(typically the synthesized ``http_<status>`` signal) is used so HTTP
404/403/429 still map to their correct categories.
"""
code: str = payload.get("error") # type: ignore[assignment]
if not isinstance(code, str) or not code:
code = fallback_code
category = _map_response_to_category(code, payload)
raise BrowserExecutorError(category=category, signal=code)
def _parse_failure_payload(
payload: dict[str, Any],
*,
fallback_code: str = "unknown_error",
) -> NoReturn:
"""Map a structured failure payload to an error category.
Accepts both 200-with-failed-status and non-200 responses.
Raises BrowserExecutorError so the circuit breaker records a failure.
When the JSON body lacks an explicit ``error`` field, ``fallback_code``
(typically the synthesized ``http_<status>`` signal) is used so HTTP
404/403/429 still map to their correct categories.
"""
raw_code = payload.get("error")
code = raw_code if isinstance(raw_code, str) and raw_code else fallback_code
category = _map_response_to_category(code, payload)
raise BrowserExecutorError(category=category, signal=code)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/browser_executor.py` around lines 323 - 341, Update
_parse_failure_payload to return NoReturn and import NoReturn from typing,
reflecting that it always raises BrowserExecutorError. Remove the type: ignore
by retrieving payload.get("error") without an incorrect str annotation, then
validate it with the existing isinstance/non-empty check before applying
fallback_code. Simplify the _parse_success failure path from returning
_parse_failure_payload(payload) to calling it directly.

Comment thread worker/main.py
Comment on lines +271 to +273
outbox_sync_interval_seconds = int(os.environ.get("OUTBOX_SYNC_INTERVAL_SECONDS", "2"))
except (ValueError, TypeError):
outbox_sync_interval_seconds = 30
outbox_sync_interval_seconds = 2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial

Validate the 2-second outbox polling interval under production load.

The new default can call sync_outbox_to_queue() up to 15 times more often per worker. Verify that the pending-outbox query and database capacity support the configured worker count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/main.py` around lines 271 - 273, Validate the new
OUTBOX_SYNC_INTERVAL_SECONDS default and configured interval against production
worker counts in the outbox polling flow around sync_outbox_to_queue(). Confirm
the pending-outbox query and database capacity support the increased polling
frequency, and adjust the interval or query/database configuration as needed
while preserving the existing fallback behavior for invalid values.

Comment thread worker/outbox_relay.py
Comment on lines +54 to +59
oldest = await db.scalar(
select(func.extract("epoch", func.now() - func.min(Outbox.created_at))).where(
Outbox.status == _PENDING_STATUS,
),
)
OUTBOX_OLDEST_PENDING_SECONDS.set(float(oldest or 0))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 'sqlite|sqlite_where|TestingSessionLocal|DATABASE_URL' \
  core/models/outbox.py core/database.py tests/conftest.py pyproject.toml
rg -n -C4 'extract\("epoch"|OUTBOX_OLDEST_PENDING_SECONDS|func\.min\(Outbox\.created_at\)' \
  worker/outbox_relay.py

Repository: tomkabel/vooglaadija

Length of output: 5813


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Outbox model relevant fields:"
sed -n '1,120p' core/models/outbox.py

echo
echo "Relay staleness metrics section:"
sed -n '1,100p' worker/outbox_relay.py

echo
echo "SQLAlchemy expressions and dependencies:"
rg -n 'from sqlalchemy import|import sqlalchemy|sqlalchemy' pyproject.toml worker core tests | head -80

echo
echo "Inspect raw SQL if available:"
rg -n 'def render_bindparam|sqlite|dialect' core | head -80

Repository: tomkabel/vooglaadija

Length of output: 13613


🌐 Web query:

SQLAlchemy func now sqlite expression func.extract epoch datetime columns documentation SQLite timestamp arithmetic

💡 Result:

To perform date and time operations with SQLite in SQLAlchemy, it is important to understand that SQLite lacks native DATE, TIME, and DATETIME types, storing them instead as strings [1][2][3]. SQLAlchemy handles this by formatting and parsing these values [2][3]. Using func.now and Time To use the current timestamp, use func.current_timestamp [4][5]. While SQLAlchemy provides a generic func.now that often compiles to CURRENT_TIMESTAMP, explicitly using func.current_timestamp is recommended for clarity [4][6][5]. Note that standard SQLite CURRENT_TIMESTAMP does not include fractional seconds [7]. To achieve higher precision, you may need to use custom logic or SQLite's unixepoch function (available in newer SQLite versions) with appropriate modifiers [8]. Extracting Components (e.g., Epoch) To extract parts of a datetime (such as the epoch/seconds since 1970) in SQLAlchemy with SQLite, use the extract construct [9]. The SQLite dialect implements this by casting the result of the strftime function [1]: from sqlalchemy import extract # Example: extracting year stmt = select(extract("year", MyTable.datetime_column)) The SQLite dialect supports mapping several fields to strftime formats, including "epoch" (which maps to %s) [1]. Timestamp Arithmetic Because SQLite stores dates as strings, standard arithmetic (like adding a timedelta) requires using SQLite's built-in date/time functions [8][10]. You can use func.datetime to apply modifiers, such as adding or subtracting seconds [10]: from sqlalchemy import func, cast, String # Example: adding seconds to a datetime column stmt = select(MyTable).where( func.datetime( MyTable.datetime_column, '+' + cast(MyTable.some_integer_column, String) + ' seconds') < func.now) For complex operations or when using newer SQLite versions (3.38.0+), the unixepoch function can be used for more straightforward arithmetic by converting timestamps to integers [8][10]. Always consult the official SQLite date and time function documentation when dealing with specific formatting or modifier requirements [8][10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("worker/outbox_relay.py")
tree = ast.parse(path.read_text(), filename=str(path))
print(path, "exists")
for node in ast.walk(tree):
    if isinstance(node, ast.AsyncFunctionDef) and node.name == "_update_staleness_metrics":
        print("found _update_staleness_metrics")
        print("line", node.lineno)
        for child in node.body:
            print(type(child).__name__, getattr(child, "lineno", None))
            if isinstance(child, ast.Try):
                try_body = ast.get_source_segment(path.read_text(), child.body[0])
                print("try first stmt:")
                print(try_body)
PY

Repository: tomkabel/vooglaadija

Length of output: 375


🌐 Web query:

SQLAlchemy func.extract epoch sqlite dialect strftime compile current_timestamp datetime column sub function __new__ source

💡 Result:

In SQLAlchemy, the func.extract(field, column) construct provides a dialect-agnostic way to retrieve components of date/time objects [1]. When using the SQLite dialect, SQLAlchemy compiles this extract call into a SQLite strftime function [2][3]. Specifically, the SQLite compiler (SQLiteCompiler in sqlalchemy.dialects.sqlite.base.py) maintains an extract_map that translates standard fields to SQLite strftime format strings [3]. For example, "year" becomes "%Y", "month" becomes "%m", and "epoch" maps to "%s" [3]. The compilation logic for extract is implemented in the visit_extract method, which wraps the strftime call in a CAST(... AS INTEGER) to ensure the result is returned as an integer, as shown here [3]: return "CAST(STRFTIME('%s', %s) AS INTEGER)" % (self.extract_map[extract.field], self.process(extract.expr, kw)) Regarding the internal source implementation: 1. Function Accessor: func is a FunctionGenerator instance [4]. When you access func.extract, it does not create a standard SQL function call [4]. Instead, it redirects to the extract() expression construct, which is defined in sqlalchemy.sql.expression and returns an Extract object [1][4][5]. 2. FunctionGenerator.getattr: The getattr method in FunctionGenerator handles dynamic attribute access, but extract is explicitly defined as a property on the FunctionGenerator class to facilitate this redirection [4][6]. 3. Current Timestamp: func.current_timestamp() is handled as a generic function or specific dialect-level translation [4][3]. In the SQLite dialect, the visit_now_func method compiles current_timestamp directly to the CURRENT_TIMESTAMP SQL keyword [3]. Note that SQLite does not have native DATETIME types; it typically stores them as strings [7][8][9]. While func.current_timestamp() is recognized by SQLAlchemy, it may require explicit type handling if you need it to return Python datetime objects rather than strings, as SQLAlchemy’s SQLite dialect does not automatically perform this conversion for all freestanding date functions [8][9].

Citations:


🌐 Web query:

SQLAlchemy SQLite extract_map epoch strftime CURRENT_TIMESTAMP datetime column SQLiteCompiler add_type_expression_engine type_annotation

💡 Result:

In SQLAlchemy, the SQLite dialect handles datetime expressions and extraction via the SQLiteCompiler, which relies on SQLite's built-in strftime function [1][2]. EXTRACT AND STRFTIME The SQLiteCompiler maintains an extract_map that defines how SQL extract expressions are translated into SQLite's strftime syntax [1][2]. For example, "month" maps to "%m" and "year" to "%Y" [1]. When using func.extract(field, column), SQLAlchemy renders this as CAST(STRFTIME(format, column) AS INTEGER), where the format is retrieved from the extract_map [1][2]. The "epoch" field is specifically mapped to "%s" [1][2]. CURRENT_TIMESTAMP AND DATETIME COLUMNS SQLite does not have native DATETIME types [3][4][5]. SQLAlchemy's DATETIME, DATE, and TIME types represent values as ISO-formatted strings [3][5]. The SQLiteCompiler renders func.now as CURRENT_TIMESTAMP [1][2]. Because pysqlite (the standard SQLite driver) does not provide native type information in cursor.description, SQLAlchemy cannot automatically detect and cast columns to Python datetime objects [3][4]. Users requiring native datetime objects from SQLite are generally advised to rely on SQLAlchemy's built-in result processing rather than relying on driver-level type detection [3][4]. TYPE ANNOTATION AND CUSTOM COMPILATION Modern SQLAlchemy (2.0+) uses type annotations with Mapped and mapped_column to associate Python types with SQL types [6][7]. The registry's type_annotation_map allows you to map specific Python types to custom SQL type implementations [7][8]. If you need to force specific DDL rendering for types across dialects (e.g., using a custom DATETIME format), you can use the @compiles decorator to intercept type compilation [9]. SUMMARY OF MECHANISMS - extract_map: Defines the mapping between SQL extract fields and strftime format strings for SQLite [1]. - SQLiteCompiler: The component responsible for translating SQLAlchemy's generic SQL constructs into SQLite-specific syntax (e.g., visit_extract, visit_now_func) [1][2]. - Type Annotation: Used via DeclarativeBase to configure type-to-SQL mapping, which simplifies defining columns that use custom types or specific configurations [7][8]. - Custom Compilation: Use the @compiles decorator for advanced customization of how SQL constructs or types are rendered for the SQLite dialect [10][9].

Citations:


🌐 Web query:

SQLite strftime %s CURRENT_TIMESTAMP datetime column subtraction SQLite

💡 Result:

In SQLite, you can calculate the difference between the current timestamp and a datetime column in seconds by converting both values into Unix timestamps using the %s format specifier with the strftime function [1][2][3]. To perform this subtraction, use the following syntax [2][3]: SELECT strftime('%s', 'now') - strftime('%s', your_column_name) FROM your_table_name; When using this method, ensure your datetime column contains values in a format natively supported by SQLite (e.g., 'YYYY-MM-DD HH:MM:SS') [1][3]. If the column uses a different format, the function may return NULL [3]. Alternative Methods: 1. Using Julian Day: This approach is often considered more precise or flexible for differences involving days or larger units, as it returns a floating-point value [4]. You can calculate the difference in days and multiply by 86400 (the number of seconds in a day) to get the result in seconds [5][4]: SELECT (julianday('now') - julianday(your_column_name)) * 86400 FROM your_table_name; 2. Timezone Considerations: If you are calculating the difference between 'now' and a stored column that does not include timezone information, the result may be affected by the system's local time setting [6]. To ensure consistent calculation, you can force the use of local time by adding the 'localtime' modifier [6]: SELECT strftime('%s', 'now', 'localtime') - strftime('%s', your_column_name) FROM your_table_name; It is generally recommended to store dates and times as ISO-8601 strings, Julian day numbers, or Unix integers to facilitate these calculations [7][8]. If you frequently need to perform arithmetic, storing timestamps as Unix integers (the number of seconds since 1970-01-01) is the most performant approach, as it allows for direct integer subtraction without calling conversion functions [7].

Citations:


🌐 Web query:

sqlalchemy.dialects.sqlite.base SQLiteCompiler visit_extract extraction_map source __init__

💡 Result:

In SQLAlchemy, the SQLiteCompiler class, located in lib/sqlalchemy/dialects/sqlite/base.py, handles the translation of SQLAlchemy extract expressions into SQLite-specific SQL [1][2]. The implementation relies on an extract_map dictionary, which is initialized by extending the base SQLCompiler.extract_map with SQLite-specific format strings [1][2]. The SQLiteCompiler.extract_map is defined as follows [1]: extract_map = util.update_copy( compiler.SQLCompiler.extract_map, { "month": "%m", "day": "%d", "year": "%Y", "second": "%S", "hour": "%H", "doy": "%j", "minute": "%M", "epoch": "%s", "dow": "%w", "week": "%W", },) The visit_extract method is responsible for generating the SQL string. It uses the provided extract_map to look up the correct format string for the field requested in the extract operation [1][2]. The implementation looks like this [1]: def visit_extract(self, extract, kw): try: return "CAST(STRFTIME('%s', %s) AS INTEGER)" % ( self.extract_map[extract.field], self.process(extract.expr, kw),) except KeyError as err: raise exc.CompileError( "%s is not a valid extract argument." % extract.field) from err This implementation renders the SQL as a CAST to INTEGER of a STRFTIME function call, which is the standard way to perform date/time extraction in SQLite dialects within SQLAlchemy [1][3]. If a field is requested that is not present in the extract_map, the method raises a CompileError [1][2].

Citations:


🌐 Web query:

SQLite documentation SUBTRACT current_timestamp strftime datetime subtraction CURRENT_TIMESTAMP datetime literal syntax

💡 Result:

In SQLite, date and time arithmetic is performed using specific built-in functions rather than standard arithmetic operators [1][2]. The approach depends on whether you are adjusting a timestamp by a duration or calculating the difference between two timestamps [1][2]. To add or subtract a fixed duration from a timestamp, use the datetime (or date/time) function with modifiers [3][4][1]. SQLite does not support direct subtraction syntax like 'now' - '1 day' [1]. Instead, use the negative sign within the modifier string [4][1]. Example: Subtracting 1 day and 2 hours from the current time [3][1]: SELECT datetime('now', '-1 day', '-2 hours'); To calculate the difference between two timestamps, the most common and reliable method is to convert both values to Julian day numbers using the julianday function and then subtract them [5][6][2]. This returns the difference in days [5][6]. To get the difference in other units, you can multiply the result [5]. Example: Calculating the difference in days between two columns [5][6]: SELECT julianday(end_column) - julianday(start_column) AS difference_in_days FROM your_table; Example: Calculating the difference in hours [5]: SELECT (julianday(end_column) - julianday(start_column)) * 24 AS difference_in_hours FROM your_table; Alternatively, for differences in seconds, you can use strftime('%s',...) to convert timestamps to Unix epoch seconds and subtract them [7][8]. Note: All date/time functions expect input strings to be in the format 'YYYY-MM-DD HH:MM:SS' [3][7]. Other formats may return NULL [7]. Using CURRENT_TIMESTAMP is equivalent to using the literal string 'now' in these functions [3][8].

Citations:


🌐 Web query:

GitHub SQLAlchemy lib sqlalchemy dialects sqlite base SQLiteCompiler visit_now_func CURRENT_TIMESTAMP SQLite source

💡 Result:

In SQLAlchemy, the SQLite dialect's compiler handles the now() SQL function by mapping it to the standard SQL CURRENT_TIMESTAMP expression. This is implemented in the SQLiteCompiler class within the lib/sqlalchemy/dialects/sqlite/base.py source file [1][2][3]. Specifically, the SQLiteCompiler overrides the visit_now_func method, which is invoked when a SQLAlchemy func.now() construct is compiled for a SQLite backend [1][2]. The implementation of this method is as follows: def visit_now_func(self, fn, kw): return "CURRENT_TIMESTAMP" This design ensures that func.now() produces valid SQL for SQLite [1][4]. Similarly, the visit_localtimestamp_func method is implemented to return DATETIME(CURRENT_TIMESTAMP, 'localtime') [1][2][3]. While func.now() is commonly used, SQLAlchemy also provides func.current_timestamp(), which is a dialect-agnostic way to generate the CURRENT_TIMESTAMP expression across different database backends [5]. Developers often use func.now() for convenience, but for cross-dialect migration compatibility, func.current_timestamp() is often recommended to ensure consistent SQL generation [5].

Citations:


🌐 Web query:

SQLAlchemy 2 datetime literal bind expression TIMESTAMP bind_processor SQLite DateTime source

💡 Result:

In SQLAlchemy 2, the interaction between DateTime types and SQLite is managed by dialect-specific implementation classes, most notably sqlalchemy.dialects.sqlite.DATETIME [1][2]. Because SQLite lacks a native DATETIME type, SQLAlchemy handles this by converting Python datetime objects into ISO-formatted strings [1][3]. For bind processing, the DATETIME class implements a bind_processor method [2][4]. This method returns a callable that accepts a Python datetime or date object and formats it into the specific string representation (e.g., YYYY-MM-DD HH:MM:SS.ffffff) that SQLite understands [2]. Regarding literal bind expressions (rendering values directly into SQL strings for debugging or logging via compile_kwargs={"literal_binds": True}): 1. Literal Rendering: If you encounter issues where DateTime values cannot be rendered as literals, it is often because a specific literal_processor has not been defined or invoked for that type in the current context [5][6]. 2. Customizing Behavior: To customize how DateTime values are processed—whether for bind parameters or literal rendering—the recommended approach is to use TypeDecorator [7][5]. This allows you to override process_bind_param for standard parameter binding and process_literal_param for literal rendering [7]. If you need to ensure consistent behavior across SQLite and other databases, TypeDecorator provides a centralized way to handle both the conversion of input values and the serialization of literals, bypassing limitations of the underlying dialect-specific implementation [7][5][8]. Note that the DATETIME and TIME types in the SQLite dialect function differently regarding automatic processing than the DATE or TIMESTAMP types, especially when certain sqlite3 driver flags are enabled [1][3][9].

Citations:


🌐 Web query:

SQLAlchemy SQLite DateTime bind literal processor render datetime object as string source

💡 Result:

In SQLAlchemy, the conversion of Python datetime objects into string literals for SQLite (when using literal binds) is handled by the literal_processor method of the dialect-specific type class [1][2]. For SQLite, SQLAlchemy provides specialized implementation classes: sqlalchemy.dialects.sqlite.DATETIME, DATE, and TIME [3][4]. These types are designed to store and render dates and times as ISO-formatted strings [3]. The literal_processor(self, dialect) method is responsible for returning a callable that accepts a Python value and converts it into the string representation required by the database [1][2]. Within the SQLite dialect's DATETIME implementation, the bind_processor and literal_processor methods use an internal formatting string to convert datetime.datetime or datetime.date objects into the format: 'YYYY-MM-DD HH:MM:SS.ffffff' (or a truncated version depending on the configuration) [5]. If you are implementing a custom type and need to support literal rendering, you should generally inherit from TypeDecorator [6][7]. Instead of overriding literal_processor directly, you should override process_literal_param(self, value, dialect) [6][7]. This allows you to define how your custom type should be rendered as a literal string without breaking the internal processing logic of the underlying implementation type [6][7]. Key technical details: - SQLite lacks native date/time types, so SQLAlchemy treats these values as strings [3][1]. - The literal_processor method is specifically invoked when the "literal_binds" flag is enabled (e.g., during DDL generation or when logging the full SQL string for debugging) [1][2][8]. - For custom types, TypeDecorator.process_literal_param is the recommended hook to ensure that the literal rendering is consistent with your type's process_bind_param and process_result_value logic [6][7].

Citations:


🌐 Web query:

site:github.com/sqlalchemy/sqlalchemy SQLAlchemy func.now CURRENT_TIMESTAMP SQLite datetime column subtraction

💡 Result:

SQLAlchemy does not provide built-in, database-agnostic operators for datetime arithmetic [1][2]. Because SQLite stores dates as strings and lacks native date subtraction operators that return a standard numeric interval, you must use SQLite's native date and time functions directly via SQLAlchemy's func construct [3][2]. To calculate the difference between CURRENT_TIMESTAMP and a column (e.g., my_table.c.datetime_col), you can use the SQLite julianday() function, which converts dates to a fractional day count, making them simple to subtract [2]. Example using SQLAlchemy: from sqlalchemy import func, select from your_model import MyTable # This generates: # SELECT julianday('now') - julianday(datetime_col) FROM my_table stmt = select(func.julianday('now') - func.julianday(MyTable.datetime_col)) In this expression: 1. func.julianday('now') acts as the current time. 2. func.julianday(MyTable.datetime_col) converts your stored column value. 3. The result is the difference in days. To get seconds, you would multiply the result by 86400 (24 * 60 * 60). Note that func.now() in SQLAlchemy renders as CURRENT_TIMESTAMP in SQLite, which returns a string [4][5]. Since SQLite arithmetic requires specific functions like julianday() or datetime(), you should explicitly use these via func rather than attempting direct subtraction of column objects [3][2].

Citations:


🌐 Web query:

SQLite CURRENT_TIMESTAMP subtraction strftime %s CURRENT_TIMESTAMP column ISO datetime NULL

💡 Result:

In SQLite, you cannot perform direct arithmetic on the CURRENT_TIMESTAMP keyword or columns stored as text, as they are treated as strings [1][2][3]. To perform time calculations, you must convert the values into a numeric format [1][4]. Subtraction using strftime('%s') To calculate the difference between the current time and a timestamp column in seconds, convert both to Unix time (seconds since 1970-01-01) using strftime('%s',...) [5][1][3]: SELECT strftime('%s', 'now') - strftime('%s', my_timestamp_column) FROM my_table; Note: strftime('%s', 'now') returns the time in UTC [5][6]. If your column contains local time, you may need to apply the 'localtime' modifier to your 'now' call or ensure consistency in stored timezone data [7][6]. Alternative using julianday You can also use the julianday function, which returns the fractional number of days since November 24, 4714 B.C. [1][8]. To get the difference in seconds, multiply the difference in days by 86400 (the number of seconds in a day) [1][8]: SELECT (julianday('now') - julianday(my_timestamp_column)) * 86400 FROM my_table; Column Definitions When creating a table, CURRENT_TIMESTAMP can be used as a default value [2][9]. It stores the current UTC time as a text string in the format YYYY-MM-DD HH:MM:SS [2]. CREATE TABLE example ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP); If you require the default value to be an expression (such as forcing local time or storing a Unix timestamp), you must enclose it in parentheses [2][9]. For example, to store the Unix timestamp as an integer: CREATE TABLE example ( created_at INTEGER DEFAULT (strftime('%s', 'now'))); If you encounter issues where CURRENT_TIMESTAMP is being treated as a literal string "CURRENT_TIMESTAMP" rather than a function, ensure your syntax uses the keyword directly in the DEFAULT clause without quotes, or use the parentheses syntax for expressions [2][9][10].

Citations:


Calculate pending age outside SQLite datetime arithmetic.

func.now() - func.min(Outbox.created_at) does not produce a timestamp interval on SQLite, and the except path resets OUTBOX_OLDEST_PENDING_SECONDS to 0. Fetch the oldest created_at and compute elapsed seconds in Python, or use dialect-specific datetime arithmetic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/outbox_relay.py` around lines 54 - 59, Update the pending-age
calculation in the outbox relay to fetch the minimum pending Outbox.created_at
value and compute elapsed seconds in Python, avoiding SQLite datetime
subtraction. Preserve the existing zero value when no pending record exists, and
ensure the OUTBOX_OLDEST_PENDING_SECONDS metric receives the computed age
without relying on the exception-reset path.

Comment thread worker/retry_scheduler.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

Comment thread packages/browser-downloader/src/downloader.js Outdated
Comment thread packages/browser-downloader/src/validate.js
Comment on lines +168 to +189
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), 'bd-dl-'));
env = { ...process.env };
for (const m of Object.values(mocks)) {
m.mockReset();
}
mocks.newPage.mockResolvedValue({});
mocks.newContext.mockResolvedValue({
newPage: (...a) => mocks.newPage(...a),
close: (...a) => mocks.contextClose(...a),
});
mocks.launch.mockResolvedValue({
newContext: (...a) => mocks.newContext(...a),
close: (...a) => mocks.browserClose(...a),
});
mocks.contextClose.mockResolvedValue();
mocks.browserClose.mockResolvedValue();
});

afterEach(() => {
process.env = env;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Vitest 2.1 vi.stubEnv vi.unstubAllEnvs API

💡 Result:

In Vitest, vi.stubEnv and vi.unstubAllEnvs are utilities designed to manage environment variables during tests [1][2]. vi.stubEnv(name, value) This method updates an environment variable on both process.env and import.meta.env [1][3]. When you call this for the first time for a specific variable, Vitest stores the original value, allowing you to restore it later [1][2]. - Name: The name of the environment variable (string) [1][2]. - Value: The new value to set [1][2]. If set to undefined, the environment variable is deleted [1][3]. Note that if the name corresponds to specific built-in environment variables (like PROD, DEV, or SSR), the value can be a boolean [1][4]. vi.unstubAllEnvs This method restores all environment variables to their original values as they existed before any vi.stubEnv calls were made [1][2]. It is intended to clean up the environment after tests to prevent state leakage [1][2]. Configuration Option Alternatively, you can enable the unstubEnvs configuration option in your vitest.config.ts file to have Vitest automatically call vi.unstubAllEnvs before each test [5][6][7]: // vitest.config.ts export default defineConfig({ test: { unstubEnvs: true, }, }) Warning: Using the unstubEnvs configuration option may cause issues with concurrent tests, as the completion of one test might restore environment variables that are still in use by other tests running in parallel [7]. In such cases, calling vi.unstubAllEnvs manually in a beforeEach hook is recommended [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching downloader.test.js:\n'
fd -a 'downloader\.test\.js$' . | sed 's#^\./##'

FILE="$(fd 'downloader\.test\.js$' . | head -n 1)"
if [ -n "${FILE:-}" ]; then
  printf '\nFile: %s\n' "$FILE"
  wc -l "$FILE"
  printf '\nRelevant lines 1-230:\n'
  sed -n '1,230p' "$FILE" | nl -ba
fi

printf '\nvitest version references:\n'
if [ -f package.json ]; then cat package.json | sed -n '1,220p'; fi
fd -a 'package\.json$' . | sort | while read -r f; do
  if rg -q '"vitest"|"`@vitest/`' "$f"; then
    echo "--- ${f#./}"
    rg -n '"vitest"|"`@vitest/`' "$f"
  fi
done

printf '\nUsage of vi.stubEnv/unstubAllEnvs/process.env snapshots:\n'
rg -n "vi\.stubEnv|unstubAllEnvs|process\.env\s*=|env\s*=.*process\.env" . -g '!node_modules' -g '!dist' -g '!build'

Repository: tomkabel/vooglaadija

Length of output: 450


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILES="$(fd 'downloader\.test\.js$' . | head -n 1)"
if [ -n "${FILES:-}" ]; then
  printf 'File: %s\n' "$FILES"
  wc -l "$FILES"
  printf '\nRelevant lines 1-240:\n'
  awk '{printf "%5d\t%s\n", NR, $0}' "$FILES" | sed -n '1,240p'
fi

printf '\nRelevant version/config/package files:\n'
fd 'package\.json$' . | sort | while read -r f; do
  if rg -q '"vitest"|"`@vitest/`' "$f" || [[ "$f" =~ vitest\.config\.(ts|js|mjs|cjs)$ ]]; then
    echo "--- ${f#./}"
    rg -n '"vitest"|"`@vitest/`' 'vitest' "$f" || true
  fi
done

printf '\nUsage of vi.stubEnv/unstubAllEnvs/process.env snapshots:\n'
rg -n "vi\.stubEnv|unstubAllEnvs|process\.env\s*=|env\s*=.*process\.env" .

Repository: tomkabel/vooglaadija

Length of output: 9865


Use Vitest environment stubs for BD_DOWNLOAD_TIMEOUT_MS.

Set this fixture with vi.stubEnv('BD_DOWNLOAD_TIMEOUT_MS', '60000') and restore it in afterEach with vi.unstubAllEnvs() instead of storing/replacing process.env. This keeps environment changes scoped and uses Vitest’s supported restore API.

♻️ Proposed refactor
-  let env;
-
   beforeEach(async () => {
     base = await mkdtemp(join(tmpdir(), 'bd-dl-'));
-    env = { ...process.env };
-  afterEach(() => {
-    process.env = env;
-  });
+  afterEach(() => {
+    vi.unstubAllEnvs();
+  });

Then set the variable with vi.stubEnv('BD_DOWNLOAD_TIMEOUT_MS', '60000') on line 200.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/browser-downloader/tests/downloader.test.js` around lines 168 - 189,
Update the downloader test setup to stub BD_DOWNLOAD_TIMEOUT_MS with Vitest’s
vi.stubEnv('BD_DOWNLOAD_TIMEOUT_MS', '60000') in beforeEach, and remove the
manual env snapshot and process.env replacement. Restore environment stubs in
afterEach using vi.unstubAllEnvs(), while preserving the existing mock setup and
teardown.

Comment on lines +128 to +132
it('rejects a path outside the base (path traversal)', async () => {
const base = await mkdtemp(join(tmpdir(), 'bd-out-'));
const realpath = async (p) => p.replace(`${base}/../`, '/etc/');
await expect(validateOutputDir('/etc/passwd', { base, realpath })).rejects.toThrow();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test does not exercise path traversal.

The realpath fake replaces `${base}/../` with /etc/. The input is /etc/passwd, which does not contain that substring, so the fake behaves as the identity function. The test only proves that /etc/passwd is not under a temporary base. It does not cover a .. segment or a symlink that resolves outside the base, which is the case the containment check in validateOutputDir defends against.

💚 Proposed test that models a symlink escape
   it('rejects a path outside the base (path traversal)', async () => {
     const base = await mkdtemp(join(tmpdir(), 'bd-out-'));
-    const realpath = async (p) => p.replace(`${base}/../`, '/etc/');
-    await expect(validateOutputDir('/etc/passwd', { base, realpath })).rejects.toThrow();
+    // `evil` looks like a child of the base but resolves outside it.
+    const inside = join(base, 'evil');
+    const realpath = async (p) => (p === inside ? '/etc' : p);
+    await expect(validateOutputDir(inside, { base, realpath })).rejects.toThrow(/under/);
+    // A `..` segment must also be rejected after resolution.
+    await expect(
+      validateOutputDir(join(base, '..', 'elsewhere'), { base, realpath: async (p) => p }),
+    ).rejects.toThrow(/under/);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('rejects a path outside the base (path traversal)', async () => {
const base = await mkdtemp(join(tmpdir(), 'bd-out-'));
const realpath = async (p) => p.replace(`${base}/../`, '/etc/');
await expect(validateOutputDir('/etc/passwd', { base, realpath })).rejects.toThrow();
});
it('rejects a path outside the base (path traversal)', async () => {
const base = await mkdtemp(join(tmpdir(), 'bd-out-'));
// `evil` looks like a child of the base but resolves outside it.
const inside = join(base, 'evil');
const realpath = async (p) => (p === inside ? '/etc' : p);
await expect(validateOutputDir(inside, { base, realpath })).rejects.toThrow(/under/);
// A `..` segment must also be rejected after resolution.
await expect(
validateOutputDir(join(base, '..', 'elsewhere'), { base, realpath: async (p) => p }),
).rejects.toThrow(/under/);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/browser-downloader/tests/validate.test.js` around lines 128 - 132,
Update the test named “rejects a path outside the base (path traversal)” so its
mocked realpath resolves an input containing a parent-directory segment or
symlink-like path from within base to an outside location, such as /etc. Ensure
validateOutputDir receives that traversal path and still rejects it, rather than
testing an already absolute path unrelated to base.

- errors.js: Move TypeError cause recursion outside err.code guard so
  fetch timeouts with ETIMEDOUT cause are correctly classified (was
  unreachable inside the typeof-code branch, always returned network_error).
- downloader.js: Fix stealth plugin race — two concurrent download()
  calls could both see stealthApplied=false and register the plugin twice.
  Replace boolean flag with a stable promise (??= pattern).
- tier1-cdp.js: Normalize header keys to lowercase for array/object
  forms — Headers.entries() already lowercases, but array and plain-object
  header forms preserved caller casing, so capitalized Authorization/
  Referer headers were silently missed.
- tier2-dom.js: Fix page.evaluate argument binding — pass a single
  object {u, cap, timeoutMs} and destructure in the callback instead
  of passing three positional args (only the first was received).
- tier2-dom.js: Serialize BLOCK_RE as {source, flags} for page.evaluate
  instead of referencing the Node module-scope constant (was undefined).
- security_headers.py: Remove 'strict-dynamic' from script-src CSP —
  strict-dynamic overrides 'self' and blocks parser-inserted external
  scripts (sse.js, dashboard.js) that lack nonces.
- yt_dlp_service.py: Tighten subdomain-bypass detection — use
  hostname.endswith('.' + domain) instead of substring match which
  falsely flagged myyoutube.com.
- retry_scheduler.py: Add db.rollback() after Redis-accepted but
  DB-failed retry enqueue to keep the outbox row pending.
- presentation.html: Add nonce attribute to slide deck controller
  script so it executes under the nonce-based CSP.
- server.js: Use DownloaderError('timeout') for request timeouts so
  classifyError maps them correctly (was generic Error).
- validate.js: Extend IPv4 blocklist with CGNAT (100.64/10),
  192.0.0.0/24, 198.18/15, and multicast (>=224) ranges.
- CI: Align DB password between service and test env (test_pass
  instead of random hex the Postgres container can never see).
Signed-off-by: tomkabel <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/services/yt_dlp_service.py (1)

270-313: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep browser-downloader routes and _get_platform() aligned on TikTok and Instagram host aliases.

select_executor() returns "browser" for the vm.tiktok.com subdomain, but _TIKTOK_HOSTS has no vm.tiktok.com entry, so _get_platform() falls back to "youtube". Add vm.tiktok.com to yt_dlp_service.py or use the exact same alias set from worker/browser_executor.py so TikTok and Instagram URLs route to the same backend and get the same extractor/cookie behavior in both paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/services/yt_dlp_service.py` around lines 270 - 313, Update the platform
alias definitions used by _get_platform so TikTok and Instagram host recognition
matches the aliases handled by select_executor(), including vm.tiktok.com.
Prefer reusing the canonical alias set from browser_executor.py when available;
otherwise add the missing alias to _TIKTOK_HOSTS and verify Instagram aliases
remain aligned, preserving consistent backend, extractor, and cookie behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@app/services/yt_dlp_service.py`:
- Around line 270-313: Update the platform alias definitions used by
_get_platform so TikTok and Instagram host recognition matches the aliases
handled by select_executor(), including vm.tiktok.com. Prefer reusing the
canonical alias set from browser_executor.py when available; otherwise add the
missing alias to _TIKTOK_HOSTS and verify Instagram aliases remain aligned,
preserving consistent backend, extractor, and cookie behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1e006bc4-7aff-4dac-a230-9cf27400368b

📥 Commits

Reviewing files that changed from the base of the PR and between 6bc89f0 and 9dff171.

📒 Files selected for processing (12)
  • .github/workflows/fastapi-test.yml
  • .secrets.baseline
  • app/api/middleware/security_headers.py
  • app/services/yt_dlp_service.py
  • app/templates/slides/presentation.html
  • packages/browser-downloader/src/downloader.js
  • packages/browser-downloader/src/errors.js
  • packages/browser-downloader/src/server.js
  • packages/browser-downloader/src/tier1-cdp.js
  • packages/browser-downloader/src/tier2-dom.js
  • packages/browser-downloader/src/validate.js
  • worker/retry_scheduler.py
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: Test Docker Compose Stack
⚠️ CI failures not shown inline (2)

GitHub Actions: FastAPI REST API Tests / 5_Lint (Python + JS + CSS + Markdown + YAML).txt: feat(worker): Phase 2 hybrid routing — TikTok/Instagram/X via browser-downloader

Conclusion: failure

View job details

##[group]Run hatch run lint:check
 �[36;1mhatch run lint:check�[0m
 shell: /usr/bin/bash -e {0}
 env:
   PYTHON_VERSION: 3.12
   HATCH_VERSION: 1.16.5
   UV_PYTHON_INSTALL_DIR: /home/runner/work/_temp/uv-python-dir
   UV_PYTHON: 3.12
   UV_CACHE_DIR: /home/runner/work/_temp/setup-uv-cache
 ##[endgroup]
 Creating environment: lint
 Checking dependencies
 Syncing dependencies
 ##[error]app/api/routes/web/web_auth.py:93:11: PLR0917 Too many positional arguments (6 > 5)

GitHub Actions: FastAPI REST API Tests / Lint (Python + JS + CSS + Markdown + YAML): feat(worker): Phase 2 hybrid routing — TikTok/Instagram/X via browser-downloader

Conclusion: failure

View job details

##[group]Run hatch run lint:check
 �[36;1mhatch run lint:check�[0m
 shell: /usr/bin/bash -e {0}
 env:
   PYTHON_VERSION: 3.12
   HATCH_VERSION: 1.16.5
   UV_PYTHON_INSTALL_DIR: /home/runner/work/_temp/uv-python-dir
   UV_PYTHON: 3.12
   UV_CACHE_DIR: /home/runner/work/_temp/setup-uv-cache
 ##[endgroup]
 Creating environment: lint
 Checking dependencies
 Syncing dependencies
 ##[error]app/api/routes/web/web_auth.py:93:11: PLR0917 Too many positional arguments (6 > 5)
🧰 Additional context used
🪛 ast-grep (0.45.0)
packages/browser-downloader/src/tier2-dom.js

[warning] 121-121: Detects non-literal values in regular expressions
Context: new RegExp(source, flags)
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] 210-210: Detects non-literal values in regular expressions
Context: new RegExp(source, flags)
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)

packages/browser-downloader/src/tier1-cdp.js

[warning] 357-357: Detects non-literal values in regular expressions
Context: new RegExp(source, flags)
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)

🪛 Betterleaks (1.7.0)
.secrets.baseline

[high] 149-149: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 156-156: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🔇 Additional comments (17)
app/api/middleware/security_headers.py (1)

21-30: LGTM!

app/templates/slides/presentation.html (1)

12-12: LGTM!

Also applies to: 253-257, 289-289, 331-339, 532-532

.secrets.baseline (1)

149-149: LGTM!

Also applies to: 156-156

.github/workflows/fastapi-test.yml (2)

52-52: LGTM!

Also applies to: 79-79


167-167: LGTM!

Also applies to: 203-220

worker/retry_scheduler.py (1)

8-8: LGTM!

Also applies to: 58-58, 142-142, 161-161, 172-184

packages/browser-downloader/src/server.js (1)

88-119: Request timeout still does not stop the in-flight download.

The timeout rejection now correctly builds a DownloaderError('timeout', ...) (Line 102), so classifyError reports timeout instead of network_error. That part is fixed.

The underlying concurrency-leak issue from the previous review remains: when the timeout wins the Promise.race, finally only calls release() (Line 118). Nothing aborts the download() call, so the Chromium instance, CDP session, and any streamlink subprocess keep running past the freed slot. Repeated timeouts let live browsers exceed MAX_CONCURRENCY.

packages/browser-downloader/src/tier1-cdp.js (5)

169-171: requestHeaders still grows unbounded and is never evicted.

onRequestWillBeSent inserts an entry per request carrying any AUTH_HEADER_NAMES value (Lines 204-215). The read at Line 262 never deletes the entry, unlike candidates, which is deleted at Line 223. A page issuing many requests during the interception window accumulates unbounded entries, some holding cookie or authorization values in memory longer than needed.

Also applies to: 204-215, 249-265


203-215: Captured cookie/authorization headers still flow toward streamlink argv.

AUTH_HEADER_NAMES captures cookie and authorization (Lines 204-215), and onLoadingFinished merges them into authHeaders (Lines 261-265), returned to downloader.js and forwarded to downloadStream. Process arguments are world-readable through /proc/<pid>/cmdline, so any other process in the container can read a replayed session cookie or bearer token if downloadStream still passes these to streamlink as CLI arguments.

Also applies to: 249-271


47-47: LGTM!


95-124: LGTM!


353-368: LGTM!

packages/browser-downloader/src/tier2-dom.js (2)

160-182: Blob body is still serialized as a JSON number array.

Passing { u, cap, timeoutMs } as one object (Line 181) fixes the previous bug where cap and timeoutMs were undefined; the cl > cap and ab.byteLength > cap guards now work as intended.

The byte-transfer method itself is unchanged: Line 175 still returns Array.from(new Uint8Array(ab)), and Line 192 rebuilds it with Buffer.from(payload.bytes). Each byte crosses CDP as a JSON decimal number plus a separator, several times the media size, and both the page and Node process hold the expanded form at once. With the default bodyCap, a large blob can still produce a multi-gigabyte JSON transfer before the post-fetch size checks apply.

Also applies to: 187-196


119-125: LGTM!

Also applies to: 208-214

packages/browser-downloader/src/errors.js (1)

99-136: LGTM!

packages/browser-downloader/src/validate.js (1)

57-121: LGTM!

packages/browser-downloader/src/downloader.js (1)

13-13: LGTM!

Also applies to: 28-38, 64-64, 82-82, 97-100

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Note

Docstrings generation - SKIPPED
Skipped regeneration as there are no new commits. Docstrings already generated for this pull request at #143.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

CI fix PR created: #144

Follow #144 for fix progress and CI status. If CI still fails, re-run the fix-ci command to try again.

Note: Fixing CI failures is a beta feature and may encounter errors. Expect some limitations and changes as we gather feedback and continue to improve it.

Note: If your CI is configured to only run on PRs targeting specific branches (e.g., main), it may not trigger on the fix PR. You can merge the fix into your branch and CI will validate on the original PR.

1 failing check(s)
  • GitHub Actions: FastAPI REST API Tests / 5_Lint (Python + JS + CSS + Markdown + YAML).txt
5 file(s) modified
  • app/api/routes/web/web_auth.py
  • app/api/routes/web/web_auth_helpers.py
  • app/services/circuit_breaker.py
  • app/utils/validators.py
  • pyproject.toml
View agent analysis
Good, this pattern is already used elsewhere in the codebase. Let me check other route files for functions with many FastAPI-injected params to see if any already hit this limit and how they're handled.

This is a plain internal helper, not called by any framework — I can safely make params keyword-only.

Now update the call site in web_auth.py to use keyword arguments:

Now let's check the login_form call to `_login_success_response` and confirm my earlier edit didn't break call sites, plus check circuit_breaker.py and validators.py.

All call sites use keyword args already. Safe to make keyword-only.

Good, all keyword. Now check `worker/browser_executor.py:131` call site too, and check its own `RedisCoordinatedCircuitBreaker` subclass in the docs file (not real code, ignore).

Good, keyword args. Now handle validators.py and the test callback functions with `# noqa: PLR0917` since their signatures are fixed by external library contracts.

Now the test callback functions (4 occurrences, fixed signature required by SQLAlchemy's `before_cursor_execute` event):

I'll add `PLR0917` to the tests per-file-ignore in pyproject.toml since these are library-mandated callback signatures (SQLAlchemy event hooks), matching the existing pattern of test-specific ignores.

All confirmed and already fixed (web_auth.py, web_auth_helpers.py, circuit_breaker.py already excluded now since they use `*,`; validators.py has noqa; test files covered by per-file-ignore). This matches the exhaustive AST scan — no remaining violations.

The diff looks correct. Now let's double check `login_form`'s `*,` placement doesn't break with FastAPI (it's fine, FastAPI resolves by parameter name regardless of positional/keyword-only status). Let's verify final state of the edited functions.

All fixed files parse correctly.

## Summary

The lint failure (`PLR0917: Too many positional arguments`) was caused by a repo-wide pattern of functions with 6 positional parameters, pre-existing on `main` but apparently newly enforced by the current ruff toolchain (unrelated to the browser-downloader PR's stated intent). I ran a full AST-based scan of `app/`, `core/`, `worker/`, `tests/` to find every violation, not just the one shown in the truncated CI log excerpt, and fixed each by root cause:

- **`app/api/routes/web/web_auth.py`** (`login_form`, `change_password`) — added `*,` to make trailing FastAPI-injected params keyword-only (safe: FastAPI resolves by name, not position).
- **`app/api/routes/web/web_auth_helpers.py`** (`_change_password_response`) — same `*,` fix, plus updated its one call site in `web_auth.py` to pass keyword arguments.
- **`app/services/circuit_breaker.py`** (`CircuitBreaker.__init__`) — added `*,` after `name`; verified every call site already uses keyword arguments.
- **`app/utils/validators.py`** (`redirect_request`) — added `# noqa: PLR0917` since this overrides `urllib.request.HTTPRedirectHandler.redirect_request`, whose signature/positional-call convention is fixed by the stdlib and can't be changed.
- **`pyproject.toml`** — added `PLR0917` to the `tests/**` per-file-ignore group, since the 4 remaining violations are `count_download_job_selects` SQLAlchemy `before_cursor_execute` event callbacks with a library-mandated fixed positional signature (matches the existing pattern of test-specific rule exemptions in that section).

coderabbitai Bot added a commit that referenced this pull request Aug 2, 2026
Docstrings generation was requested by @tomkabel.

* #141 (comment)

The following files were modified:

* `app/api/dependencies/__init__.py`
* `app/api/docs.py`
* `app/api/middleware/prometheus.py`
* `app/api/middleware/request_body_size.py`
* `app/api/middleware/security_headers.py`
* `app/api/rate_limit_config.py`
* `app/api/routes/auth.py`
* `app/api/routes/chaos.py`
* `app/api/routes/health.py`
* `app/api/routes/sse.py`
* `app/api/routes/web/web_auth.py`
* `app/api/routes/web/web_auth_helpers.py`
* `app/api/routes/web/web_dashboard.py`
* `app/api/routes/web/web_downloads.py`
* `app/api/routes/web/web_helpers.py`
* `app/api/routes/web/web_settings.py`
* `app/api/startup.py`
* `app/auth.py`
* `app/main.py`
* `app/services/auth_service.py`
* `app/services/circuit_breaker.py`
* `app/services/download_service.py`
* `app/services/error_classifier.py`
* `app/services/job_factory.py`
* `app/services/outbox_service.py`
* `app/services/pubsub_service.py`
* `app/services/user_service.py`
* `app/services/yt_dlp_service.py`
* `app/utils/validators.py`
* `core/config.py`
* `core/database.py`
* `core/logging_config.py`
* `core/models/user.py`
* `core/queue.py`
* `core/redis_client.py`
* `core/utils/security.py`
* `entrypoint.sh`
* `packages/browser-downloader/src/concurrency.js`
* `packages/browser-downloader/src/downloader.js`
* `packages/browser-downloader/src/errors.js`
* `packages/browser-downloader/src/server.js`
* `packages/browser-downloader/src/streamlink-backend.js`
* `packages/browser-downloader/src/tier1-cdp.js`
* `packages/browser-downloader/src/tier2-dom.js`
* `packages/browser-downloader/src/validate.js`
* `tests/test_services/test_yt_dlp.py`
* `tests/test_worker/test_browser_executor.py`
* `tests/test_worker/test_outbox_recovery.py`
* `worker/browser_executor.py`
* `worker/dlq_manager.py`
* `worker/health.py`
* `worker/job_claimer.py`
* `worker/job_executor.py`
* `worker/main.py`
* `worker/outbox_relay.py`
* `worker/processor.py`
* `worker/retry_scheduler.py`
* `worker/zombie_sweeper.py`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

3 participants