Skip to content

Commit f76099a

Browse files
committed
Fix JSON repair result handling for null payloads
1 parent 7357848 commit f76099a

File tree

6 files changed

+82
-49
lines changed

6 files changed

+82
-49
lines changed

src/core/app/middleware/json_repair_middleware.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
from src.core.interfaces.response_processor_interface import (
1010
IResponseMiddleware,
1111
)
12-
from src.core.services.json_repair_service import JsonRepairService
12+
from src.core.services.json_repair_service import (
13+
JsonRepairResult,
14+
JsonRepairService,
15+
)
1316

1417
logger = logging.getLogger(__name__)
1518

@@ -69,34 +72,36 @@ async def process(
6972
)
7073

7174
try:
72-
repaired_json = self.json_repair_service.repair_and_validate_json(
73-
response.content,
74-
schema=self.config.session.json_repair_schema,
75-
strict=strict_effective,
76-
)
77-
if repaired_json is not None:
78-
metrics.inc(
79-
"json_repair.non_streaming.strict_success"
80-
if strict_effective
81-
else "json_repair.non_streaming.best_effort_success"
75+
repair_result: JsonRepairResult = (
76+
self.json_repair_service.repair_and_validate_json(
77+
response.content,
78+
schema=self.config.session.json_repair_schema,
79+
strict=strict_effective,
8280
)
83-
else:
84-
metrics.inc(
85-
"json_repair.non_streaming.strict_fail"
86-
if strict_effective
87-
else "json_repair.non_streaming.best_effort_fail"
81+
)
82+
metric_suffix = (
83+
"strict_success"
84+
if strict_effective and repair_result.success
85+
else (
86+
"best_effort_success"
87+
if repair_result.success
88+
else (
89+
"strict_fail" if strict_effective else "best_effort_fail"
90+
)
8891
)
92+
)
93+
metrics.inc(f"json_repair.non_streaming.{metric_suffix}")
8994
except Exception:
9095
metrics.inc(
9196
"json_repair.non_streaming.strict_fail"
9297
if strict_effective
9398
else "json_repair.non_streaming.best_effort_fail"
9499
)
95100
raise
96-
if repaired_json is not None:
101+
if repair_result.success:
97102
if logger.isEnabledFor(logging.INFO):
98103
logger.info(f"JSON detected and repaired for session {session_id}")
99-
response.content = json.dumps(repaired_json)
104+
response.content = json.dumps(repair_result.content)
100105
response.metadata["repaired"] = True
101106

102107
return response

src/core/services/json_repair_service.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import json
44
import logging
5-
from typing import Any, cast
5+
from dataclasses import dataclass
6+
from typing import Any
67

78
from json_repair import repair_json
89
from jsonschema import ValidationError as JsonSchemaValidationError
@@ -13,6 +14,14 @@
1314
logger = logging.getLogger(__name__)
1415

1516

17+
@dataclass(frozen=True)
18+
class JsonRepairResult:
19+
"""Represents the outcome of a JSON repair attempt."""
20+
21+
success: bool
22+
content: Any | None
23+
24+
1625
class JsonRepairService:
1726
"""
1827
A service to repair and validate JSON data.
@@ -25,7 +34,7 @@ def repair_and_validate_json(
2534
json_string: str,
2635
schema: dict[str, Any] | None = None,
2736
strict: bool = False,
28-
) -> dict[str, Any] | None:
37+
) -> JsonRepairResult:
2938
"""
3039
Repairs a JSON string and optionally validates it against a schema.
3140
@@ -35,13 +44,13 @@ def repair_and_validate_json(
3544
strict: If True, raises an error if the JSON is invalid after repair.
3645
3746
Returns:
38-
The repaired and validated JSON object, or None if repair fails.
47+
JsonRepairResult describing whether repair succeeded and the content.
3948
"""
4049
try:
4150
repaired_json = self.repair_json(json_string)
42-
if schema:
51+
if schema is not None:
4352
self.validate_json(repaired_json, schema)
44-
return repaired_json
53+
return JsonRepairResult(success=True, content=repaired_json)
4554
except JsonSchemaValidationError as e:
4655
if strict:
4756
raise ValidationError(
@@ -57,7 +66,7 @@ def repair_and_validate_json(
5766
},
5867
) from e
5968
logger.warning("JSON schema validation failed: %s", e)
60-
return None
69+
return JsonRepairResult(success=False, content=repaired_json)
6170
except (ValueError, TypeError) as e:
6271
if strict:
6372
raise JSONParsingError(
@@ -67,10 +76,10 @@ def repair_and_validate_json(
6776
"error_message": str(e),
6877
},
6978
) from e
70-
logger.warning(f"Failed to repair or validate JSON: {e}")
71-
return None
79+
logger.warning("Failed to repair or validate JSON: %s", e)
80+
return JsonRepairResult(success=False, content=None)
7281

73-
def repair_json(self, json_string: str) -> dict[str, Any]:
82+
def repair_json(self, json_string: str) -> Any:
7483
"""
7584
Repairs a JSON string.
7685
@@ -81,7 +90,7 @@ def repair_json(self, json_string: str) -> dict[str, Any]:
8190
The repaired JSON object.
8291
"""
8392
repaired_string = repair_json(json_string)
84-
return cast(dict[str, Any], json.loads(repaired_string))
93+
return json.loads(repaired_string)
8594

8695
def validate_json(
8796
self, json_object: dict[str, Any], schema: dict[str, Any]

src/core/services/streaming/json_repair_processor.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
IStreamProcessor,
1111
StreamingContent,
1212
)
13-
from src.core.services.json_repair_service import JsonRepairService
13+
from src.core.services.json_repair_service import JsonRepairResult, JsonRepairService
1414

1515
logger = logging.getLogger(__name__)
1616

@@ -67,9 +67,9 @@ async def process(self, content: StreamingContent) -> StreamingContent:
6767
else:
6868
i = self._process_json_character(text, i)
6969
if self._is_json_complete():
70-
repaired_json, success = self._handle_json_completion()
71-
if success:
72-
out_parts.append(json.dumps(repaired_json))
70+
repair_result = self._handle_json_completion()
71+
if repair_result.success:
72+
out_parts.append(json.dumps(repair_result.content))
7373
else:
7474
out_parts.append(self._buffer)
7575
self._reset_state()
@@ -151,26 +151,23 @@ def _is_current_quote_escaped(self) -> bool:
151151
def _is_json_complete(self) -> bool:
152152
return self._json_started and self._brace_level == 0 and not self._in_string
153153

154-
def _handle_json_completion(self) -> tuple[Any, bool]:
155-
repaired = None
156-
success = False
154+
def _handle_json_completion(self) -> JsonRepairResult:
157155
try:
158-
repaired = self._service.repair_and_validate_json(
156+
result = self._service.repair_and_validate_json(
159157
self._buffer,
160158
schema=self._schema,
161159
strict=self._strict_mode,
162160
)
163-
if repaired is not None:
164-
success = True
165161
except Exception as e: # pragma: no cover - strict mode rethrow
166162
if self._strict_mode:
167163
raise JSONParsingError(
168164
message=f"JSON repair failed in strict mode: {e}",
169165
details={"original_buffer": self._buffer},
170166
) from e
171167
logger.warning("JSON repair raised error: %s", e)
168+
return JsonRepairResult(success=False, content=None)
172169

173-
if repaired is not None:
170+
if result.success:
174171
metrics.inc(
175172
"json_repair.streaming.strict_success"
176173
if self._strict_mode
@@ -185,7 +182,7 @@ def _handle_json_completion(self) -> tuple[Any, bool]:
185182
logger.warning(
186183
"JSON block detected but failed to repair. Flushing raw buffer."
187184
)
188-
return repaired, success
185+
return result
189186

190187
def _log_buffer_capacity_warning(self) -> None:
191188
if self._json_started and len(self._buffer) > self._buffer_cap_bytes:
@@ -199,16 +196,16 @@ def _flush_final_buffer(self) -> str | None:
199196
if not self._in_string and buf.rstrip().endswith(":"):
200197
buf = buf + " null"
201198
self._buffer = buf
202-
repaired_final = self._service.repair_and_validate_json(
199+
repair_result = self._service.repair_and_validate_json(
203200
buf, schema=self._schema, strict=self._strict_mode
204201
)
205-
if repaired_final is not None:
202+
if repair_result.success:
206203
metrics.inc(
207204
"json_repair.streaming.strict_success"
208205
if self._strict_mode
209206
else "json_repair.streaming.best_effort_success"
210207
)
211-
return json.dumps(repaired_final)
208+
return json.dumps(repair_result.content)
212209
else:
213210
metrics.inc(
214211
"json_repair.streaming.strict_fail"

tests/unit/core/services/test_json_repair_middleware.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ async def test_process_response_empty_object(
7373
assert processed_response.metadata.get("repaired") is True
7474

7575

76+
async def test_process_response_null_payload(
77+
json_repair_middleware: JsonRepairMiddleware,
78+
) -> None:
79+
response = ProcessedResponse(content="null")
80+
processed_response = await json_repair_middleware.process(
81+
response, "session_id", {}
82+
)
83+
84+
assert processed_response.content == "null"
85+
assert processed_response.metadata.get("repaired") is True
86+
87+
7688
async def test_process_response_best_effort_failure_metrics(
7789
json_repair_middleware: JsonRepairMiddleware,
7890
monkeypatch: pytest.MonkeyPatch,

tests/unit/core/services/test_json_repair_service.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,21 @@ def test_repair_and_validate_json_schema_failure_best_effort(
4040
"required": ["a"],
4141
}
4242

43-
repaired = json_repair_service.repair_and_validate_json(
43+
result = json_repair_service.repair_and_validate_json(
4444
'{"a": "text"}', schema=schema, strict=False
4545
)
4646

47-
assert repaired is None
47+
assert result.success is False
48+
assert result.content == {"a": "text"}
49+
50+
51+
def test_repair_and_validate_json_allows_null_payload(
52+
json_repair_service: JsonRepairService,
53+
) -> None:
54+
result = json_repair_service.repair_and_validate_json("null")
55+
56+
assert result.success is True
57+
assert result.content is None
4858

4959

5060
def test_repair_and_validate_json_schema_failure_strict(

tests/unit/json_repair_processor_test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Any
55

66
from src.core.domain.streaming_content import StreamingContent
7-
from src.core.services.json_repair_service import JsonRepairService
7+
from src.core.services.json_repair_service import JsonRepairResult, JsonRepairService
88
from src.core.services.streaming.json_repair_processor import JsonRepairProcessor
99

1010

@@ -16,11 +16,11 @@ def repair_and_validate_json(
1616
json_string: str,
1717
schema: dict[str, Any] | None = None,
1818
strict: bool = False,
19-
) -> dict[str, Any] | None:
20-
return None
19+
) -> JsonRepairResult:
20+
return JsonRepairResult(success=False, content=None)
2121

2222

23-
def test_json_repair_processor_flushes_raw_buffer_when_repair_returns_none() -> None:
23+
def test_json_repair_processor_flushes_raw_buffer_when_repair_fails() -> None:
2424
processor = JsonRepairProcessor(
2525
repair_service=FailingJsonRepairService(),
2626
buffer_cap_bytes=1024,

0 commit comments

Comments
 (0)