Skip to content

Commit 42801ce

Browse files
fix(processing): do not wrap JSONDecodeError in JSON mode so retry catches it; add regression tests for JSON/Validation errors in retry (closes #1856)
Co-Authored-By: Jason Liu <[email protected]>
1 parent 0921f9c commit 42801ce

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

instructor/processing/function_calls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def _validate_model_from_json(
9292
return cls.model_validate(parsed, context=validation_context, strict=False)
9393
except json.JSONDecodeError as e:
9494
logger.debug(f"JSON decode error: {e}")
95-
raise ValueError(f"Failed to parse JSON: {e}") from e
95+
raise
9696
except Exception as e:
9797
logger.debug(f"Model validation error: {e}")
9898
raise

tests/test_json_extraction.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,11 +267,19 @@ def test_validate_model_non_strict(self):
267267
assert result.skills == ["testing"]
268268

269269
def test_validate_model_json_error(self):
270-
"""Test handling JSON decode errors."""
270+
"""Test handling JSON decode errors.
271+
272+
In strict mode, Pydantic raises ValidationError with an 'Invalid JSON' message.
273+
"""
271274
invalid_json = '{"name": "Invalid, "age": 20}' # Missing quote
272275

273276
with pytest.raises(Exception) as excinfo:
274277
_validate_model_from_json(Person, invalid_json, None, True)
275-
276-
# Pydantic directly raises validation errors now, not our custom message
277278
assert "Invalid JSON" in str(excinfo.value)
279+
280+
def test_validate_model_json_error_non_strict(self):
281+
"""In non-strict mode, json.loads should raise JSONDecodeError (not wrapped)."""
282+
invalid_json = '{"name": "Invalid, "age": 20}' # Missing quote
283+
284+
with pytest.raises(json.JSONDecodeError):
285+
_validate_model_from_json(Person, invalid_json, None, False)

tests/test_retry_json_mode.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""
2+
Test that retry mechanism works correctly with JSON mode.
3+
Specifically tests that JSONDecodeError is properly caught by retry handler.
4+
5+
This is a regression test for issue #1856.
6+
"""
7+
8+
import json
9+
import pytest
10+
from unittest.mock import Mock, patch
11+
from pydantic import BaseModel, ValidationError
12+
13+
import instructor
14+
from instructor.core.exceptions import InstructorRetryException
15+
from instructor.mode import Mode
16+
17+
18+
class User(BaseModel):
19+
name: str
20+
age: int
21+
22+
23+
def test_json_decode_error_caught_by_retry():
24+
"""Test that JSON errors are caught by retry handler, not generic Exception handler.
25+
26+
This is a regression test for issue #1856 where JSONDecodeError was wrapped
27+
in ValueError, causing it to be caught by the generic Exception handler instead
28+
of the specific validation error handler that calls handle_reask_kwargs.
29+
30+
Note: In strict mode, Pydantic raises ValidationError with 'Invalid JSON' message.
31+
In non-strict mode, json.loads raises JSONDecodeError directly.
32+
Both are now properly caught by the retry handler.
33+
"""
34+
mock_response = Mock()
35+
mock_response.choices = [Mock()]
36+
mock_response.choices[0].message = Mock()
37+
mock_response.choices[0].message.content = "invalid json {"
38+
mock_response.choices[0].finish_reason = "stop"
39+
mock_response.usage = None
40+
41+
mock_client = Mock()
42+
mock_client.chat = Mock()
43+
mock_client.chat.completions = Mock()
44+
mock_client.chat.completions.create = Mock(return_value=mock_response)
45+
46+
client = instructor.patch(mock_client, mode=Mode.JSON)
47+
48+
with pytest.raises(InstructorRetryException) as exc_info:
49+
client.chat.completions.create(
50+
model="gpt-4o-mini",
51+
response_model=User,
52+
messages=[{"role": "user", "content": "test"}],
53+
max_retries=2,
54+
)
55+
56+
exception = exc_info.value
57+
assert exception.n_attempts == 2
58+
assert len(exception.failed_attempts) == 2
59+
60+
for attempt in exception.failed_attempts:
61+
assert isinstance(attempt.exception, (json.JSONDecodeError, ValidationError))
62+
if isinstance(attempt.exception, ValidationError):
63+
assert "Invalid JSON" in str(attempt.exception)
64+
65+
66+
def test_validation_error_caught_by_retry():
67+
"""Test that ValidationError is still caught by retry handler."""
68+
mock_response = Mock()
69+
mock_response.choices = [Mock()]
70+
mock_response.choices[0].message = Mock()
71+
mock_response.choices[0].message.content = '{"name": "John"}'
72+
mock_response.choices[0].finish_reason = "stop"
73+
mock_response.usage = None
74+
75+
mock_client = Mock()
76+
mock_client.chat = Mock()
77+
mock_client.chat.completions = Mock()
78+
mock_client.chat.completions.create = Mock(return_value=mock_response)
79+
80+
client = instructor.patch(mock_client, mode=Mode.JSON)
81+
82+
with pytest.raises(InstructorRetryException) as exc_info:
83+
client.chat.completions.create(
84+
model="gpt-4o-mini",
85+
response_model=User,
86+
messages=[{"role": "user", "content": "test"}],
87+
max_retries=2,
88+
)
89+
90+
exception = exc_info.value
91+
assert exception.n_attempts == 2
92+
assert len(exception.failed_attempts) == 2
93+
94+
for attempt in exception.failed_attempts:
95+
assert isinstance(attempt.exception, ValidationError)

0 commit comments

Comments
 (0)