Blog
 / 
API
October 2, 2026

How to Use the You.com Web Search API in Rust

How to Use the You.com Web Search API in Rust

TLDR: Call the You.com Web Search API from Rust by sending a JSON POST to https://ydc-index.io/v1/search with an X-API-Key header, using one reusable reqwest client with explicit timeouts. Model every response field as optional, because the API documents none as required, and retry only 429, 5xx, and network failures. The code targets reqwest 0.13.5, covers blocking and async clients, and includes tests that run without an API key.

The You.com docs list a REST reference and a Python SDK but no Rust SDK, so a Rust service calls the endpoint directly. That is a small job with reqwest and serde, and three decisions determine whether it holds up: which response fields you can rely on, how long a stalled call may hold a worker, and which failures deserve another attempt. This guide builds one client around those decisions and tests it against a local mock server instead of live calls.

The request contract is the one our Go, Python, and TypeScript guides use. This guide covers what is specific to Rust.

What do you need before you call the API?

You need an API key from the You.com platform, a Rust toolchain at 1.85 or later, and two crates. Release 0.13.5, from September 8, 2026, is the latest reqwest, and 1.85 is the rust-version it declares on crates.io. The 0.13 line changed defaults: per the changelog, rustls is now the default TLS backend, and the query and form features are opt-in. Neither affects a JSON POST, so the dependency line needs only the blocking and json features.

[package]
name = "ydc-search"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.13.5", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }

Export the key before you run anything and keep it out of source. YDC_API_KEY is the variable name the You.com docs use in every sample. API keys are scoped per product, so a key without Web Search access gets a 403 with Missing required scopes, per the error reference.

export YDC_API_KEY="your-you-com-api-key"

What do the request and response look like?

The endpoint is POST https://ydc-index.io/v1/search, with the key in an X-API-Key header and a JSON body, per the OpenAPI spec. Only query is required. These are the parameters a service usually touches:

ParameterValuesNotes
queryStringRequired. Search operators are allowed.
count1 to 100, default 10Maximum results per section, and the sections are web and news.
offset0 to 9, default 0Counted in multiples of count.
freshnessday, week, month, year, or a YYYY-MM-DDtoYYYY-MM-DD rangeLimits results to a recent window.
include_domains, exclude_domains, boost_domainsUp to 500 domains eachinclude_domains cannot be combined with the other two. The API returns 422.
extractionObject with extraction_modePOST only. highlights or full_page. It supersedes the deprecated livecrawl.

The response holds up to three sections under results, which are web, news, and knowledge, plus a metadata object. The spec marks none of them required, and it lists no required field on a web or news result either. A web result can carry url, title, description, snippets, page_age, thumbnail_url, favicon_url, and contents. The snippets field is omitted when the request asks for highlights, which arrive in contents.highlights instead.

How do you model the response so one sparse result does not fail the call?

The derive macro in serde ignores unknown fields by default for formats like JSON, per the container attributes docs, so new fields from the API never break decoding. Missing fields are the opposite risk. A String field makes the whole Response::json call fail when one result lacks it, and you lose the other results with it. Because the spec requires no result fields, every field below is an Option, arrays default to empty, and the results key itself defaults to an empty section.

use std::env;
use std::error::Error;
use std::fmt;
use std::thread;
use std::time::Duration;

use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};

const BASE_URL: &str = "https://ydc-index.io";
const MAX_WAIT: Duration = Duration::from_secs(30);

#[derive(Serialize)]
struct SearchRequest<'a> {
    query: &'a str,
    #[serde(skip_serializing_if = "Option::is_none")]
    count: Option<u32>,
}

// The API documents no required response fields, so every field is optional here.
#[derive(Debug, Default, Deserialize)]
struct SearchResponse {
    #[serde(default)]
    results: Results,
}

#[derive(Debug, Default, Deserialize)]
struct Results {
    #[serde(default)]
    web: Vec<WebResult>,
    #[serde(default)]
    news: Vec<NewsResult>,
}

