
TLDR: Call the You.com Web Search API from Java by sending a JSON POST to https://ydc-index.io/v1/search with an X-API-Key header, using one shared JDK HttpClient with explicit timeouts. Decode the response with Jackson records that ignore unknown fields, because Jackson 2 fails on them by default, and retry only 429, 5xx, and network failures. The code needs Java 17 or later and includes checks that run without an API key.
A Java service can call the Web Search API with nothing but the JDK's HttpClient and a JSON library. The You.com docs list a REST reference and a Python SDK but no Java SDK, so that is the supported path. Three details decide whether the integration holds up: how you decode a response whose fields are all optional, how long a stalled call can hold a thread, and which failures deserve another attempt. The sections below take them in order and finish with checks that run against a local mock server.
The request contract is the one our Python, TypeScript, and Go guides use, and the cURL guide is the quickest way to look at the raw JSON. This guide covers what is specific to the JVM.
What do you need before you call the API?
Use Java 17 or later. The java.net.http client has been standard since Java 11, per JEP 321, and the response types below are records, which became final in Java 16, per JEP 395. Jackson 3 also requires Java 17. For JSON, Jackson has two active lines. Maven Central lists 2.22.3 and 3.2.3 as the latest releases, and they differ in packages and defaults. This guide uses 2.x, whose source the code was checked against, and a later section lists what changes on 3.x.
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.3</version>
</dependency>
Create a key on the You.com platform and export it before you run anything. 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. The other parameters a service usually touches:
| Parameter | Values | Notes |
|---|---|---|
count | 1 to 100, default 10 | Maximum results per section, and the sections are web and news. |
offset | 0 to 9, default 0 | Counted in multiples of count. |
freshness | day, week, month, year, or a date range | See the date filter guide. |
include_domains, exclude_domains, boost_domains | Up to 500 domains each | include_domains cannot be combined with the other two. The API returns 422. |
extraction | Object with extraction_mode | POST only. highlights or full_page. |
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. 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 decode the response without breaking on new fields?
Jackson 2 fails on unknown properties unless you tell it not to. The source for FAIL_ON_UNKNOWN_PROPERTIES says the feature is enabled by default, meaning a JsonMappingException is thrown when an unknown property appears. A real response carries page_age, thumbnail_url, favicon_url, and a top-level metadata object, so a record that declares only title and url throws on the first search. Annotating each record with @JsonIgnoreProperties(ignoreUnknown = true) fixes that, and it also keeps the code working when the API adds fields.
Missing fields are the other half. Jackson has bound records since 2.12, and when a constructor property is absent from the JSON it passes null for reference types, because FAIL_ON_MISSING_CREATOR_PROPERTIES is disabled by default in the same source file. The spec requires no result field, so the compact constructors below turn absent lists into empty lists and an absent results object into an empty one. A sparse result then decodes instead of failing the whole response. The one snake_case field the program reads, page_age, is mapped with @JsonProperty.
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
import java.util.List;
public final class WebSearch {
public record SearchRequest(String query, int count) {}
// Every response record ignores unknown fields and tolerates missing ones.
@JsonIgnoreProperties(ignoreUnknown = true)
public record SearchResponse(Results results) {
public SearchResponse {
results = results == null ? new Results(null, null) : results;
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Results(List<WebResult> web, List<NewsResult> news) {
public Results {
web = web == null ? List.of() : web;
news = news == null ? List.of() : news;
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record WebResult(String url, String title, String description, List<String> snippets) {
public WebResult {
snippets = snippets == null ? List.of() : snippets;
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record NewsResult(String url, String title, @JsonProperty("page_age") String pageAge) {}
private static final ObjectMapper MAPPER = new ObjectMapper(); // thread-safe once configured
private final SearchClient client;
public WebSearch(SearchClient client) {
this.client = client;
}
public SearchResponse search(String query, int count) throws IOException, InterruptedException {
String body = MAPPER.writeValueAsString(new SearchRequest(query, count));
return MAPPER.readValue(client.post(body), SearchResponse.class);
}
public static void main(String[] args) throws Exception {
String key = System.getenv("YDC_API_KEY");
if (key == null || key.isBlank()) {
System.err.println("error: set YDC_API_KEY before running");
System.exit(2);
}
SearchClient client = new SearchClient(
key.trim(), URI.create("https://ydc-index.io/v1/search"), Duration.ofSeconds(15), 3);
SearchResponse response = new WebSearch(client).search("java http client retry", 5);
int i = 1;
for (WebResult hit : response.results().web()) {
String summary = hit.description() != null ? hit.description()
: hit.snippets().isEmpty() ? "" : hit.snippets().get(0);
System.out.printf("%d. %s%n %s%n %s%n", i++,
hit.title() != null ? hit.title() : "(untitled)",
hit.url() != null ? hit.url() : "(no url)", summary);
}
for (NewsResult item : response.results().news()) {
System.out.printf("news: %s %s (%s)%n",
item.title() != null ? item.title() : "(untitled)",
item.url() != null ? item.url() : "(no url)",
item.pageAge() != null ? item.pageAge() : "undated");
}
}
}
The mapper is a shared static because, per its Javadoc, a Jackson mapper is thread-safe once its configuration is finished. The decode lives in WebSearch and the network in SearchClient, which takes a JSON string and returns one. That split keeps the transport free of Jackson, so the checks below can exercise it with nothing but the JDK.
On Jackson 3: the 3.0 release notes list four changes that touch this code. Group ids and Java packages move from com.fasterxml.jackson to tools.jackson, except jackson-annotations, which keeps its package. FAIL_ON_UNKNOWN_PROPERTIES now defaults to false. Exceptions are unchecked JacksonException types. Mappers come from JsonMapper.builder(). Keep the annotations anyway, so the same records behave the same on both lines.
Which failures deserve a retry?
Retry only failures that waiting can fix. The error reference documents these outcomes for the Search API, and the last two rows are how the JDK client reports a network failure:
| Outcome | What it means | Retry? |
|---|---|---|
| 401 | The key is missing, invalid, or expired | No |
| 402 | Keyed access is out of credits | No |
| 403 | The key lacks scope for the path, or the path is on the wrong host | No |
| 422 | An invalid parameter combination, such as include_domains with exclude_domains | No |
| 429 | The rate limit was exceeded | Yes, after the delay |
| 500 and other 5xx | A server-side failure, including auth middleware errors | Yes, with backoff |
HttpTimeoutException | The request or connect timeout elapsed | Yes, with backoff |
ConnectException | The connection was refused | Yes, with backoff |
The client encodes that table in two places: retryable for statuses and the multi-catch for timeouts and refused connections. A server-supplied delay wins over the computed backoff. RFC 9110 defines Retry-After as either a number of seconds or an HTTP date. The client reads the seconds form and treats a date or a malformed value as absent, so the backoff of 500 milliseconds, doubling to an 8-second cap, applies instead. MAX_WAIT caps a server-supplied delay at 30 seconds so a bad header cannot park a thread for an hour.
Two Java details matter. Other IOException types, such as a TLS failure, are not retried, because waiting does not fix them. And Thread.sleep throws InterruptedException, which the method declares and does not swallow, so a shutdown signal interrupts a pending retry instead of being ignored. The rate limits page lists 10 requests per second as the default for self-serve accounts on /v1/search, so parallel workers sharing a key are the realistic source of 429s.
How do you build a client you can share?
Build one HttpClient per client object and reuse it. The Javadoc says an HttpClient is immutable once built, can send multiple requests, and typically manages its own connection pools, which are not shared between instances, so a client per call throws that reuse away.
Set both timeouts yourself. Per the Javadoc, not setting a request timeout is the same as an infinite duration, which blocks forever. A connect timeout applies only when a new connection must be established, and an expired one raises HttpConnectTimeoutException, per the builder docs. A stalled upstream would otherwise hold the calling thread for as long as the connection lives, so the example sets 5 seconds to connect and takes the request timeout as a constructor argument.
import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
public final class SearchClient {
/** A non-2xx answer: the status, the Retry-After delay if one was sent, and a bounded slice of the body. */
public static final class ApiException extends RuntimeException {
private final int status;
private final Duration retryAfter; // null if absent or sent as an HTTP date
ApiException(int status, Duration retryAfter, String body) {
super("search failed with HTTP " + status + ": " + body);
this.status = status;
this.retryAfter = retryAfter;
}
public int status() {
return status;
}
Duration retryAfter() {
return retryAfter;
}
}
private static final Duration MAX_WAIT = Duration.ofSeconds(30);
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final URI endpoint;
private final String apiKey;
private final Duration requestTimeout;
private final int maxAttempts;
public SearchClient(String apiKey, URI endpoint, Duration requestTimeout, int maxAttempts) {
this.apiKey = apiKey;
this.endpoint = endpoint;
this.requestTimeout = requestTimeout;
this.maxAttempts = maxAttempts;
}
/** POSTs a JSON body and returns the body of the 200 response, retrying only what waiting can fix. */
public String post(String json) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(endpoint)
.timeout(requestTimeout)
.header("Content-Type", "application/json")
.header("X-API-Key", apiKey)
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
for (int attempt = 1; ; attempt++) {
Duration wait;
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return response.body();
}
ApiException failure = new ApiException(
response.statusCode(), retryAfter(response), preview(response.body()));
if (!retryable(failure.status()) || attempt >= maxAttempts) {
throw failure;
}
wait = failure.retryAfter() != null ? failure.retryAfter() : backoff(attempt);
} catch (HttpTimeoutException | ConnectException e) {
if (attempt >= maxAttempts) {
throw e;
}
wait = backoff(attempt);
}
Thread.sleep(wait.toMillis());
}
}
private static boolean retryable(int status) {
return status == 429 || status >= 500;
}
private static Duration backoff(int attempt) {
long millis = 500L << Math.min(attempt - 1, 4); // 500 ms, 1 s, 2 s, 4 s, then 8 s
return Duration.ofMillis(Math.min(millis, 8_000L));
}
// Retry-After is delay-seconds or an HTTP date; this reads the seconds form only.
private static Duration retryAfter(HttpResponse<?> response) {
String value = response.headers().firstValue("Retry-After").orElse("").trim();
try {
long seconds = Long.parseLong(value);
if (seconds < 0) {
return null;
}
Duration wait = Duration.ofSeconds(seconds);
return wait.compareTo(MAX_WAIT) > 0 ? MAX_WAIT : wait;
} catch (NumberFormatException e) {
return null;
}
}
private static String preview(String body) {
return body.length() <= 300 ? body : body.substring(0, 300);
}
}
A non-2xx answer becomes an ApiException that keeps the 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"}. The status stays an int on the exception so callers can branch on it without parsing the message.
main in WebSearch 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 container where the secret never mounted. Its output falls back for absent fields instead of dereferencing nulls.
Should you block, go async, or use virtual threads?
send blocks the calling thread until the response or a timeout arrives. sendAsync takes the same request and returns a CompletableFuture that completes exceptionally with an HttpTimeoutException when the timeout elapses. On a small platform thread pool, a slow search holds a pool thread, so async is the safer shape there. On Java 21 and later, virtual threads, final in JEP 444, are suspended instead of holding an operating system thread when they block on I/O, and the JEP says straightforward blocking code scales well on them, so the loop above can stay as it is.
If you choose async, the retry policy carries over unchanged: the status table, the delay calculation, and the attempt limit. Only the waiting changes, because a retry on a CompletableFuture needs a scheduler instead of Thread.sleep.
How do you test the client without calling the API?
Start a server inside the test. The JDK ships com.sun.net.httpserver.HttpServer, and the class below wraps it in a mock that answers each request with the next canned response, optionally after a delay, and records the method, path, key, and content type it saw. The client under test points at 127.0.0.1, nothing leaves your machine, and the key is a placeholder. The checks are plain Java so they run with no test framework. Each check ports to a JUnit test method by swapping check for an assertion.
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
public final class ClientChecks {
record Canned(int status, String retryAfter, String body, long delayMillis) {}
/** A local server that answers each request with the next canned response and records what it saw. */
static final class MockApi implements AutoCloseable {
final HttpServer server;
final List<String> seen = new CopyOnWriteArrayList<>();
MockApi(Canned... responses) throws IOException {
Iterator<Canned> script = List.of(responses).iterator();
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(Executors.newFixedThreadPool(2));
server.createContext("/v1/search", exchange -> {
Canned next;
synchronized (script) {
next = script.hasNext() ? script.next() : responses[responses.length - 1];
}
seen.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath()
+ " key=" + exchange.getRequestHeaders().getFirst("X-API-Key")
+ " type=" + exchange.getRequestHeaders().getFirst("Content-Type"));
exchange.getRequestBody().readAllBytes();
try {
Thread.sleep(next.delayMillis());
byte[] body = next.body().getBytes(StandardCharsets.UTF_8);
if (next.retryAfter() != null) {
exchange.getResponseHeaders().add("Retry-After", next.retryAfter());
}
exchange.sendResponseHeaders(next.status(), body.length);
exchange.getResponseBody().write(body);
} catch (IOException | InterruptedException e) {
// The client gave up on a delayed response; nothing to send.
} finally {
exchange.close();
}
});
server.start();
}
URI uri() {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/search");
}
@Override
public void close() {
server.stop(0);
}
}
static int failures = 0;
static void check(boolean condition, String name) {
System.out.println((condition ? "PASS " : "FAIL ") + name);
if (!condition) {
failures++;
}
}
public static void main(String[] args) throws Exception {
try (MockApi api = new MockApi(new Canned(200, null, "{\"results\":{}}", 0))) {
SearchClient client = new SearchClient("test-key", api.uri(), Duration.ofSeconds(5), 3);
String body = client.post("{\"query\":\"java\",\"count\":2}");
check(body.equals("{\"results\":{}}"), "a 200 returns the body");
check(api.seen.equals(List.of("POST /v1/search key=test-key type=application/json")),
"the key and content type are sent");
}
try (MockApi api = new MockApi(new Canned(429, "0", "{}", 0), new Canned(200, null, "{\"ok\":1}", 0))) {
SearchClient client = new SearchClient("test-key", api.uri(), Duration.ofSeconds(5), 3);
check(client.post("{}").equals("{\"ok\":1}") && api.seen.size() == 2,
"a 429 is retried after the server's delay, then succeeds");
}
try (MockApi api = new MockApi(new Canned(401, null, "{\"detail\":\"Invalid or expired API key\"}", 0))) {
SearchClient client = new SearchClient("bad-key", api.uri(), Duration.ofSeconds(5), 3);
try {
client.post("{}");
check(false, "a 401 is thrown");
} catch (SearchClient.ApiException e) {
check(e.status() == 401 && api.seen.size() == 1, "a 401 is attempted exactly once");
}
}
try (MockApi api = new MockApi(new Canned(503, "0", "{}", 0))) {
SearchClient client = new SearchClient("test-key", api.uri(), Duration.ofSeconds(5), 3);
try {
client.post("{}");
check(false, "a 503 is thrown");
} catch (SearchClient.ApiException e) {
check(e.status() == 503 && api.seen.size() == 3, "retries stop at the attempt limit");
}
}
try (MockApi api = new MockApi(new Canned(200, null, "{\"slow\":1}", 700), new Canned(200, null, "{\"ok\":1}", 0))) {
SearchClient client = new SearchClient("test-key", api.uri(), Duration.ofMillis(250), 2);
check(client.post("{}").equals("{\"ok\":1}") && api.seen.size() == 2, "a request timeout is retried");
}
int closedPort;
try (ServerSocket socket = new ServerSocket(0)) {
closedPort = socket.getLocalPort();
}
SearchClient refused = new SearchClient("test-key",
URI.create("http://127.0.0.1:" + closedPort + "/v1/search"), Duration.ofSeconds(2), 2);
long started = System.nanoTime();
try {
refused.post("{}");
check(false, "a refused connection is thrown");
} catch (ConnectException e) {
long elapsedMillis = (System.nanoTime() - started) / 1_000_000;
check(elapsedMillis >= 450, "a refused connection is retried once, then surfaced");
}
System.exit(failures == 0 ? 0 : 1);
}
}
The seven checks pin one decision each. A 200 returns its body and sends the key and content type. A 429 with Retry-After: 0 is retried once and then succeeds, with no sleeping. A 401 is attempted exactly once. A 503 is retried until the attempt limit and then surfaced with its status. A response slower than the request timeout is retried. A refused connection is retried once and then thrown as a ConnectException, with the elapsed time proving a backoff happened.
Example verification: SearchClient and these checks were compiled and run on OpenJDK 21 against the real HttpClient and HttpServer, and all seven pass. The Jackson layer was compiled against signature-accurate stand-ins taken from the Jackson 2.x source and was not executed, because Jackson was not on the classpath of the environment that ran the checks. Decode one captured response with your own Jackson version before relying on it. None of this checks live relevance, billing, or production latency, and no live authenticated call was made.
What should you check before you ship?
- Secrets. Read the key from the environment and keep it out of any log line you write. Logging the query, status, latency, and retry count is enough to debug a search call.
- Timeouts. Set a connect timeout and a request timeout on every client. The JDK default for the request timeout is none.
- Concurrency. The documented default is 10 requests per second on
/v1/searchfor 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
countyou read. - Versions. Pin Jackson, and know which line you are on. The imports, exception types, and unknown-property default all differ between 2.x and 3.x.
For every field the API can return, see the JSON response guide, and for endpoint limits and pricing see the Web Search API guide.
Related Guides
LI Test
LI Test
Share Article:





