Skip to content

Retry instead of raising error when Google returns 0 candidates#4125

Merged
DouweM merged 2 commits intopydantic:mainfrom
wassafshahzad:wassaf/4102-error-gemini-empty-response
Jan 29, 2026
Merged

Retry instead of raising error when Google returns 0 candidates#4125
DouweM merged 2 commits intopydantic:mainfrom
wassafshahzad:wassaf/4102-error-gemini-empty-response

Conversation

@wassafshahzad
Copy link
Copy Markdown
Contributor

@wassafshahzad wassafshahzad commented Jan 28, 2026

Pre-Review Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • Linting and type checking pass per make format and make typecheck.
  • PR title is fit for the release changelog.

Pre-Merge Checklist

  • New tests for any fix or new behavior, maintaining 100% coverage.
  • Updated documentation for new features and behaviors, including docstrings for API docs.

@wassafshahzad wassafshahzad marked this pull request as ready for review January 28, 2026 02:38
def _process_response(self, response: GenerateContentResponse) -> ModelResponse:
if not response.candidates:
raise UnexpectedModelBehavior('Expected at least one candidate in Gemini response') # pragma: no cover
return ModelResponse(parts=[])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should still include all the other fields we build below, like finish reason, timestamp, provider response ID etc. Just parts should be [] (like in the if candidate.content is None or candidate.content.parts is None: branch below).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Than should, we simplify the logic by extracting the candidate directly and tightening the null checks.

Proposed Logic:

  1. Extract Candidate: Use a conditional assignment to grab the first candidate.
  2. Simplify Initialization: Skip the raw_reason block and initialize finalize_reason = None.
  3. Update Guard Clause: Add candidate is None to the existing safety check.
  4. Metadata: Ensure grounding_metadata is handled as None.
candidate = response.candidates[0] if response.candidates else None
finalize_reason = None

if candidate is None or candidate.content is None or candidate.content.parts is None:
    # ... handle null case ...
    grounding_metadata = None

Comment thread tests/models/test_google.py Outdated

def test_google_process_response_empty_candidates(google_provider: GoogleProvider):
model = GoogleModel('gemini-2.5-pro', provider=google_provider)
response = _generate_response_with_texts(response_id='resp-456', texts=['', '', ''])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have the 3 texts here if we're going to reset the candidates?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believed it would generate a empty response. Should I remove it

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we're resetting the candidates after anyway? So why would it matter if we create an empty response?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point! I have my updated approach, I'm now creating the response directly with GenerateContentResponse.model_validate({'response_id': 'resp-456', 'candidates': []}) instead of creating it with text data and then resetting candidates.

Comment thread tests/models/test_google.py Outdated
response.candidates = []
result = model._process_response(response) # pyright: ignore[reportPrivateUsage]

assert result.parts == snapshot([])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should assert result == snapshot() so we see the entire model response.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@DouweM DouweM self-assigned this Jan 28, 2026
@DouweM DouweM added bug Report that something isn't working, or PR implementing a fix awaiting author revision labels Jan 28, 2026
@wassafshahzad wassafshahzad requested a review from DouweM January 29, 2026 20:58
@DouweM DouweM changed the title Removed error caused by empty response from google models Retry instead of raising error when Google returns 0 candidates Jan 29, 2026
@DouweM DouweM merged commit 388d722 into pydantic:main Jan 29, 2026
34 checks passed
@DouweM
Copy link
Copy Markdown
Collaborator

DouweM commented Jan 29, 2026

@wassafshahzad Thank you!

@amiyapatanaik
Copy link
Copy Markdown

Thank you, much appreciated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting author revision bug Report that something isn't working, or PR implementing a fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gemini models in some cases cause empty responses: Expected at least one candidate in Gemini response

3 participants