Search tools in AI agents fail more often than developers expect: empty results from overly specific queries, timeouts on slow networks, rate limits during burst usage, and malformed queries from the LLM. A retry chain handles these failures gracefully by attempting progressively simpler queries, with backoff between attempts, and falling back to cached results when all retries fail. This tutorial builds a production-grade retry chain that wraps any search API call. The pattern adds near-zero cost overhead since retries only fire on failures.
Prerequisites
- Python 3.9+ installed
- requests library installed
- A Scavio API key from scavio.dev
- An agent with a search tool to harden
Walkthrough
Step 1: Build the base search function with error handling
Start with a search function that catches and classifies all failure modes: HTTP errors, timeouts, empty results, and malformed responses.
import requests, os, time
from enum import Enum
API_KEY = os.environ['SCAVIO_API_KEY']
class SearchError(Enum):
SUCCESS = 'success'
EMPTY = 'empty_results'
TIMEOUT = 'timeout'
RATE_LIMIT = 'rate_limit'
AUTH_ERROR = 'auth_error'
SERVER_ERROR = 'server_error'
UNKNOWN = 'unknown'
def search_with_status(query: str, timeout: int = 15) -> tuple:
"""Returns (results, error_type)."""
try:
resp = requests.post('https://api.scavio.dev/api/v1/search',
headers={'x-api-key': API_KEY, 'Content-Type': 'application/json'},
json={'query': query, 'country_code': 'us'},
timeout=timeout)
if resp.status_code == 429:
return [], SearchError.RATE_LIMIT
if resp.status_code == 401:
return [], SearchError.AUTH_ERROR
if resp.status_code >= 500:
return [], SearchError.SERVER_ERROR
results = resp.json().get('organic_results', [])
if not results:
return [], SearchError.EMPTY
return results, SearchError.SUCCESS
except requests.exceptions.Timeout:
return [], SearchError.TIMEOUT
except Exception:
return [], SearchError.UNKNOWNStep 2: Build the query simplification chain
When a query returns empty results, try progressively simpler versions. Remove qualifiers, shorten the query, and broaden the scope.
def simplify_query(query: str) -> list:
"""Generate progressively simpler versions of a query."""
words = query.split()
variants = [query] # original first
# Remove quotes and special chars
cleaned = query.replace('"', '').replace("'", '')
if cleaned != query:
variants.append(cleaned)
# Remove year qualifiers
import re
no_year = re.sub(r'\b202[0-9]\b', '', query).strip()
if no_year != query:
variants.append(no_year)
# First N words (progressively shorter)
if len(words) > 5:
variants.append(' '.join(words[:5]))
if len(words) > 3:
variants.append(' '.join(words[:3]))
# Remove filler words
filler = {'the', 'a', 'an', 'is', 'are', 'was', 'were', 'for', 'and', 'or', 'but'}
no_filler = ' '.join(w for w in words if w.lower() not in filler)
if no_filler != query and len(no_filler) > 5:
variants.append(no_filler)
return list(dict.fromkeys(variants)) # dedupe preserving order
# Test:
for v in simplify_query('"best enterprise CRM software" for startups 2026'):
print(f' {v}')Step 3: Build the retry chain with backoff
Chain together retries with exponential backoff for transient errors and query simplification for empty results.
import logging
log = logging.getLogger('search_retry')
def search_retry_chain(query: str, max_retries: int = 3) -> dict:
"""Retry chain: try original, then simplified queries, with backoff."""
queries = simplify_query(query)
all_attempts = []
for attempt, q in enumerate(queries[:max_retries]):
results, error = search_with_status(q)
all_attempts.append({'query': q, 'error': error.value, 'result_count': len(results)})
if error == SearchError.SUCCESS:
return {
'results': results,
'query_used': q,
'attempts': len(all_attempts),
'all_attempts': all_attempts
}
if error == SearchError.AUTH_ERROR:
log.error('Authentication failed. Check your API key.')
break
if error in (SearchError.RATE_LIMIT, SearchError.SERVER_ERROR, SearchError.TIMEOUT):
wait = 2 ** attempt
log.warning(f'Transient error ({error.value}), retrying in {wait}s...')
time.sleep(wait)
# Retry same query for transient errors
results, error = search_with_status(q)
if error == SearchError.SUCCESS:
all_attempts.append({'query': q, 'error': 'retry_success', 'result_count': len(results)})
return {'results': results, 'query_used': q,
'attempts': len(all_attempts), 'all_attempts': all_attempts}
# For EMPTY, move to next simplified query
log.info(f'Attempt {attempt + 1}: "{q}" -> {error.value}')
return {'results': [], 'query_used': query,
'attempts': len(all_attempts), 'all_attempts': all_attempts}Step 4: Add a result cache for fallback
Cache successful search results so the agent has a fallback when all retries fail. TTL-based cache prevents serving very stale data.
import hashlib
_cache = {}
CACHE_TTL = 3600 # 1 hour
def cached_search(query: str) -> dict:
key = hashlib.md5(query.lower().strip().encode()).hexdigest()
now = time.time()
# Check cache first
if key in _cache:
cached, timestamp = _cache[key]
if now - timestamp < CACHE_TTL:
return {**cached, 'from_cache': True, 'cache_age_s': int(now - timestamp)}
# Run retry chain
result = search_retry_chain(query)
# Cache successful results
if result['results']:
_cache[key] = (result, now)
elif key in _cache:
# All retries failed, return stale cache with warning
cached, timestamp = _cache[key]
return {**cached, 'from_cache': True,
'cache_age_s': int(now - timestamp),
'warning': 'Serving stale cached results (all retries failed)'}
return {**result, 'from_cache': False}
# Test:
result = cached_search('best crm software 2026')
print(f'Results: {len(result["results"])}, cache: {result["from_cache"]}, '
f'attempts: {result["attempts"]}')Step 5: Integrate into your agent as the main search tool
Replace your agent's basic search function with the retry chain. The agent calls the same interface but gets automatic retries and caching.
def agent_search_tool(query: str) -> str:
"""Production-ready search tool for agents.
Handles retries, query simplification, and caching automatically."""
result = cached_search(query)
if not result['results']:
return f'Search returned no results after {result["attempts"]} attempts. Please rephrase your query.'
formatted = []
for r in result['results'][:5]:
formatted.append(f'Title: {r["title"]}\nURL: {r["link"]}\nSnippet: {r.get("snippet", "")}')
header = f'Found {len(result["results"])} results'
if result.get('from_cache'):
header += f' (cached, {result["cache_age_s"]}s old)'
if result['query_used'] != query:
header += f' (simplified query: "{result["query_used"]}")'
return f'{header}\n\n' + '\n\n'.join(formatted)
# Use in any agent framework:
# @tool
# def web_search(query: str) -> str:
# return agent_search_tool(query)
print(agent_search_tool('best enterprise CRM software pricing comparison 2026'))Python Example
import os, requests, time, hashlib
API_KEY = os.environ['SCAVIO_API_KEY']
_cache = {}
def search(query, timeout=10):
resp = requests.post('https://api.scavio.dev/api/v1/search',
headers={'x-api-key': API_KEY, 'Content-Type': 'application/json'},
json={'query': query, 'country_code': 'us'}, timeout=timeout)
return resp.json().get('organic_results', [])
def search_with_retry(query, max_retries=3):
queries = [query] + [' '.join(query.split()[:n]) for n in [5, 3]]
for q in queries[:max_retries]:
results = search(q)
if results:
key = hashlib.md5(query.encode()).hexdigest()
_cache[key] = results
return results
time.sleep(1)
return _cache.get(hashlib.md5(query.encode()).hexdigest(), [])
results = search_with_retry('best enterprise CRM pricing comparison 2026')
print(f'{len(results)} results')JavaScript Example
const API_KEY = process.env.SCAVIO_API_KEY;
const cache = new Map();
async function search(query) {
const resp = await fetch('https://api.scavio.dev/api/v1/search', {
method: 'POST',
headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, country_code: 'us' })
});
return (await resp.json()).organic_results || [];
}
async function searchWithRetry(query, maxRetries = 3) {
const words = query.split(' ');
const queries = [query, words.slice(0, 5).join(' '), words.slice(0, 3).join(' ')];
for (const q of queries.slice(0, maxRetries)) {
const results = await search(q);
if (results.length) { cache.set(query, results); return results; }
await new Promise(r => setTimeout(r, 1000));
}
return cache.get(query) || [];
}
searchWithRetry('best enterprise CRM pricing 2026')
.then(r => console.log(`${r.length} results`));Expected Output
"best enterprise CRM software" for startups 2026
best enterprise CRM software for startups 2026
best enterprise CRM software for startups
best enterprise CRM
enterprise CRM software startups 2026
Attempt 1: "best enterprise CRM software..." -> empty_results
Attempt 2: "best enterprise CRM software for startups" -> success
Results: 10, cache: False, attempts: 2
Found 10 results (simplified query: "best enterprise CRM software for startups")