#[derive(Debug, Deserialize)]
struct WebResult {
    url: Option<String>,
    title: Option<String>,
    description: Option<String>,
    #[serde(default)]
    snippets: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct NewsResult {
    url: Option<String>,
    title: Option<String>,
    page_age: Option<String>,
}

These types decode two real responses captured from the keyless MCP profile on October 6, 2026, one requested with highlights and one with snippets, and they decode a result that has only a title. The model keeps just the fields the program prints. Add contents and page_age when you need them, and keep them optional too.

Which errors deserve a retry?

Retry only failures that waiting can fix. The error reference documents these outcomes for the Search API:

OutcomeWhat it meansRetry?
401The key is missing, invalid, or expiredNo
402Keyed access is out of creditsNo
403The key lacks scope for the path, or the path is on the wrong hostNo
422An invalid parameter combination, such as include_domains with exclude_domainsNo
429The rate limit was exceededYes, after the delay
500 and other 5xxA server-side failure, including auth middleware errorsYes, with backoff
Timeout or connect failureNo HTTP response arrivedYes, with backoff

The code keeps that policy in two methods on the error type. is_retryable encodes the table, and a decode failure is never retried because a second attempt returns the same body. delay prefers the server's Retry-After value and otherwise backs off from 500 milliseconds up to an 8-second cap. RFC 9110 defines Retry-After as either a number of seconds or an HTTP date. parse_retry_after handles the seconds form and returns None for a date, so the backoff applies instead. MAX_WAIT caps a server-supplied delay so a bad header cannot park a worker for an hour.

#[derive(Debug)]
enum SearchError {
    /// No HTTP response: DNS, connect, TLS, or timeout failure.
    Transport(reqwest::Error),
    /// A non-2xx answer, with the Retry-After delay if the server sent one.
    Status {
        status: StatusCode,
        retry_after: Option<Duration>,
        body: String,
    },
    /// A 2xx answer whose body did not match the response types.
    Decode(reqwest::Error),
}

impl fmt::Display for SearchError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SearchError::Transport(e) => write!(f, "request failed: {e}"),
            SearchError::Status { status, body, .. } => {
                write!(f, "search failed with HTTP {status}: {body}")
            }
            SearchError::Decode(e) => write!(f, "response did not match the expected shape: {e}"),
        }
    }
}

impl Error for SearchError {}

impl SearchError {
    fn is_retryable(&self) -> bool {
        match self {
            SearchError::Transport(e) => e.is_timeout() || e.is_connect(),
            SearchError::Status { status, .. } => {
                *status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
            }
            SearchError::Decode(_) => false,
        }
    }

    fn delay(&self, attempt: u32) -> Duration {
        if let SearchError::Status { retry_after: Some(wait), .. } = self {
            return (*wait).min(MAX_WAIT);
        }
        // 500 ms, 1 s, 2 s, 4 s, then capped at 8 s.
        let factor = 2u64.saturating_pow(attempt.saturating_sub(1));
        Duration::from_millis(500u64.saturating_mul(factor)).min(Duration::from_secs(8))
    }
}

// Retry-After is delay-seconds or an HTTP date; this handles the seconds form only.
fn parse_retry_after(value: &HeaderValue) -> Option<Duration> {
    value.to_str().ok()?.trim().parse::<u64>().ok().map(Duration::from_secs)
}

Parallelism is the realistic source of 429s. The rate limits page lists 10 requests per second as the default for self-serve accounts on /v1/search, so one worker rarely hits it and a pool of workers sharing a key can. Give them a shared limiter and treat retries as the backstop.

How do you build a client you can share?

Build one client per process. The reqwest docs say the client holds a connection pool internally, so creating one and reusing it is the advice, and it is why SearchClient owns the Client instead of building one per call.

Set the timeouts yourself. On the blocking builder, timeout defaults to 30 seconds, while the async builder's total timeout defaults to none and its connect_timeout has no default. A search call that waits on a stalled connection holds a worker for as long as you allow, so the example sets 5 seconds to connect and a 15-second request timeout.

The key goes in a default header marked sensitive. The reqwest docs suggest marking security-sensitive default headers that way, and set_sensitive(true) makes the value's Debug output print Sensitive instead of the key. The key never appears in the URL or the body, so request logs that record those do not capture it. The base URL is a constructor argument for one reason: the tests below point it at a local server.

