Skip to content

Commit 9584ce7

Browse files
authored
feat(retry): add comprehensive tracking of all failed attempts and exceptions (#1802)
## Description This PR enhances the retry mechanism in Instructor to track all failed completions and exceptions across the entire range of retries, not just the final failure. This provides much better debugging capabilities and insight into retry patterns. ## Changes ### Core Changes - **New data structure**: Tracks individual retry failures with attempt number, exception, and completion response - **Enhanced **: Now includes a list containing all retry failures - **Updated retry functions**: Both and now collect comprehensive failure information ### Key Features - Track attempt number for each failure - Store the actual exception that occurred - Preserve completion responses (when available) for analysis - Maintain backward compatibility with existing exception handling ### Benefits - **Better debugging**: See exactly what failed at each retry attempt - **Pattern analysis**: Identify if failures are consistent or changing across retries - **Completion inspection**: Access raw LLM responses that failed validation - **Comprehensive error reporting**: Full visibility into the retry process ## Usage Example ```python try: response = client.chat.completions.create( response_model=MyModel, messages=messages, max_retries=3 ) except InstructorRetryException as e: print(f"Failed after {e.n_attempts} attempts") # New: Access all failed attempts for attempt in e.failed_attempts: print(f"Attempt {attempt.attempt_number}: {attempt.exception}") if attempt.completion: # Analyze the raw completion that failed analyze_completion(attempt.completion) ``` ## Testing - All existing tests pass (backward compatibility maintained) - Linting and formatting checks pass - Example demonstrates the new functionality ## Backward Compatibility This change is fully backward compatible. The new field is optional and defaults to an empty list if not provided. This PR was written by [Cursor](https://cursor.com) <!-- ELLIPSIS_HIDDEN --> ---- > [!IMPORTANT] > Enhances retry mechanism to track all failed attempts and exceptions for improved debugging and analysis. > > - **Behavior**: > - Introduces `FailedAttempt` in `exceptions.py` to track retry attempts with attempt number, exception, and completion. > - Updates `InstructorRetryException` to include `failed_attempts` list. > - Modifies `retry_sync` and `retry_async` in `retry.py` to populate `failed_attempts` with each failed attempt. > - **Benefits**: > - Provides detailed tracking of all retry attempts for better debugging and analysis. > - Maintains backward compatibility by defaulting `failed_attempts` to an empty list if not provided. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=567-labs%2Finstructor&utm_source=github&utm_medium=referral)<sup> for 69f2017. You can [customize](https://app.ellipsis.dev/567-labs/settings/summaries) this summary. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN -->
2 parents 369d928 + 69f2017 commit 9584ce7

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

instructor/core/exceptions.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any
3+
from typing import Any, NamedTuple
44

55

66
class InstructorError(Exception):
@@ -9,6 +9,14 @@ class InstructorError(Exception):
99
pass
1010

1111

12+
class FailedAttempt(NamedTuple):
13+
"""Represents a single failed retry attempt."""
14+
15+
attempt_number: int
16+
exception: Exception
17+
completion: Any | None = None
18+
19+
1220
class IncompleteOutputException(InstructorError):
1321
"""Exception raised when the output from LLM is incomplete due to max tokens limit reached."""
1422

@@ -34,13 +42,15 @@ def __init__(
3442
n_attempts: int,
3543
total_usage: int,
3644
create_kwargs: dict[str, Any] | None = None,
45+
failed_attempts: list[FailedAttempt] | None = None,
3746
**kwargs: dict[str, Any],
3847
):
3948
self.last_completion = last_completion
4049
self.messages = messages
4150
self.n_attempts = n_attempts
4251
self.total_usage = total_usage
4352
self.create_kwargs = create_kwargs
53+
self.failed_attempts = failed_attempts or []
4454
super().__init__(*args, **kwargs)
4555

4656

instructor/core/retry.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from json import JSONDecodeError
77
from typing import Any, Callable, TypeVar
88

9-
from .exceptions import InstructorRetryException, AsyncValidationError
9+
from .exceptions import InstructorRetryException, AsyncValidationError, FailedAttempt
1010
from .hooks import Hooks
1111
from ..mode import Mode
1212
from ..processing.response import (
@@ -175,6 +175,9 @@ def retry_sync(
175175
# Pre-extract stream flag to avoid repeated lookup
176176
stream = kwargs.get("stream", False)
177177

178+
# Track all failed attempts
179+
failed_attempts: list[FailedAttempt] = []
180+
178181
try:
179182
response = None
180183
for attempt in max_retries:
@@ -200,6 +203,15 @@ def retry_sync(
200203
logger.debug(f"Parse error: {e}")
201204
hooks.emit_parse_error(e)
202205

206+
# Track this failed attempt
207+
failed_attempts.append(
208+
FailedAttempt(
209+
attempt_number=attempt.retry_state.attempt_number,
210+
exception=e,
211+
completion=response,
212+
)
213+
)
214+
203215
# Check if this is the last attempt
204216
if isinstance(max_retries, Retrying) and hasattr(
205217
max_retries, "stop"
@@ -231,6 +243,15 @@ def retry_sync(
231243
logger.debug(f"Completion error: {e}")
232244
hooks.emit_completion_error(e)
233245

246+
# Track this failed attempt
247+
failed_attempts.append(
248+
FailedAttempt(
249+
attempt_number=attempt.retry_state.attempt_number,
250+
exception=e,
251+
completion=response,
252+
)
253+
)
254+
234255
# Check if this is the last attempt for completion errors
235256
if isinstance(max_retries, Retrying) and hasattr(
236257
max_retries, "stop"
@@ -261,6 +282,7 @@ def retry_sync(
261282
), # Use the optimized function instead of nested lookups
262283
create_kwargs=kwargs,
263284
total_usage=total_usage,
285+
failed_attempts=failed_attempts,
264286
) from e
265287

266288

@@ -304,6 +326,9 @@ async def retry_async(
304326
# Pre-extract stream flag to avoid repeated lookup
305327
stream = kwargs.get("stream", False)
306328

329+
# Track all failed attempts
330+
failed_attempts: list[FailedAttempt] = []
331+
307332
try:
308333
response = None
309334
async for attempt in max_retries:
@@ -333,6 +358,15 @@ async def retry_async(
333358
logger.debug(f"Parse error: {e}")
334359
hooks.emit_parse_error(e)
335360

361+
# Track this failed attempt
362+
failed_attempts.append(
363+
FailedAttempt(
364+
attempt_number=attempt.retry_state.attempt_number,
365+
exception=e,
366+
completion=response,
367+
)
368+
)
369+
336370
# Check if this is the last attempt
337371
if isinstance(max_retries, AsyncRetrying) and hasattr(
338372
max_retries, "stop"
@@ -364,6 +398,15 @@ async def retry_async(
364398
logger.debug(f"Completion error: {e}")
365399
hooks.emit_completion_error(e)
366400

401+
# Track this failed attempt
402+
failed_attempts.append(
403+
FailedAttempt(
404+
attempt_number=attempt.retry_state.attempt_number,
405+
exception=e,
406+
completion=response,
407+
)
408+
)
409+
367410
# Check if this is the last attempt for completion errors
368411
if isinstance(max_retries, AsyncRetrying) and hasattr(
369412
max_retries, "stop"
@@ -394,4 +437,5 @@ async def retry_async(
394437
), # Use the optimized function instead of nested lookups
395438
create_kwargs=kwargs,
396439
total_usage=total_usage,
440+
failed_attempts=failed_attempts,
397441
) from e

0 commit comments

Comments
 (0)