unique-search-proxy 2026.46.0.dev4__tar.gz → 2026.46.0.dev6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/PKG-INFO +5 -3
  2. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/README.md +2 -0
  3. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/pyproject.toml +3 -3
  4. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/crawl.py +19 -4
  5. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/service.py +42 -7
  6. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/pinned_egress.py +2 -0
  7. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/provider_response.py +10 -0
  8. unique_search_proxy-2026.46.0.dev6/unique_search_proxy_client/web/core/url_safety/gate.py +172 -0
  9. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/monitoring/metrics.py +14 -0
  10. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/request_limits.py +9 -0
  11. unique_search_proxy-2026.46.0.dev4/unique_search_proxy_client/web/core/url_safety/gate.py +0 -87
  12. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/__init__.py +0 -0
  13. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/__init__.py +0 -0
  14. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/__init__.py +0 -0
  15. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/health.py +0 -0
  16. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/__init__.py +0 -0
  17. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/agent_search.py +0 -0
  18. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/configuration.py +0 -0
  19. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/context_headers.py +0 -0
  20. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/openapi_examples.py +0 -0
  21. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/search.py +0 -0
  22. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/app.py +0 -0
  23. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/context.py +0 -0
  24. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/__init__.py +0 -0
  25. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/__init__.py +0 -0
  26. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/citation_instructions.j2 +0 -0
  27. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/cleanup.py +0 -0
  28. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/client.py +0 -0
  29. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/instructions.py +0 -0
  30. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/runner.py +0 -0
  31. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/service.py +0 -0
  32. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/factory.py +0 -0
  33. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/service_base.py +0 -0
  34. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/vertexai/client.py +0 -0
  35. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/vertexai/gemini.py +0 -0
  36. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/vertexai/redirects.py +0 -0
  37. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/vertexai/service.py +0 -0
  38. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/client/__init__.py +0 -0
  39. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/client/azure_transport.py +0 -0
  40. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/client/credentials.py +0 -0
  41. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/client/service.py +0 -0
  42. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawl_limiter.py +0 -0
  43. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/__init__.py +0 -0
  44. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/__init__.py +0 -0
  45. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/__init__.py +0 -0
  46. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/errors.py +0 -0
  47. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/html_markdown.py +0 -0
  48. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/__init__.py +0 -0
  49. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/html.py +0 -0
  50. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/pdf.py +0 -0
  51. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/plain_text.py +0 -0
  52. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/registry.py +0 -0
  53. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/settings.py +0 -0
  54. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/user_agent.py +0 -0
  55. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/factory.py +0 -0
  56. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/firecrawl/polling.py +0 -0
  57. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/firecrawl/request_body.py +0 -0
  58. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/firecrawl/service.py +0 -0
  59. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/jina/request_body.py +0 -0
  60. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/jina/service.py +0 -0
  61. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/tavily/request_body.py +0 -0
  62. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/tavily/service.py +0 -0
  63. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/providers.py +0 -0
  64. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/registry.py +0 -0
  65. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/__init__.py +0 -0
  66. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/brave/__init__.py +0 -0
  67. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/brave/pagination.py +0 -0
  68. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/brave/query_params.py +0 -0
  69. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/brave/service.py +0 -0
  70. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/custom_api/__init__.py +0 -0
  71. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/custom_api/service.py +0 -0
  72. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/descriptor.py +0 -0
  73. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/factory.py +0 -0
  74. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/google/__init__.py +0 -0
  75. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/google/pagination.py +0 -0
  76. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/google/query_params.py +0 -0
  77. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/google/service.py +0 -0
  78. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/pagination.py +0 -0
  79. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/perplexity/__init__.py +0 -0
  80. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/perplexity/request_body.py +0 -0
  81. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/perplexity/service.py +0 -0
  82. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/service_base.py +0 -0
  83. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/url_safety/__init__.py +0 -0
  84. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/error_handlers.py +0 -0
  85. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/__init__.py +0 -0
  86. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/__init__.py +0 -0
  87. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/introspect.py +0 -0
  88. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/schema.py +0 -0
  89. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/template.py +0 -0
  90. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/templates/provider_values_yaml.j2 +0 -0
  91. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/values_yaml.py +0 -0
  92. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/metadata.py +0 -0
  93. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/registry.py +0 -0
  94. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/url_safety_registration.py +0 -0
  95. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/logging_config.py +0 -0
  96. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/middleware/context.py +0 -0
  97. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/monitoring/__init__.py +0 -0
  98. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/monitoring/setup.py +0 -0
  99. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/__init__.py +0 -0
  100. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/common.py +0 -0
  101. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/crawl.py +0 -0
  102. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/search.py +0 -0
  103. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/types.py +0 -0
  104. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/__init__.py +0 -0
  105. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/app.py +0 -0
  106. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/base.py +0 -0
  107. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/client.py +0 -0
  108. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/monitoring.py +0 -0
  109. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/__init__.py +0 -0
  110. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/base.py +0 -0
  111. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/bing_agent.py +0 -0
  112. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/brave.py +0 -0
  113. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/firecrawl.py +0 -0
  114. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/google.py +0 -0
  115. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/jina.py +0 -0
  116. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/perplexity.py +0 -0
  117. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/tavily.py +0 -0
  118. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/vertexai_agent.py +0 -0
  119. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/secret_str.py +0 -0
  120. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/startup_report.py +0 -0
  121. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/usage.py +0 -0
  122. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/utils/__init__.py +0 -0
  123. {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/utils/url.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-search-proxy
3
- Version: 2026.46.0.dev4
3
+ Version: 2026.46.0.dev6
4
4
  Summary: Web Search Proxy implementation
5
5
  Author: ThePhilAz
6
6
  Author-email: ThePhilAz <rami.azouz@philico.com>
@@ -20,8 +20,8 @@ Requires-Dist: certifi>=2025.11.12,<2027
20
20
  Requires-Dist: google-genai>=1.73.0,<2
21
21
  Requires-Dist: google-auth>=2.43.0,<3
22
22
  Requires-Dist: jinja2>=3.1.6,<4
23
- Requires-Dist: unique-toolkit[monitoring]>=2026.46.0.dev4,<2026.46.0rc0
24
- Requires-Dist: unique-search-proxy-core>=2026.46.0.dev1,<2026.46.0rc0
23
+ Requires-Dist: unique-toolkit[monitoring]>=2026.46.0.dev10,<2026.46.0rc0
24
+ Requires-Dist: unique-search-proxy-core>=2026.46.0.dev2,<2026.46.0rc0
25
25
  Requires-Dist: openai>=1.105.0,<3
26
26
  Requires-Python: >=3.12
27
27
  Description-Content-Type: text/markdown
@@ -240,6 +240,8 @@ Per-URL outcomes — one URL failing does not fail the batch:
240
240
 
241
241
  Crawler discriminators: `Basic`, `Tavily`, `Jina`, `Firecrawl`.
242
242
 
243
+ **Timeout:** with `Basic`, URLs still loading just before `timeout` come back as per-URL `UPSTREAM_TIMEOUT` results next to the finished pages. A busy worker can send the response up to 5 s after `timeout`, so set your HTTP client timeout at least 5 s above it. The other crawlers fail the whole batch with a `504` at `timeout`.
244
+
243
245
  ### 5.5 Errors
244
246
 
245
247
  Structured envelope on all non-2xx responses:
@@ -212,6 +212,8 @@ Per-URL outcomes — one URL failing does not fail the batch:
212
212
 
213
213
  Crawler discriminators: `Basic`, `Tavily`, `Jina`, `Firecrawl`.
214
214
 
215
+ **Timeout:** with `Basic`, URLs still loading just before `timeout` come back as per-URL `UPSTREAM_TIMEOUT` results next to the finished pages. A busy worker can send the response up to 5 s after `timeout`, so set your HTTP client timeout at least 5 s above it. The other crawlers fail the whole batch with a `504` at `timeout`.
216
+
215
217
  ### 5.5 Errors
216
218
 
217
219
  Structured envelope on all non-2xx responses:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique-search-proxy"
3
- version = "2026.46.0.dev4"
3
+ version = "2026.46.0.dev6"
4
4
  description = "Web Search Proxy implementation"
5
5
  authors = [{ name = "ThePhilAz", email = "rami.azouz@philico.com" }]
6
6
  readme = "README.md"
@@ -22,8 +22,8 @@ dependencies = [
22
22
  "google-genai>=1.73.0,<2",
23
23
  "google-auth>=2.43.0,<3",
24
24
  "jinja2>=3.1.6,<4",
25
- "unique-toolkit[monitoring]>=2026.46.0.dev4,<2026.46.0rc0",
26
- "unique-search-proxy-core>=2026.46.0.dev1,<2026.46.0rc0",
25
+ "unique-toolkit[monitoring]>=2026.46.0.dev10,<2026.46.0rc0",
26
+ "unique-search-proxy-core>=2026.46.0.dev2,<2026.46.0rc0",
27
27
  "openai>=1.105.0,<3",
28
28
  ]
29
29
 
@@ -45,6 +45,13 @@ from unique_search_proxy_client.web.usage import record_request_usage
45
45
  router = APIRouter(tags=["crawl"])
46
46
  _LOGGER = logging.getLogger(__name__)
47
47
 
48
+ # Callers often use the crawl timeout as their HTTP client timeout. Finishing
49
+ # this much earlier lets the response reach them before they give up.
50
+ _RESPONSE_HEADROOM_SECONDS = 0.5
51
+ # Only catches work that ignores the deadline. A busy worker that cancels pages
52
+ # late must delay the response, not drop the pages that finished.
53
+ _SAFETY_NET_GRACE_SECONDS = 5.0
54
+
48
55
 
49
56
  def _crawl_request_context(exc: ProxyError, *, crawler_id: str) -> ProxyError:
50
57
  return attach_request_context(
@@ -108,12 +115,18 @@ async def crawl(
108
115
 
109
116
  try:
110
117
  _enforce_url_limits(body.urls)
111
- async with get_crawl_limiter(request.app).slot(), asyncio.timeout(timeout):
118
+ async with (
119
+ get_crawl_limiter(request.app).slot(),
120
+ asyncio.timeout(timeout + _SAFETY_NET_GRACE_SECONDS),
121
+ ):
122
+ request_deadline = asyncio.get_running_loop().time() + timeout
123
+ deadline = request_deadline - _RESPONSE_HEADROOM_SECONDS
112
124
  registry = get_http_client_registry(request.app)
113
125
  client = await registry.client_for(get_request_context())
114
126
  gate = await apply_url_safety_gate(
115
127
  body.urls,
116
128
  redirect_http_client=client,
129
+ deadline=deadline,
117
130
  )
118
131
  units = len(gate.allowed_targets)
119
132
  if not gate.allowed_targets:
@@ -126,7 +139,7 @@ async def crawl(
126
139
  )
127
140
  merged_results = merge_crawl_results(
128
141
  body.urls,
129
- blocked_by_index=gate.blocked_by_index,
142
+ uncrawled_by_index=gate.uncrawled_by_index,
130
143
  crawler_results=[],
131
144
  )
132
145
  record_crawl_url_outcomes(
@@ -157,9 +170,11 @@ async def crawl(
157
170
  crawler_results = await crawler.crawl_pinned(
158
171
  crawl_body,
159
172
  gate.allowed_targets,
173
+ deadline=deadline,
160
174
  )
161
175
  else:
162
- crawler_results = await crawler.crawl(crawl_body)
176
+ async with asyncio.timeout_at(request_deadline):
177
+ crawler_results = await crawler.crawl(crawl_body)
163
178
  succeeded = True
164
179
  except TimeoutError as exc:
165
180
  record_crawl_error(
@@ -217,7 +232,7 @@ async def crawl(
217
232
  record_crawl_success(crawler_id, len(body.urls), duration)
218
233
  merged_results = merge_crawl_results(
219
234
  body.urls,
220
- blocked_by_index=gate.blocked_by_index,
235
+ uncrawled_by_index=gate.uncrawled_by_index,
221
236
  crawler_results=crawler_results,
222
237
  )
223
238
  record_crawl_url_outcomes(crawler_id, _url_outcomes(merged_results))
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ from collections.abc import Coroutine
6
+ from typing import Any
5
7
 
6
8
  import httpx
7
9
  from httpx import AsyncClient, Timeout
@@ -33,6 +35,7 @@ from unique_search_proxy_client.web.core.crawlers.basic.user_agent import (
33
35
  random_user_agent,
34
36
  )
35
37
  from unique_search_proxy_client.web.core.provider_response import (
38
+ crawl_deadline_missed,
36
39
  crawl_forbidden_target,
37
40
  crawl_upstream_error,
38
41
  transport_error_raw,
@@ -97,7 +100,10 @@ class BasicCrawlerService(BaseCrawler[BasicCrawlRequest]):
97
100
  self,
98
101
  request: BasicCrawlRequest, # type: ignore[valid-type]
99
102
  allowed_targets: list[AllowedCrawlTarget],
103
+ *,
104
+ deadline: float | None = None,
100
105
  ) -> list[CrawlUrlResult]:
106
+ """Crawl targets in order; one still running at ``deadline`` fails on its own."""
101
107
  client = self._http_client
102
108
  if client is None:
103
109
  raise RuntimeError("HTTP client is required for Basic crawler")
@@ -115,9 +121,9 @@ class BasicCrawlerService(BaseCrawler[BasicCrawlRequest]):
115
121
  ),
116
122
  )
117
123
  budget = ByteBudget(request_limits_settings.max_crawl_response_bytes)
118
- return list(
119
- await asyncio.gather(
120
- *[
124
+ results = await asyncio.gather(
125
+ *[
126
+ self._crawl_one_before_deadline(
121
127
  self._crawl_one(
122
128
  client,
123
129
  allowed_target.display_url,
@@ -126,11 +132,40 @@ class BasicCrawlerService(BaseCrawler[BasicCrawlRequest]):
126
132
  semaphore=semaphore,
127
133
  budget=budget,
128
134
  content_type_handlers=request.content_types.to_handlers(),
129
- )
130
- for allowed_target in allowed_targets
131
- ],
132
- ),
135
+ ),
136
+ deadline=deadline,
137
+ )
138
+ for allowed_target in allowed_targets
139
+ ],
133
140
  )
141
+ missed = sum(result is None for result in results)
142
+ if missed:
143
+ _LOGGER.warning(
144
+ "Basic crawl missed the request deadline timeout=%ss pages=%d",
145
+ timeout,
146
+ missed,
147
+ )
148
+ return [
149
+ result
150
+ if result is not None
151
+ else crawl_deadline_missed(
152
+ allowed_target.display_url,
153
+ f"Crawl did not finish before the {timeout}s request deadline",
154
+ )
155
+ for allowed_target, result in zip(allowed_targets, results, strict=True)
156
+ ]
157
+
158
+ async def _crawl_one_before_deadline(
159
+ self,
160
+ crawl: Coroutine[Any, Any, CrawlUrlResult],
161
+ *,
162
+ deadline: float | None,
163
+ ) -> CrawlUrlResult | None:
164
+ try:
165
+ async with asyncio.timeout_at(deadline):
166
+ return await crawl
167
+ except TimeoutError:
168
+ return None
134
169
 
135
170
  async def _crawl_one(
136
171
  self,
@@ -16,6 +16,8 @@ class PinnedEgressCrawler(Protocol):
16
16
  self,
17
17
  request: BaseModel,
18
18
  allowed_targets: list[AllowedCrawlTarget],
19
+ *,
20
+ deadline: float | None = None,
19
21
  ) -> list[CrawlUrlResult]: ...
20
22
 
21
23
 
@@ -91,6 +91,16 @@ def crawl_forbidden_target(url: str, message: str) -> CrawlUrlResult:
91
91
  )
92
92
 
93
93
 
94
+ def crawl_deadline_missed(url: str, message: str) -> CrawlUrlResult:
95
+ """Build a per-URL crawl failure for a URL that missed the request deadline."""
96
+ return crawl_upstream_error(
97
+ url,
98
+ message,
99
+ code=ProxyErrorCode.UPSTREAM_TIMEOUT.value,
100
+ content_type=None,
101
+ )
102
+
103
+
94
104
  def raise_for_upstream_response(
95
105
  response: httpx.Response,
96
106
  *,
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING
8
+
9
+ from unique_search_proxy_core.schema import CrawlUrlResult
10
+ from unique_search_proxy_core.url_safety import (
11
+ ResolvedCrawlTarget,
12
+ UrlSafetyOutcome,
13
+ UrlSafetyService,
14
+ )
15
+
16
+ from unique_search_proxy_client.web.core.provider_response import (
17
+ crawl_deadline_missed,
18
+ crawl_forbidden_target,
19
+ )
20
+ from unique_search_proxy_client.web.monitoring.metrics import (
21
+ record_crawl_blocked,
22
+ record_url_safety_check,
23
+ )
24
+ from unique_search_proxy_client.web.settings.request_limits import (
25
+ request_limits_settings,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from httpx import AsyncClient
30
+
31
+ _LOGGER = logging.getLogger(__name__)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class AllowedCrawlTarget:
36
+ """User-facing URL paired with the validated resolution for pinned egress."""
37
+
38
+ display_url: str
39
+ resolved: ResolvedCrawlTarget
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class UrlSafetyGateResult:
44
+ allowed_targets: list[AllowedCrawlTarget]
45
+ blocked_by_index: dict[int, CrawlUrlResult]
46
+ timed_out_by_index: dict[int, CrawlUrlResult] = field(default_factory=dict)
47
+
48
+ @property
49
+ def uncrawled_by_index(self) -> dict[int, CrawlUrlResult]:
50
+ return {**self.blocked_by_index, **self.timed_out_by_index}
51
+
52
+
53
+ async def apply_url_safety_gate(
54
+ urls: list[str],
55
+ *,
56
+ redirect_http_client: AsyncClient,
57
+ deadline: float | None = None,
58
+ ) -> UrlSafetyGateResult:
59
+ """Partition crawl URLs into allowed, blocked and timed-out targets."""
60
+ check_slots = asyncio.Semaphore(request_limits_settings.max_concurrent_url_checks)
61
+ outcomes = await asyncio.gather(
62
+ *[
63
+ _validate_before_deadline(
64
+ url,
65
+ redirect_http_client=redirect_http_client,
66
+ check_slots=check_slots,
67
+ deadline=deadline,
68
+ )
69
+ for url in urls
70
+ ],
71
+ )
72
+
73
+ allowed_targets: list[AllowedCrawlTarget] = []
74
+ blocked_by_index: dict[int, CrawlUrlResult] = {}
75
+ timed_out_by_index: dict[int, CrawlUrlResult] = {}
76
+
77
+ for index, (url, outcome) in enumerate(zip(urls, outcomes, strict=True)):
78
+ if outcome is None:
79
+ timed_out_by_index[index] = crawl_deadline_missed(
80
+ url.strip(),
81
+ "URL safety check did not finish before the crawl deadline",
82
+ )
83
+ continue
84
+
85
+ if outcome.blocked is not None:
86
+ record_crawl_blocked(outcome.blocked.category)
87
+ blocked_by_index[index] = crawl_forbidden_target(
88
+ outcome.url.strip(),
89
+ outcome.blocked.reason,
90
+ )
91
+ continue
92
+
93
+ if outcome.resolved is None:
94
+ msg = "URL safety allowed a crawl target without resolved metadata"
95
+ raise RuntimeError(msg)
96
+
97
+ allowed_targets.append(
98
+ AllowedCrawlTarget(
99
+ display_url=outcome.url.strip(),
100
+ resolved=outcome.resolved,
101
+ ),
102
+ )
103
+
104
+ if timed_out_by_index:
105
+ _LOGGER.warning(
106
+ "URL safety checks missed the crawl deadline unchecked=%d",
107
+ len(timed_out_by_index),
108
+ )
109
+
110
+ return UrlSafetyGateResult(
111
+ allowed_targets=allowed_targets,
112
+ blocked_by_index=blocked_by_index,
113
+ timed_out_by_index=timed_out_by_index,
114
+ )
115
+
116
+
117
+ async def _validate_before_deadline(
118
+ url: str,
119
+ *,
120
+ redirect_http_client: AsyncClient,
121
+ check_slots: asyncio.Semaphore,
122
+ deadline: float | None,
123
+ ) -> UrlSafetyOutcome | None:
124
+ """Return the URL's safety outcome, or ``None`` when the deadline passes first."""
125
+ try:
126
+ async with asyncio.timeout_at(deadline), check_slots:
127
+ return await _validate_and_record(
128
+ url,
129
+ redirect_http_client=redirect_http_client,
130
+ )
131
+ except TimeoutError:
132
+ return None
133
+
134
+
135
+ async def _validate_and_record(
136
+ url: str,
137
+ *,
138
+ redirect_http_client: AsyncClient,
139
+ ) -> UrlSafetyOutcome:
140
+ """Validate one URL and record how long the check took, by outcome."""
141
+ started = time.perf_counter()
142
+ outcome_label = "unfinished"
143
+ try:
144
+ [outcome] = await UrlSafetyService.validate_urls_individually(
145
+ [url],
146
+ redirect_http_client=redirect_http_client,
147
+ )
148
+ outcome_label = "allowed" if outcome.blocked is None else "blocked"
149
+ return outcome
150
+ finally:
151
+ record_url_safety_check(outcome_label, time.perf_counter() - started)
152
+
153
+
154
+ def merge_crawl_results(
155
+ urls: list[str],
156
+ *,
157
+ uncrawled_by_index: dict[int, CrawlUrlResult],
158
+ crawler_results: list[CrawlUrlResult],
159
+ ) -> list[CrawlUrlResult]:
160
+ """Merge the gate's results for uncrawled URLs with crawler outcomes in order."""
161
+ merged: list[CrawlUrlResult] = []
162
+ crawler_index = 0
163
+ for index, _url in enumerate(urls):
164
+ uncrawled = uncrawled_by_index.get(index)
165
+ if uncrawled is not None:
166
+ merged.append(uncrawled)
167
+ continue
168
+
169
+ merged.append(crawler_results[crawler_index])
170
+ crawler_index += 1
171
+
172
+ return merged
@@ -40,6 +40,9 @@ def _exponential_buckets(start: float, ceiling: float, count: int) -> tuple[floa
40
40
  _SEARCH_LATENCY_BUCKETS = _linear_buckets(ceiling=5.0, steps=20)
41
41
  _CRAWL_LATENCY_BUCKETS = _linear_buckets(ceiling=60.0, steps=20)
42
42
  _AGENT_SEARCH_LATENCY_BUCKETS = _linear_buckets(ceiling=120.0, steps=20)
43
+ # Half-second steps up to 10s, so checks that hit the per-hop redirect timeout
44
+ # (5s by default) show up as their own bucket.
45
+ _URL_SAFETY_CHECK_LATENCY_BUCKETS = _linear_buckets(ceiling=10.0, steps=20)
43
46
 
44
47
  # One histogram covers every endpoint, from sub-second /v1/search to /v1/agent-search
45
48
  # runs approaching 120s — exponential buckets keep resolution useful at both ends.
@@ -94,6 +97,12 @@ crawl_blocked_total = m.counter(
94
97
  "Crawl targets blocked by URL safety policy",
95
98
  ["reason_category"],
96
99
  )
100
+ url_safety_check_duration_seconds = m.histogram(
101
+ "url_safety_check_duration_seconds",
102
+ "Time to check one crawl URL (redirect probes and DNS), by outcome",
103
+ ["outcome"],
104
+ buckets=_URL_SAFETY_CHECK_LATENCY_BUCKETS,
105
+ )
97
106
  url_safety_application_checks_enabled = m.gauge(
98
107
  "url_safety_application_checks_enabled",
99
108
  "Whether application-level URL safety checks are enabled (1) or delegated "
@@ -182,6 +191,11 @@ def record_crawl_blocked(reason_category: str, count: int = 1) -> None:
182
191
  crawl_blocked_total.labels(reason_category=reason_category).inc(count)
183
192
 
184
193
 
194
+ def record_url_safety_check(outcome: str, duration_seconds: float) -> None:
195
+ """Record one URL check; ``outcome`` is allowed, blocked or unfinished."""
196
+ url_safety_check_duration_seconds.labels(outcome=outcome).observe(duration_seconds)
197
+
198
+
185
199
  def set_url_safety_application_checks_enabled(enabled: bool) -> None:
186
200
  url_safety_application_checks_enabled.set(int(enabled))
187
201
 
@@ -23,6 +23,7 @@ REQUEST_LIMITS_ENV_PREFIX = "REQUEST_LIMITS_"
23
23
  "max_url_length",
24
24
  "max_concurrent_fetches",
25
25
  "max_concurrent_crawls",
26
+ "max_concurrent_url_checks",
26
27
  ],
27
28
  "timeouts": ["max_timeout_seconds"],
28
29
  },
@@ -82,6 +83,14 @@ class RequestLimitsSettings(BaseSettings):
82
83
  "this get a 429 instead of queueing."
83
84
  ),
84
85
  )
86
+ max_concurrent_url_checks: int = Field(
87
+ default=3,
88
+ ge=1,
89
+ description=(
90
+ "URL safety checks one crawl request runs at once before fetching. "
91
+ "Each check probes the URL's redirects with HEAD requests."
92
+ ),
93
+ )
85
94
  max_timeout_seconds: int = Field(
86
95
  default=120,
87
96
  ge=1,
@@ -1,87 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
- from typing import TYPE_CHECKING
5
-
6
- from unique_search_proxy_core.schema import CrawlUrlResult
7
- from unique_search_proxy_core.url_safety import ResolvedCrawlTarget, UrlSafetyService
8
-
9
- from unique_search_proxy_client.web.core.provider_response import crawl_forbidden_target
10
- from unique_search_proxy_client.web.monitoring.metrics import record_crawl_blocked
11
-
12
- if TYPE_CHECKING:
13
- from httpx import AsyncClient
14
-
15
-
16
- @dataclass(frozen=True)
17
- class AllowedCrawlTarget:
18
- """User-facing URL paired with the validated resolution for pinned egress."""
19
-
20
- display_url: str
21
- resolved: ResolvedCrawlTarget
22
-
23
-
24
- @dataclass(frozen=True)
25
- class UrlSafetyGateResult:
26
- allowed_targets: list[AllowedCrawlTarget]
27
- blocked_by_index: dict[int, CrawlUrlResult]
28
-
29
-
30
- async def apply_url_safety_gate(
31
- urls: list[str],
32
- *,
33
- redirect_http_client: AsyncClient,
34
- ) -> UrlSafetyGateResult:
35
- """Validate crawl URLs and partition them into allowed vs blocked targets."""
36
- outcomes = await UrlSafetyService.validate_urls_individually(
37
- urls,
38
- redirect_http_client=redirect_http_client,
39
- )
40
- allowed_targets: list[AllowedCrawlTarget] = []
41
- blocked_by_index: dict[int, CrawlUrlResult] = {}
42
-
43
- for index, outcome in enumerate(outcomes):
44
- if outcome.blocked is not None:
45
- record_crawl_blocked(outcome.blocked.category)
46
- blocked_by_index[index] = crawl_forbidden_target(
47
- outcome.url.strip(),
48
- outcome.blocked.reason,
49
- )
50
- continue
51
-
52
- if outcome.resolved is None:
53
- msg = "URL safety allowed a crawl target without resolved metadata"
54
- raise RuntimeError(msg)
55
-
56
- allowed_targets.append(
57
- AllowedCrawlTarget(
58
- display_url=outcome.url.strip(),
59
- resolved=outcome.resolved,
60
- ),
61
- )
62
-
63
- return UrlSafetyGateResult(
64
- allowed_targets=allowed_targets,
65
- blocked_by_index=blocked_by_index,
66
- )
67
-
68
-
69
- def merge_crawl_results(
70
- urls: list[str],
71
- *,
72
- blocked_by_index: dict[int, CrawlUrlResult],
73
- crawler_results: list[CrawlUrlResult],
74
- ) -> list[CrawlUrlResult]:
75
- """Merge per-URL blocked results with crawler outcomes in request order."""
76
- merged: list[CrawlUrlResult] = []
77
- crawler_index = 0
78
- for index, _url in enumerate(urls):
79
- blocked = blocked_by_index.get(index)
80
- if blocked is not None:
81
- merged.append(blocked)
82
- continue
83
-
84
- merged.append(crawler_results[crawler_index])
85
- crawler_index += 1
86
-
87
- return merged