struct SearchClient {
    http: Client,
    base_url: String,
}

impl SearchClient {
    fn new(api_key: &str, base_url: &str) -> Result<Self, Box<dyn Error>> {
        let mut key = HeaderValue::from_str(api_key.trim())?;
        key.set_sensitive(true);
        let mut headers = HeaderMap::new();
        headers.insert("X-API-Key", key);

        let http = Client::builder()
            .default_headers(headers)
            .connect_timeout(Duration::from_secs(5))
            .timeout(Duration::from_secs(15))
            .user_agent("rust-search-example/0.1")
            .build()?;

        Ok(Self {
            http,
            base_url: base_url.trim_end_matches('/').to_string(),
        })
    }

    fn search(&self, query: &str, count: Option<u32>) -> Result<SearchResponse, SearchError> {
        let resp = self
            .http
            .post(format!("{}/v1/search", self.base_url))
            .json(&SearchRequest { query, count })
            .send()
            .map_err(SearchError::Transport)?;

        let status = resp.status();
        if !status.is_success() {
            let retry_after = resp.headers().get(RETRY_AFTER).and_then(parse_retry_after);
            let body: String = resp.text().unwrap_or_default().chars().take(300).collect();
            return Err(SearchError::Status { status, retry_after, body });
        }
        resp.json::<SearchResponse>().map_err(SearchError::Decode)
    }

    fn search_with_retry(
        &self,
        query: &str,
        count: Option<u32>,
        max_attempts: u32,
    ) -> Result<SearchResponse, SearchError> {
        let mut attempt = 1;
        loop {
            match self.search(query, count) {
                Ok(response) => return Ok(response),
                Err(err) if err.is_retryable() && attempt < max_attempts => {
                    thread::sleep(err.delay(attempt));
                    attempt += 1;
                }
                Err(err) => return Err(err),
            }
        }
    }
}

search returns a typed SearchError and not a string. A non-2xx answer keeps its status, the parsed Retry-After delay, and up to 300 characters of the body, because the body usually says why. The error reference documents bodies such as {"detail": "Invalid or expired API key"}. A 200 whose body does not match the types is a separate Decode variant, since that is a different problem from a 429.

The program reads the key once and exits with code 2 if it is missing or blank. A configuration error belongs in the first second of startup, not on the first search in a pod where the secret never mounted. Output falls back for absent fields instead of unwrapping them.

fn main() {
    let api_key = match env::var("YDC_API_KEY") {
        Ok(key) if !key.trim().is_empty() => key,
        _ => {
            eprintln!("error: set YDC_API_KEY before running");
            std::process::exit(2);
        }
    };
    let client = SearchClient::new(&api_key, BASE_URL).unwrap_or_else(|e| {
        eprintln!("error: {e}");
        std::process::exit(2);
    });

    match client.search_with_retry("rust async cancellation safety", Some(5), 3) {
        Ok(response) => {
            for (i, hit) in response.results.web.iter().enumerate() {
                let summary = hit
                    .description
                    .as_deref()
                    .or_else(|| hit.snippets.first().map(String::as_str))
                    .unwrap_or("");
                println!(
                    "{}. {}\n   {}\n   {}",
                    i + 1,
                    hit.title.as_deref().unwrap_or("(untitled)"),
                    hit.url.as_deref().unwrap_or("(no url)"),
                    summary
                );
            }
            for item in &response.results.news {
                println!(
                    "news: {} {} ({})",
                    item.title.as_deref().unwrap_or("(untitled)"),
                    item.url.as_deref().unwrap_or("(no url)"),
                    item.page_age.as_deref().unwrap_or("undated")
                );
            }
        }
        Err(err) => {
            eprintln!("error: {err}");
            std::process::exit(1);
        }
    }
}

Should you use the blocking or the async client?

Use the one that matches your runtime. The reqwest blocking docs say its functionality must not run inside an async runtime or it will panic, and suggest the async client or tokio::task::spawn_blocking instead. A test confirmed it: using a blocking client inside a multi-threaded tokio runtime panicked with Cannot drop a runtime in a context where blocking is not allowed. A tokio service should use the async client.

