From 2b975683d363c10c7c315da71a7dc3576b68846c Mon Sep 17 00:00:00 2001 From: "aiste.grigaliunaite@decodo.com" Date: Wed, 28 Oct 2026 10:51:05 +0200 Subject: [PATCH 1/2] feat: fix bugs and release --- pyproject.toml | 1 + src/decodo_crewai/_base.py | 10 ++++++- tests/test_tools.py | 53 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fad287f..765efba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ # ToolFailure first shipped in 1.15.9. "crewai>=1.15.9", "decodo-sdk>=2.4.1", + "httpx>=0.27.0", ] [project.optional-dependencies] diff --git a/src/decodo_crewai/_base.py b/src/decodo_crewai/_base.py index 6289416..e41d294 100644 --- a/src/decodo_crewai/_base.py +++ b/src/decodo_crewai/_base.py @@ -3,6 +3,7 @@ import os from typing import Any +import httpx from crewai.tools import BaseTool, EnvVar from crewai.tools.tool_failure import ToolFailure from decodo import ( @@ -44,7 +45,7 @@ class DecodoBaseTool(BaseTool): def __init__(self, api_key: str | None = None, **kwargs: Any) -> None: super().__init__(**kwargs) - api_key = api_key or os.environ.get(API_KEY_ENV_VAR) + api_key = (api_key or os.environ.get(API_KEY_ENV_VAR) or "").strip() if not api_key: raise ValueError(f"Pass api_key or set the {API_KEY_ENV_VAR} environment variable.") self._client = DecodoClient( @@ -74,6 +75,13 @@ def _scrape(self, params: ScrapeRequest) -> Any: code=str(e.status_code), retryable=e.status_code >= 500, ) + except (httpx.LocalProtocolError, UnicodeEncodeError): + return ToolFailure( + message=f"The request to Decodo could not be sent. Check {API_KEY_ENV_VAR} for invalid characters.", + code="invalid_request", + ) + except httpx.TransportError as e: + return ToolFailure(message=f"Could not reach Decodo: {e}", code="network_error", retryable=True) results = response.get("results") or [] if not results: diff --git a/tests/test_tools.py b/tests/test_tools.py index acb8d57..e95c865 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -47,6 +47,20 @@ def test_missing_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: DecodoWebScraperTool() +def test_blank_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DECODO_API_KEY", " \n") + with pytest.raises(ValueError, match="DECODO_API_KEY"): + DecodoWebScraperTool() + + +@pytest.mark.parametrize("api_key", ["padded-key\n", " padded-key "]) +def test_api_key_whitespace_is_stripped(fake_api: FakeApi, monkeypatch: pytest.MonkeyPatch, api_key: str) -> None: + monkeypatch.setenv("DECODO_API_KEY", api_key) + DecodoWebScraperTool().run(url="https://example.com") + DecodoWebScraperTool(api_key=api_key).run(url="https://example.com") + assert [call["headers"]["Authorization"] for call in fake_api.calls] == ["Bearer padded-key"] * 2 + + def test_api_key_argument_overrides_env(fake_api: FakeApi) -> None: DecodoWebScraperTool(api_key="explicit-key").run(url="https://example.com") assert fake_api.calls[0]["headers"]["Authorization"] == "Bearer explicit-key" @@ -123,6 +137,45 @@ def test_api_errors_become_tool_failures(fake_api: FakeApi, status_code: int, co assert result.retryable is retryable +def test_network_errors_become_retryable_tool_failures(monkeypatch: pytest.MonkeyPatch) -> None: + def refuse(*args: Any, **kwargs: Any) -> httpx.Response: + raise httpx.ConnectError("Connection refused") + + monkeypatch.setattr("decodo.http.httpx.request", refuse) + + for tool, kwargs in [ + (DecodoWebScraperTool(), {"url": "https://example.com"}), + (DecodoSearchTool(), {"query": "coffee"}), + (DecodoEcommerceTool(), {"store": "amazon", "query": "coffee"}), + (DecodoSERPTool(), {"query": "coffee"}), + ]: + result = tool.run(**kwargs) + assert isinstance(result, ToolFailure) + assert result.code == "network_error" + assert result.retryable is True + assert "Connection refused" in result.message + + +@pytest.mark.parametrize( + "error", + [ + httpx.LocalProtocolError("Illegal header value b'Bearer secret\\nkey'"), + UnicodeEncodeError("ascii", "Bearer secretkeyé", 16, 17, "ordinal not in range(128)"), + ], +) +def test_unsendable_api_key_is_not_echoed(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None: + def reject_header(*args: Any, **kwargs: Any) -> httpx.Response: + raise error + + monkeypatch.setattr("decodo.http.httpx.request", reject_header) + + result = DecodoWebScraperTool().run(url="https://example.com") + + assert isinstance(result, ToolFailure) + assert result.code == "invalid_request" + assert "secret" not in result.message + + def test_target_error_status_becomes_tool_failure(fake_api: FakeApi) -> None: fake_api.body = {"results": [{"content": "", "status_code": 404}]} From 4858e6c51e25954ae8b86d7d39b78915065436a6 Mon Sep 17 00:00:00 2001 From: "aiste.grigaliunaite@decodo.com" Date: Wed, 28 Oct 2026 11:35:54 +0200 Subject: [PATCH 2/2] chore: readme updates --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 72dccc4..11c874b 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ print(DecodoWebScraperTool().run(url="https://ip.decodo.com")) print(DecodoSearchTool().run(query="mechanical keyboard", source="amazon")) ``` -For a complete multi-agent example, see the [research crew](examples/research_crew): a researcher, an analyst and a writer that turn any topic into a cited report. +For a complete multi-agent example, see the [research crew](https://github.com/Decodo/crewAI/tree/main/examples/research_crew): a researcher, an analyst and a writer that turn any topic into a cited report. ## Tools @@ -127,7 +127,7 @@ Bing results are rendered in a headless browser and can take over a minute. ## Errors -When a request fails, for example because of an invalid API key, a rate limit or a page that returns `404`, the tools return a CrewAI `ToolFailure` instead of raising. The agent sees what went wrong and can retry or change course, and CrewAI records the failure according to the agent's `tool_failure_policy`. +When a request fails, for example because of an invalid API key, a rate limit, a page that returns `404` or a network error that keeps the request from reaching Decodo, the tools return a CrewAI `ToolFailure` instead of raising. The agent sees what went wrong and can retry or change course, and CrewAI records the failure according to the agent's `tool_failure_policy`. ## License