Skip to content

Commit 70ae66a

Browse files
authored
Merge pull request #88 from WorkflowAI/guillaume/new-missing-token-user-message
New missing token user message
2 parents b946d78 + 27ee41e commit 70ae66a

File tree

10 files changed

+120
-148
lines changed

10 files changed

+120
-148
lines changed

.github/workflows/quality.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,4 @@ jobs:
4747
poetry install --all-extras
4848
4949
- name: Run tests
50-
run: poetry run pytest --ignore=tests/e2e
50+
run: poetry run pytest --ignore=tests/e2e --ignore-glob="examples/*.py"

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ repos:
2222
types: [file, python]
2323
- id: pytest
2424
name: testing (pytest)
25-
entry: pytest . --ignore tests/e2e
25+
entry: make test
2626
language: system
2727
pass_filenames: false

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ lint:
2626

2727
.PHONY: test
2828
test:
29-
pytest --ignore=tests/e2e
29+
pytest --ignore=tests/e2e --ignore-glob="examples/*.py"
3030

3131
.PHONY: lock
3232
lock:

examples/01_basic_agent.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,12 @@ async def main():
8080
# Example 1: Basic usage with Paris
8181
print("\nExample 1: Basic usage with Paris")
8282
print("-" * 50)
83-
run = await get_capital_info.run(CityInput(city="Paris"))
84-
print(run)
83+
try:
84+
run = await get_capital_info.run(CityInput(city="Paris"))
85+
print(run)
86+
except workflowai.WorkflowAIError as e:
87+
print(e)
88+
return
8589

8690
# Example 2: Using Tokyo
8791
print("\nExample 2: Using Tokyo")

examples/18_flight_info_extraction.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919
class EmailInput(BaseModel):
2020
"""Raw email content containing flight booking details.
2121
This could be a confirmation email, itinerary update, or e-ticket from any airline."""
22+
2223
email_content: str
2324

2425

2526
class FlightInfo(BaseModel):
2627
"""Model for extracted flight information."""
28+
2729
class Status(str, Enum):
2830
"""Possible statuses for a flight booking."""
31+
2932
CONFIRMED = "Confirmed"
3033
PENDING = "Pending"
3134
CANCELLED = "Cancelled"
@@ -41,6 +44,7 @@ class Status(str, Enum):
4144
arrival: datetime
4245
status: Status
4346