Everything above carries over: the types, the error enum, and the retry policy. Make four changes. Import reqwest::Client instead of the blocking one, drop the std::thread import, add tokio to Cargo.toml, and make main async with #[tokio::main], which needs the macros and rt features and rt-multi-thread for its default flavor, per the tokio docs. Await the search_with_retry call in main, then replace the two methods that touch the network:

[dependencies]
reqwest = { version = "0.13.5", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
    async fn search(&self, query: &str, count: Option<u32>) -> Result<SearchResponse, SearchError> {
        let resp = self
            .http
            .post(format!("{}/v1/search", self.base_url))
            .json(&SearchRequest { query, count })
            .send()
            .await
            .map_err(SearchError::Transport)?;

        let status = resp.status();
        if !status.is_success() {
            let retry_after = resp.headers().get(RETRY_AFTER).and_then(parse_retry_after);
            let body: String = resp.text().await.unwrap_or_default().chars().take(300).collect();
            return Err(SearchError::Status { status, retry_after, body });
        }
        resp.json::<SearchResponse>().await.map_err(SearchError::Decode)
    }

    async fn search_with_retry(
        &self,
        query: &str,
        count: Option<u32>,
        max_attempts: u32,
    ) -> Result<SearchResponse, SearchError> {
        let mut attempt = 1;
        loop {
            match self.search(query, count).await {
                Ok(response) => return Ok(response),
                Err(err) if err.is_retryable() && attempt < max_attempts => {
                    tokio::time::sleep(err.delay(attempt)).await;
                    attempt += 1;
                }
                Err(err) => return Err(err),
            }
        }
    }

The timeout defaults flip here, so the explicit timeouts in the builder matter more. The async client would wait indefinitely on a stalled response without them.

How do you test the client without calling the API?

Point the client at a local server. The tests below start a TcpListener on 127.0.0.1, answer each connection with one canned response, and record every request head, so a test can assert both what the client did and what it sent. Nothing leaves your machine, and the key in the tests is a placeholder. Append the module to the program and run cargo test.

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::{Read, Write};
    use std::net::{TcpListener, TcpStream};
    use std::sync::{Arc, Mutex};

    // Read one HTTP request: the head, then Content-Length bytes of body.
    fn read_request(stream: &mut TcpStream) -> String {
        let mut data = Vec::new();
        let mut buf = [0u8; 1024];
        loop {
            let n = stream.read(&mut buf).unwrap_or(0);
            if n == 0 {
                return String::from_utf8_lossy(&data).to_lowercase();
            }
            data.extend_from_slice(&buf[..n]);
            if let Some(end) = data.windows(4).position(|w| w == b"\r\n\r\n") {
                let head = String::from_utf8_lossy(&data[..end]).to_lowercase();
                let length = head
                    .lines()
                    .find_map(|line| line.strip_prefix("content-length:"))
                    .and_then(|v| v.trim().parse::<usize>().ok())
                    .unwrap_or(0);
                let mut missing = length.saturating_sub(data.len() - (end + 4));
                while missing > 0 {
                    let n = stream.read(&mut buf).unwrap_or(0);
                    if n == 0 {
                        break;
                    }
                    missing = missing.saturating_sub(n);
                }
                return head;
            }
        }
    }

    // Serve one canned response per connection and record each request head.
    fn serve(responses: Vec<String>) -> (String, Arc<Mutex<Vec<String>>>) {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let base_url = format!("http://{}", listener.local_addr().unwrap());
        let seen = Arc::new(Mutex::new(Vec::new()));
        let log = Arc::clone(&seen);
        thread::spawn(move || {
            for response in responses {
                let (mut stream, _) = listener.accept().unwrap();
                log.lock().unwrap().push(read_request(&mut stream));
                stream.write_all(response.as_bytes()).unwrap();
            }
        });
        (base_url, seen)
    }

    fn reply(status: &str, extra_headers: &str, body: &str) -> String {
        format!(
            "HTTP/1.1 {status}\r\n{extra_headers}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
            body.len()
        )
    }

    #[test]
    fn sparse_results_decode_and_the_key_is_sent() {
        let body = r#"{"results":{"web":[{"url":"https://a.example"},{"title":"No URL"}]}}"#;
        let (base, seen) = serve(vec![reply("200 OK", "", body)]);
        let client = SearchClient::new("test-key", &base).unwrap();

        let response = client.search("rust", Some(2)).unwrap();

        assert_eq!(response.results.web.len(), 2);
        assert_eq!(response.results.web[0].url.as_deref(), Some("https://a.example"));
        assert!(response.results.web[1].url.is_none());
        assert!(response.results.news.is_empty());
        let head = seen.lock().unwrap()[0].clone();
        assert!(head.starts_with("post /v1/search"));
        assert!(head.contains("x-api-key: test-key"));
    }

    #[test]
    fn a_429_is_retried_after_the_server_delay() {
        let ok = r#"{"results":{"web":[]}}"#;
        let (base, seen) = serve(vec![
            reply("429 Too Many Requests", "Retry-After: 0\r\n", "{}"),
            reply("200 OK", "", ok),
        ]);
        let client = SearchClient::new("test-key", &base).unwrap();

        assert!(client.search_with_retry("rust", None, 3).is_ok());
        assert_eq!(seen.lock().unwrap().len(), 2);
    }

    #[test]
    fn a_401_is_never_retried() {
        let denied = reply("401 Unauthorized", "", r#"{"detail":"Invalid or expired API key"}"#);
        let (base, seen) = serve(vec![denied.clone(), denied.clone(), denied]);
        let client = SearchClient::new("bad-key", &base).unwrap();

        match client.search_with_retry("rust", None, 3) {
            Err(SearchError::Status { status, .. }) => assert_eq!(status, StatusCode::UNAUTHORIZED),
            other => panic!("expected a 401, got {other:?}"),
        }
        assert_eq!(seen.lock().unwrap().len(), 1);
    }

    #[test]
    fn retries_stop_at_the_attempt_limit() {
        let down = reply("503 Service Unavailable", "Retry-After: 0\r\n", "{}");
        let (base, seen) = serve(vec![down.clone(), down.clone(), down]);
        let client = SearchClient::new("test-key", &base).unwrap();

        assert!(client.search_with_retry("rust", None, 3).is_err());
        assert_eq!(seen.lock().unwrap().len(), 3);
    }
}

Each test pins one decision. The first proves a sparse result decodes and that the X-API-Key header and the /v1/search path are what the server saw. The second proves a 429 with Retry-After: 0 is retried once and then succeeds, with no sleeping in the suite. The third proves a 401 is attempted exactly once. The fourth proves retries stop at the attempt limit. These tests target the blocking client. For the async client, mark each one #[tokio::test], add .await to the client calls, and import std::thread inside the module.

Example verification: These examples were compiled and tested on stable Rust 1.99.0 with reqwest 0.13.5. The four tests pass, the types decode the two captured responses, and the async variant compiles and passes its own 429 and 401 tests. The checks use a local mock server and payloads captured from the keyless MCP profile, which returns the same results structure, with no live authenticated calls. They verify client behavior, not search relevance, billing, or production latency.

What should you check before you ship?

  • Secrets. Read the key from the environment, keep the header sensitive, and keep it out of any log line you write yourself.
  • Timeouts. Set a connect timeout and a request timeout on every client, because the blocking and async defaults differ.
  • Concurrency. The documented default is 10 requests per second on /v1/search for self-serve accounts, so workers that share an account share that budget. Add a shared limiter before you scale out.
  • Cost. The billing page prices a Web Search API call at $5.00 per 1,000 as of October 2026, which is $0.005 per search, and full-page extraction adds $1.00 per 1,000 pages crawled live. Cache repeated queries, and request only the count you read.
  • Versions. Commit Cargo.lock and rerun the tests when you bump reqwest. The step from 0.12 to 0.13 changed defaults.

For every field the API can return, see the JSON response guide, and for endpoint limits and pricing see the Web Search API guide. If a model, not your code, decides when to search, agent tool calling with a search API covers that design.

Related Guides

    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