Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions actions/serper/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/)
and this project adheres to [Semantic Versioning](https://semver.org/).

## [1.1.3] - 2025-01-27

### Added

- Exponential backoff retry logic for handling connection failures (RemoteDisconnected exceptions)

## [1.1.2] - 2025-08-07

### Changed
Expand Down
1 change: 1 addition & 0 deletions actions/serper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The Serper Action Package provides an interface to interact with the Serper API,
- Perform Google searches using the Serper API.
- Retrieve structured search results, including knowledge graph, organic results, places, people also ask, and related searches.
- Securely manage API keys using Sema4.ai's Secret management.
- Built-in retry logic with exponential backoff for handling connection failures.

## Installation

Expand Down
61 changes: 58 additions & 3 deletions actions/serper/actions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import time
from pathlib import Path
from typing import List, Optional

Expand All @@ -11,6 +12,58 @@

load_dotenv(Path(__file__).absolute().parent / "devdata" / ".env")

def retry_with_exponential_backoff(func, max_retries=3, base_delay=1.0, max_delay=5.0):
"""
Retry a function with exponential backoff for connection-related exceptions.

Args:
func: The function to retry
max_retries: Maximum number of retry attempts (default: 3)
base_delay: Base delay in seconds for exponential backoff (default: 1.0)
max_delay: Maximum delay in seconds (default: 5.0)

Returns:
The result of the function call

Raises:
ActionError: If all retries are exhausted
"""
last_exception = None

for attempt in range(max_retries + 1):
try:
return func()
except (HTTPError, Exception) as e:
last_exception = e

# Check if it's a connection-related error that should be retried
error_str = str(e).lower()
is_connection_error = (
"remote end closed connection" in error_str or
"connection aborted" in error_str or
"remotedisconnected" in error_str or
"connection reset" in error_str or
"broken pipe" in error_str
)

if not is_connection_error and not isinstance(e, HTTPError):
# Not a retryable error, raise immediately
raise ActionError(f"Non-retryable error occurred: {str(e)}")

if attempt == max_retries:
Comment on lines +49 to +53

Choose a reason for hiding this comment

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

P2 Badge Stop retrying non-connection HTTP errors

HTTP failures from Serper (e.g., 401 for an invalid key or 400 for a bad query) are now retried up to the max_retries count because HTTPError does not hit the non-retryable branch at lines 49‑51 and only raises on the final attempt. That introduces extra delay and repeated API calls for deterministic client errors that previously failed fast and were meant to be non-retryable per the description of only handling connection drops.

Useful? React with 👍 / 👎.

# Last attempt failed, raise the exception
if is_connection_error:
raise ActionError(f"Remote connection failed after {max_retries} retries: {str(e)}")
else:
raise ActionError(f"HTTP error occurred: {str(e)}")

# Calculate delay with exponential backoff
delay = min(base_delay * (2 ** attempt), max_delay)
time.sleep(delay)

# This should never be reached, but just in case
raise ActionError(f"Unexpected error: {str(last_exception)}")


# Define Pydantic models for the response
class KnowledgeGraph(BaseModel):
Expand Down Expand Up @@ -81,7 +134,7 @@ def search_google(q: str, num: int, api_key: Secret) -> Response[SearchResult]:
if not api_key:
raise ActionError("API key is required but not provided")

try:
def make_request():
headers = {"X-API-KEY": api_key, "Content-Type": "application/json"}
payload = json.dumps({"q": q, "num": num})

Expand All @@ -92,10 +145,12 @@ def search_google(q: str, num: int, api_key: Secret) -> Response[SearchResult]:
)

response.raise_for_status()
return response

try:
# Use retry logic with exponential backoff
response = retry_with_exponential_backoff(make_request)
search_result = SearchResult(**response.json())
return Response(result=search_result)
except HTTPError as e:
raise ActionError(f"HTTP error occurred: {str(e)}")
except Exception as e:
raise ActionError(f"An unexpected error occurred: {str(e)}")
9 changes: 5 additions & 4 deletions actions/serper/package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@ name: Serper
description: Interact with the Serper API to perform Google searches.

# Required: The version of the action package.
version: 1.1.2
version: 1.1.3

# The version of the `package.yaml` format.
spec-version: v2

dependencies:
conda-forge:
- python=3.11.11
- python-dotenv=1.1.0
- python-dotenv=1.1.1
- uv=0.6.11
pypi:
- sema4ai-actions=1.4.1
- pydantic=2.11.7
- sema4ai-actions=1.4.2
- pydantic=2.11.9
- urllib3=2.5.0

external-endpoints:
- name: "Google Serper API"
Expand Down