47+
4448
@workflowai.agent(
4549
id="flight-info-extractor",
4650
model=Model.GEMINI_2_0_FLASH_LATEST,

pytest.ini

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[pytest]
2-
python_files = *_test.py
3-
python_functions = test_*
4-
testpaths = tests workflowai
2+
python_files = *_test.py examples/*.py
3+
python_functions = test_* main
4+
testpaths = tests workflowai examples
55
filterwarnings =
66
ignore::pydantic.warnings.PydanticDeprecatedSince20
77
asyncio_mode = auto

workflowai/core/client/_api.py

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydantic import BaseModel, TypeAdapter, ValidationError
77

88
from workflowai.core._logger import logger
9-
from workflowai.core.domain.errors import BaseError, ErrorResponse, WorkflowAIError
9+
from workflowai.core.domain.errors import BaseError, WorkflowAIError
1010

1111
# A type for return values
1212
_R = TypeVar("_R")
@@ -103,26 +103,6 @@ async def delete(self, path: str) -> None:
103103
response = await client.delete(path)
104104
await self.raise_for_status(response)
105105

106-
def _extract_error(
107-
self,
108-
response: httpx.Response,
109-
data: Union[bytes, str],
110-
exception: Optional[Exception] = None,
111-
) -> WorkflowAIError:
112-
try:
113-
res = ErrorResponse.model_validate_json(data)
114-
return WorkflowAIError(error=res.error, run_id=res.id, response=response, partial_output=res.task_output)
115-
except ValidationError:
116-
raise WorkflowAIError(
117-
error=BaseError(
118-
message="Unknown error" if exception is None else str(exception),
119-
details={
120-
"raw": str(data),
121-
},
122-
),
123-
response=response,
124-
) from exception
125-
126106
async def _wrap_sse(self, raw: AsyncIterator[bytes], termination_chars: bytes = b"\n\n"):
127107
data = b""
128108
in_data = False
@@ -181,7 +161,7 @@ async def stream(
181161
try:
182162
yield returns.model_validate_json(chunk)
183163
except ValidationError as e:
184-
raise self._extract_error(response, chunk, e) from None
164+
raise WorkflowAIError.from_response(response, chunk) from e
185165

186166
async def raise_for_status(self, response: httpx.Response):
187167
if response.status_code < 200 or response.status_code >= 300:

workflowai/core/client/_api_test.py

Lines changed: 0 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -9,83 +9,6 @@
99
from workflowai.core.domain.errors import WorkflowAIError
1010

1111

12-
class TestAPIClientExtractError:
13-
def test_extract_error(self):
14-
client = APIClient(url="test_url", api_key="test_api_key")
15-
16-
# Test valid JSON error response
17-
response = httpx.Response(
18-
status_code=400,
19-
json={
20-
"error": {
21-
"message": "Test error message",
22-
"details": {"key": "value"},
23-
},
24-
"id": "test_task_123",
25-
},
26-
)
27-
28-
error = client._extract_error(response, response.content) # pyright:ignore[reportPrivateUsage]
29-
assert isinstance(error, WorkflowAIError)
30-
assert error.error.message == "Test error message"
31-
assert error.error.details == {"key": "value"}
32-
assert error.run_id == "test_task_123"
33-
assert error.response == response
34-
35-
def test_extract_partial_output(self):
36-
client = APIClient(url="test_url", api_key="test_api_key")
37-
38-
# Test valid JSON error response
39-
response = httpx.Response(
40-
status_code=400,
41-
json={
42-
"error": {
43-
"message": "Test error message",
44-
"details": {"key": "value"},
45-
},
46-
"id": "test_task_123",
47-
"task_output": {"key": "value"},
48-
},
49-
)
50-
51-
error = client._extract_error(response, response.content) # pyright:ignore[reportPrivateUsage]
52-
assert isinstance(error, WorkflowAIError)
53-
assert error.error.message == "Test error message"
54-
assert error.error.details == {"key": "value"}
55-
assert error.run_id == "test_task_123"
56-
assert error.partial_output == {"key": "value"}
57-
assert error.response == response
58-
59-
def test_extract_error_invalid_json(self):
60-
client = APIClient(url="test_url", api_key="test_api_key")
61-
62-
# Test invalid JSON response
63-
invalid_data = b"Invalid JSON data"
64-
response = httpx.Response(status_code=400, content=invalid_data)
65-
66-
with pytest.raises(WorkflowAIError) as e:
67-
client._extract_error(response, invalid_data) # pyright:ignore[reportPrivateUsage]
68-
assert isinstance(e.value, WorkflowAIError)
69-
assert e.value.error.message == "Unknown error"
70-
assert e.value.error.details == {"raw": "b'Invalid JSON data'"}
71-
assert e.value.response == response
72-
73-
def test_extract_error_with_custom_error(self):
74-
client = APIClient(url="test_url", api_key="test_api_key")
75-
76-
# Test with provided exception
77-
invalid_data = "{'detail': 'Not Found'}"
78-
response = httpx.Response(status_code=404, content=invalid_data)
79-
exception = ValueError("Custom error")
80-
81-
with pytest.raises(WorkflowAIError) as e:
82-
client._extract_error(response, invalid_data, exception) # pyright:ignore[reportPrivateUsage]
83-
assert isinstance(e.value, WorkflowAIError)
84-
assert e.value.error.message == "Custom error"
85-
assert e.value.error.details == {"raw": "{'detail': 'Not Found'}"}
86-
assert e.value.response == response
87-
88-
8912
@pytest.fixture
9013
def client() -> APIClient:
9114
return APIClient(url="https://blabla.com", api_key="test_api_key")

workflowai/core/domain/errors.py

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from email.utils import parsedate_to_datetime
2-
from json import JSONDecodeError
32
from time import time
43
from typing import Any, Literal, Optional, Union
54

65
from httpx import Response
7-
from pydantic import BaseModel
6+
from pydantic import BaseModel, ValidationError
7+
from typing_extensions import override
88

99
from workflowai.core.domain import tool_call
1010

@@ -79,7 +79,7 @@
7979

8080
class BaseError(BaseModel):
8181
details: Optional[dict[str, Any]] = None
82-
message: str
82+
message: str = "Unknown error"
8383
status_code: Optional[int] = None
8484
code: Optional[ErrorCode] = None
8585

@@ -127,41 +127,29 @@ def __str__(self):
127127
return f"WorkflowAIError : [{self.error.code}] ({self.error.status_code}): [{self.error.message}]"
128128

129129
@classmethod
130-
def error_cls(cls, code: str):
130+
def error_cls(cls, status_code: int, code: Optional[str] = None):
131+
if status_code == 401:
132+
return InvalidAPIKeyError
131133
if code == "invalid_generation" or code == "failed_generation" or code == "agent_run_failed":
132134
return InvalidGenerationError
133135
return cls
134136

135137
@classmethod
136-
def from_response(cls, response: Response):
138+
def from_response(cls, response: Response, data: Union[bytes, str, None] = None):
137139
try:
138-
response_json = response.json()
139-
r_error = response_json.get("error", {})
140-
error_message = response_json.get("detail", {}) or r_error.get("message", "Unknown Error")
141-
details = r_error.get("details", {})
142-
error_code = r_error.get("code", "unknown_error")
143-
status_code = response.status_code
144-
run_id = response_json.get("id", None)
145-
partial_output = response_json.get("task_output", None)
146-
except JSONDecodeError:
147-
error_message = "Unknown error"
148-
details = {"raw": response.content.decode()}
149-
error_code = "unknown_error"
150-
status_code = response.status_code
151-
run_id = None
152-
partial_output = None
153-
154-
return cls.error_cls(error_code)(
155-
response=response,
156-
error=BaseError(
157-
message=error_message,
158-
details=details,
159-
status_code=status_code,
160-
code=error_code,
161-
),
162-
run_id=run_id,
163-
partial_output=partial_output,
164-
)
140+
res = ErrorResponse.model_validate_json(data or response.content)
141+
error_cls = cls.error_cls(response.status_code, res.error.code)
142+
return error_cls(error=res.error, run_id=res.id, response=response, partial_output=res.task_output)
143+
except ValidationError:
144+
return cls.error_cls(response.status_code)(
145+
error=BaseError(
146+
message="Unknown error",
147+
details={
148+
"raw": str(data),
149+
},
150+
),
151+
response=response,
152+
)
165153

166154
@property
167155
def retry_after_delay_seconds(self) -> Optional[float]:
@@ -194,3 +182,17 @@ class InvalidGenerationError(WorkflowAIError): ...
194182

195183

196184
class MaxTurnsReachedError(WorkflowAIError): ...
185+
186+
187+
class InvalidAPIKeyError(WorkflowAIError):
188+
@property
189+
@override
190+
def message(self) -> str:
191+
return (
192+
"❌ Your API key is invalid. Please double-check your API key, "
193+
"or create a new one at https://workflowai.com/organization/settings/api-keys "
194+
"or from your self-hosted WorkflowAI instance."
195+
)
196+
197+
def __str__(self) -> str:
198+
return self.message

0 commit comments

Comments
 (0)