
TLDR: To call the You.com Web Search API from Ruby, POST a JSON body to https://ydc-index.io/v1/search with your key in the X-API-Key header using Net::HTTP, then read results.web from JSON.parse. Net::HTTP waits up to 60 seconds to connect and for each read by default, so set explicit timeouts, check the status code yourself, and retry only 429, 5xx, and transport failures.
The You.com docs ship an official Python SDK and mention no Ruby gem (checked October 2026), so a Ruby application calls the documented REST contract directly, and the standard library covers it with net/http and json. What deserves care is the Ruby-specific behavior around it: what Net::HTTP's timeouts do and do not bound, when it reuses a connection, which exception classes are safe to rescue, and how a failing search should propagate in a long-running process.
If you want the raw HTTP exchange first, the cURL guide walks through it in a terminal, and the Python guide shows the official SDK. The reference article collects the endpoint, limits, and pricing, and the JSON response guide goes deeper on the fields this guide only summarizes.
What do you need before you call the Web Search API from Ruby?
You need an API key from the You.com platform and Ruby 3.3 or later. As of October 2026, ruby-lang.org lists Ruby 4.0 and 3.4 under normal maintenance and 3.3 under security maintenance, with 3.2 at end of life. The samples use only the standard library: net/http, json, openssl, and time.
Authentication is one header, X-API-Key, whose value lives in an environment variable the docs call YDC_API_KEY; a bearer token in Authorization does not work here. In Ruby, ENV.fetch is the right reader, because it raises KeyError with the variable's name when the variable is missing, and it accepts a block that runs instead when you want a friendlier message. Where you call it matters: a fetch inside the search method fails on the first search, not at boot. Read the key once at process start, in an initializer or at the top of a script, and pass it in, so a missing variable stops the deploy instead of a request.
One endpoint does the work: POST https://ydc-index.io/v1/search, with query as the only required field in the JSON body. The Web Search API overview notes that GET still works but gets no new features, so extraction and knowledge are available on POST only.
How do you make the first request?
The file below separates transport (Ydc.search) from interpretation (Ydc.decode), which turns a status, a body, and a Retry-After value into parsed JSON or an exception. It raises its own error classes instead of bare strings, for a reason covered in the retry section:
# frozen_string_literal: true
require 'json'
require 'net/http'
require 'openssl'
require 'time'
module Ydc
ENDPOINT = URI('https://ydc-index.io/v1/search')
TRANSPORT_ERRORS = [
Timeout::Error, IOError, EOFError, SocketError,
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE,
OpenSSL::SSL::SSLError
].freeze
class ApiError < StandardError
attr_reader :status, :body, :retry_after
def initialize(status, body, retry_after = nil)
super("You.com API returned HTTP #{status}")
@status = status
@body = body.to_s[0, 500]
@retry_after = retry_after
end
end
class TransportError < StandardError; end
module_function
# Turns a status, a body, and a Retry-After value into parsed JSON or an exception.
# It never touches the network, so tests can call it with fixtures.
def decode(status, body, retry_after = nil)
raise ApiError.new(status, body, retry_after) unless (200..299).cover?(status)
parsed = JSON.parse(body)
parsed.is_a?(Hash) ? parsed : {}
end
# results.web is optional, so a missing key is an empty list, not an error.
def web_results(parsed)
parsed.dig('results', 'web') || []
end
# Retry-After is either a number of seconds or an HTTP date.
def parse_retry_after(value)
return nil if value.nil?
value = value.strip
return value.to_i if value.match?(/\A\d+\z/)
[(Time.httpdate(value) - Time.now).ceil, 0].max
rescue ArgumentError
nil
end
def with_session(&block)
Net::HTTP.start(
ENDPOINT.host, ENDPOINT.port,
use_ssl: true, open_timeout: 5, read_timeout: 15, write_timeout: 15,
&block
)
end
# Pass http: from with_session to reuse one connection across several searches.
def search(request, api_key, http: nil)
req = Net::HTTP::Post.new(ENDPOINT)
req['Content-Type'] = 'application/json'
req['X-API-Key'] = api_key
req.body = JSON.generate(request)
res = http ? http.request(req) : with_session { |session| session.request(req) }
decode(res.code.to_i, res.body.to_s, parse_retry_after(res['Retry-After']))
rescue *TRANSPORT_ERRORS => e
raise TransportError, "#{e.class}: #{e.message}"
end
end
Net::HTTP.start does the setup work that hand-built Net::HTTP.new code does. The Net::HTTP documentation shows that its options are applied through the matching setters, so open_timeout, read_timeout, and write_timeout in the call configure the session, and use_ssl: true defaults verify_mode to VERIFY_PEER. The timeouts matter because all three start at 60 seconds: open_timeout is the wait for a connection to open, and read_timeout and write_timeout are the wait for one block to be read or written, via one read(2) or write(2) call. The sample's 5 and 15 are examples, not a measured latency or a service guarantee.
Per-block timeouts are not a deadline for the whole request. Because the limit applies to each read, a response that trickles in a block at a time can outlast it, and the timeouts Net::HTTP documents (open, read, write, SSL, continue, and keep-alive) do not include one for the whole request. If you need a hard end-to-end limit, use a client that offers one: Faraday's timeout option is documented as the maximum number of seconds to wait for the request to complete.
Two more defaults are easy to trip over. Net::HTTPResponse#code is a string such as '302', so the sample calls to_i before comparing. And Net::HTTP.post is a convenient class method, but it takes only a URL, a body, and headers, so it cannot set timeouts and always runs on the 60-second defaults.
Connection reuse needs an explicit session. When you call request on a Net::HTTP object that has not been started, the source in the docs shows it opening a session, setting Connection: close, and finishing after that one request, so keeping one object around does not keep one connection open. Ydc.with_session wraps Net::HTTP.start, and passing its object to Ydc.search as http: sends several requests over one connection, within keep_alive_timeout, which starts at 2 seconds. If the connection fails mid-session, Net::HTTP retries only idempotent requests (max_retries starts at 1), and RFC 9110 section 9.2.2 lists only PUT, DELETE, and the safe methods as idempotent, so do not count on Net::HTTP retrying a failed search POST. The wrapper below handles that.
The command-line script reads the key once with ENV.fetch, rejects a blank value, makes one attempt, and prints the top hits. Its format string is double-quoted on purpose: in a single-quoted Ruby string, \n is a backslash followed by an n, because single quotes disable every escape except \' and \\, per the Ruby literals documentation.
# frozen_string_literal: true
require_relative 'ydc_search'
api_key = ENV.fetch('YDC_API_KEY') { abort 'Set YDC_API_KEY before running.' }.strip
abort 'YDC_API_KEY is blank.' if api_key.empty?
begin
response = Ydc.search({ query: 'ruby net http timeouts', count: 5, freshness: 'year' }, api_key)
rescue Ydc::ApiError => e
abort "HTTP #{e.status}: #{e.body}"
rescue Ydc::TransportError, JSON::ParserError => e
abort e.message
end
hits = Ydc.web_results(response)
puts 'No web results.' if hits.empty?
hits.each_with_index do |hit, i|
puts format("%d. %s\n %s", i + 1, hit['title'] || '(untitled)', hit['url'])
end
What does the response contain?
Treat the response as a hash where nothing is guaranteed. results.web and results.news are both optional, and the docs advise checking before iterating, which is why Ydc.web_results ends in || [] and also tolerates a missing results key. A web hit may carry url, title, description, snippets, page_age, thumbnail_url, favicon_url, and contents when the request sets extraction. The OpenAPI schema requires none of them, so the script falls back to '(untitled)'. page_age is a string, and the docs' examples show it without a UTC offset.
Filters go into the request hash next to query. freshness accepts day, week, month, year, or a YYYY-MM-DDtoYYYY-MM-DD range, and the three domain filters take arrays of domains. A request that pairs include_domains with exclude_domains or boost_domains comes back as a 422, so validate that before sending. See the request controls page for every option and the date filter guide for how a time phrase inside the query interacts with freshness.
Which failures are worth retrying, and how do you retry in Ruby?
The docs attach a specific meaning to each status, and the retry rule follows from it:
- Retry: 429, the per-second limit (10 requests per second by default on
/v1/searchas of October 2026), and 5xx server errors (the docs list a 500 that can also come from authentication middleware). Transport failures such as timeouts and connection resets belong here too. - Fix, then resend: 401 for a missing, expired, or revoked key or a wrong header name, with bodies reading
API key is requiredorInvalid or expired API key; 402 for a keyed account that is out of credits; 403 when the key lacks scope for the path or the path is on the wrong host (Missing Authentication Token); and 422 for an invalid parameter combination.
Retrying the second group only repeats the mistake. The error reference and rate limits page list the bodies.
Add the retry wrapper to the same module:
module Ydc
module_function
# Retries 429, 5xx, and transport failures. Everything else fails on the first attempt.
def search_with_retry(request, api_key, max_attempts: 3, http: nil)
attempt = 1
begin
search(request, api_key, http: http)
rescue ApiError => e
raise unless e.status == 429 || e.status >= 500
raise if attempt >= max_attempts
delay = e.retry_after || (2**(attempt - 1))
attempt += 1
sleep([delay, 10].min)
retry
rescue TransportError
raise if attempt >= max_attempts
delay = 2**(attempt - 1)
attempt += 1
sleep([delay, 10].min)
retry
end
end
end
Rescue by class, not by message or by RuntimeError. The Net::ReadTimeout and Net::OpenTimeout classes descend from Timeout::Error, which descends from RuntimeError, and a bare raise 'message' also creates a RuntimeError. A retry loop built on rescue RuntimeError therefore retries timeouts and every plain-string failure alike, including a 402 or 422, and has to search the message text to tell them apart. Ydc::TRANSPORT_ERRORS names the transient classes explicitly, starting from the list that Net::HTTP's own max_retries documents, and Ydc::ApiError carries the status as data.
Delays come from the API when it sends Retry-After, and from a 1 second then 2 second backoff when it does not, with every sleep capped at 10 seconds. JSON::ParserError is deliberately not retried: an unparseable 200 gives no reason to expect a different answer. Because sleep blocks the calling thread, a web request should make one attempt and render without search results on failure. Keep this wrapper for scripts and background jobs, and swap Ydc.search_with_retry for Ydc.search in the script above to use it.
How do you test the client without calling the API?
The pure functions make offline tests easy: Ydc.decode and Ydc.web_results take strings and hashes, so a test feeds them saved fixtures and no network is involved. The script below uses nothing but Ruby, and a non-zero exit code fails a CI job. Leave live calls out of the default run, because each one is billed ($5.00 per 1,000 calls as of October 2026, per the billing page).
# frozen_string_literal: true
require_relative 'ydc_search'
failures = 0
check = lambda do |name, passed|
puts "#{passed ? 'PASS' : 'FAIL'} #{name}"
failures += 1 unless passed
end
# Response shapes: results and results.web are both optional.
check.call('empty object gives no web results', Ydc.web_results(Ydc.decode(200, '{}')).empty?)
check.call('news-only response gives no web results',
Ydc.web_results(Ydc.decode(200, '{"results":{"news":[]}}')).empty?)
fixture = '{"results":{"web":[{"url":"https://example.com","title":"T","page_age":"2026-01-01T00:00:00"}]}}'
web = Ydc.web_results(Ydc.decode(200, fixture))
check.call('web results decode', web.first['title'] == 'T' && web.first['page_age'] == '2026-01-01T00:00:00')
# A 200 with an unparseable body must not look like "no results".
begin
Ydc.decode(200, '<html>maintenance</html>')
check.call('invalid JSON raises', false)
rescue JSON::ParserError
check.call('invalid JSON raises', true)
end
# Errors carry the status, the body, and the Retry-After value.
begin
Ydc.decode(429, '{"detail":"slow down"}', Ydc.parse_retry_after('3'))
check.call('429 raises', false)
rescue Ydc::ApiError => e
check.call('429 carries status and Retry-After', e.status == 429 && e.retry_after == 3)
end
begin
Ydc.decode(401, '{"detail":"Invalid or expired API key"}')
check.call('401 raises', false)
rescue Ydc::ApiError => e
check.call('401 carries the body', e.status == 401 && e.body.include?('Invalid or expired'))
end
# Retry-After accepts seconds or an HTTP date, and ignores anything else.
check.call('Retry-After accepts an HTTP date',
Ydc.parse_retry_after('Wed, 21 Oct 2099 07:28:00 GMT').positive?)
check.call('Retry-After ignores garbage', Ydc.parse_retry_after('soon').nil?)
exit(failures.zero? ? 0 : 1)
Read the fixtures as a list of ways the response can surprise you: no results key, no web key, a normal hit, a 200 that is really an HTML maintenance page, and the two failure statuses that matter most. The last two checks cover both forms of Retry-After. None of this measures relevance, latency, or current pricing, so keep a handful of live queries to judge by hand.
Should you use Faraday instead?
Use Faraday if the application already depends on it. Its request options include timeout, the maximum number of seconds to wait for the request to complete, next to open_timeout, read_timeout, and write_timeout, and each defaults to the adapter's default. Retries come from the separate faraday-retry gem, and its defaults matter for a POST search: it retries 2 times, only for idempotent methods (delete, get, head, options, put) and a short list of network exceptions. To retry this request, pass methods that include post, and add 429 to retry_statuses, which the gem documents as the way to have it pause for the Retry-After header. Net::HTTP needs no gems and is enough for a script or a small service.
What breaks in production?
Retry loops keyed on exception text. A loop that rescues RuntimeError and searches the message for HTTP 401 retries a 402 or 422 and misses every transport error that is not a RuntimeError, such as Errno::ECONNREFUSED or SocketError. Detection: retry counts above zero on statuses the docs call configuration errors.
A single-quoted format string. format('%d. %s\n %s', ...) prints a literal backslash and n instead of a line break. Detection: output with \n in it, so use double quotes whenever the string contains an escape.
A new connection for every search. A batch job that calls Ydc.search in a loop opens and closes a session each time. Detection: reuse one session with Ydc.with_session and compare the loop's duration.
A key read late. ENV.fetch inside the search method turns a missing variable into a runtime error on the first search. Detection: move the fetch to boot, as the script does, and treat a blank value as missing.
Fan-out that trips the rate limit. The docs give a per-second limit and put X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset on every response, and they suggest reading those headers to throttle before a 429. Detection: 429 bursts that coincide with batch jobs or a thread pool whose workers all start at once.
Related Guides
LI Test
LI Test
Share Article:





