Blog
 / 
API
October 8, 2026

How to Do Async Web Search in Python with the You.com Web Search API

How to Do Async Web Search in Python with the You.com Web Search API

TLDR: Async web search in Python means fanning out many Web Search API calls at once instead of waiting for each one to finish. The working recipe is an async HTTP client, a semaphore that caps concurrency, per-request timeouts, and a retry path for rate-limit responses. This guide shows the sync baseline, the async version with real error handling, the decision framework for when async pays off, and the failure modes that bite production fan-outs.

Fan-out search is the normal shape of agentic workloads: enrich a list of companies, check claims across a set of topics, or run per-row research in a pipeline. Each step needs the same thing, many independent web searches. You.com ships a Web Search API for exactly this, a POST endpoint that takes a query and returns ranked web and news results. The Python question is how to call it many times at once without melting your latency budget or tripping rate limits.

What Does the Sync Baseline Look Like?

Start with one call, because every async version is a transformation of this shape. The Web Search API is a POST to https://ydc-index.io/v1/search with your API key in the X-API-Key header, taking query and count, and returning ranked results with URLs, snippets, and page age. The full request surface, including the freshness and domain filters, is documented in the Web Search API guide.

import json
import os
import urllib.request

def search(query: str, count: int = 5) -> list:
    key = os.environ["YOUCOM_API_KEY"]
    req = urllib.request.Request(
        "https://ydc-index.io/v1/search",
        data=json.dumps({"query": query, "count": count}).encode(),
        headers={"Content-Type": "application/json", "X-API-Key": key},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        payload = json.load(resp)
    return payload.get("results", {}).get("web", [])

results = search("python asyncio tutorial")
for hit in results:
    print(hit["url"], "|", hit.get("page_age", "no age"))

This works, and for a handful of queries it is the right answer. A loop over ten queries at, say, a second each costs ten seconds of wall clock, all of it waiting on the network while your CPU idles. That idle time is what async reclaims.

How Do You Fan Out Searches Concurrently?

The async version replaces the loop with three coordinated pieces: an async HTTP client, a semaphore that caps how many requests are in flight, and a gather that collects results as they complete. The example below uses httpx, a standard Python HTTP client with native async support, and handles two response problems inline: transient timeouts and rate-limit responses, which come back as HTTP 429.

import asyncio
import json
import os

import httpx

API_URL = "https://ydc-index.io/v1/search"
CONCURRENCY = 5

async def search_one(client: httpx.AsyncClient, sem: asyncio.Semaphore,
                     query: str) -> dict:
    headers = {
        "Content-Type": "application/json",
        "X-API-Key": os.environ["YOUCOM_API_KEY"],
    }
    for attempt in (1, 2):
        try:
            async with sem:
                resp = await client.post(
                    API_URL,
                    json={"query": query, "count": 5},
                    headers=headers,
                    timeout=30.0,
                )
            if resp.status_code == 429 and attempt == 1:
                await asyncio.sleep(2 ** attempt)
                continue
            resp.raise_for_status()
            data = resp.json().get("results", {}).get("web", [])
            return {"query": query, "ok": True, "results": data}
        except httpx.TimeoutException:
            if attempt == 2:
                return {"query": query, "ok": False, "error": "timeout"}
    return {"query": query, "ok": False, "error": "rate_limited"}

async def fan_out(queries: list) -> list:
    sem = asyncio.Semaphore(CONCURRENCY)
    async with httpx.AsyncClient() as client:
        tasks = [search_one(client, sem, q) for q in queries]
        return await asyncio.gather(*tasks)

if __name__ == "__main__":
    queries = [
        "asyncio semaphore tutorial",
        "httpx async client best practices",
        "python rate limit handling 429",
        "fan out API calls asyncio",
    ]
    for batch in asyncio.run(fan_out(queries)):
        status = "ok" if batch["ok"] else batch["error"]
        print(batch["query"], "->", status, len(batch.get("results", [])))

Two details carry most of the engineering weight. The semaphore is acquired only around the network call, so retries and sleeps do not hold a concurrency slot while they wait. And every result carries its query back, because asyncio.gather preserves submission order, not completion order, and the moment you sort or filter the output list you lose the pairing unless you carry the key with the payload.

When Does Async Actually Pay Off?

The decision framework has one axis, the number of independent requests, and one constraint, the concurrency your API account tolerates. Under roughly ten queries, a sync loop is simpler to write, simpler to debug, and fast enough. Between tens and hundreds of queries, the async version with a semaphore is the difference between minutes and seconds of wall clock. Above that, the constraint is no longer Python, it is the rate limit on your account, and the right move is batching the work into scheduled runs rather than pushing concurrency higher. If you do not know your plan's rate limit, find it out empirically: start with a semaphore of five, watch for 429 responses, and adjust rather than guessing.

What Failure Modes Bite Async Fan-Outs?

Each failure mode here has a detection you can build in. Rate-limit storms: one 429 means back off, a burst of them means your semaphore is above your account's ceiling. Detection: count 429 responses per run and alert when the share crosses a threshold, and lower CONCURRENCY until they stop. Silent partial failure: an exception in one task cancels the whole gather unless you pass return_exceptions=True, so a single bad query can erase ninety good results. Detection: use return_exceptions=True and count non-dict entries, then retry only the failures instead of the whole batch. Lost pairing: results are zipped against a sorted query list and every row gets the wrong sources. Detection: assert that each result's query matches the query you sent, an invariant that fails loudly the first time order is broken. Event-loop misuse: calling asyncio.run inside an already-running loop, which raises RuntimeError the moment your script is embedded in a notebook or a web server. Detection: keep one entry point that owns the loop, and let the rest of the code await.

The same fan-out shape is how agent frameworks wire search into tool calling, a pattern covered in our guide to adding web search to agent tool calling, and the concurrency ceiling you choose here is exactly the knob those frameworks expose.

Related Guides

FAQ

What is async web search in Python? It is the pattern of issuing many web search API calls concurrently with asyncio instead of one at a time, using a semaphore to cap how many are in flight. The goal is to turn total wall clock from the sum of all request latencies into roughly the latency of the slowest small group of them.

Do I need aiohttp or httpx, or is the standard library enough? The standard library's urllib has no async surface, so for true concurrency you need an async client such as httpx or aiohttp, or thread-pool execution of urllib calls with concurrent.futures. Threads work fine for I/O-bound fan-outs if you prefer zero dependencies, at the cost of manual pairing of results to queries.

How do I pick the right concurrency limit? Start at five, watch for HTTP 429 responses, and adjust from there. The correct ceiling is a property of your API account, not of Python, so no fixed number is universally right and the empirical ramp is the honest method.

Why did my gather return fewer results than I sent queries? Most often one task raised an exception and you did not pass return_exceptions=True, so the exception propagated and cancelled the siblings, or your retry path exhausted its attempts and returned an error record. Count your ok and error records per run and the missing ones identify themselves.

Should the retry sleep inside the semaphore? No. Hold the semaphore only for the network call and release it before sleeping, otherwise a backing-off task blocks a concurrency slot doing nothing, which serializes your fan-out exactly when the API is asking you to slow down.

    Share Article:

  1. LI Test

  2. LI Test

Related resources.

How to Feed Real-Time Data to an LLM with the You.com Web Search API

October 9, 2026

Blog

How to Parse Search Results JSON in Python with the You.com Web Search API

October 9, 2026

Blog

How to Retrieve Version-Specific Library Docs with the You.com Web Search and Contents APIs

October 9, 2026

Blog

How to Work Around LLM Knowledge Cutoffs with the You.com Web Search API

October 9, 2026

Blog

How to Get Search Results Programmatically with the You.com Web Search API

October 8, 2026

Blog