Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,47 @@
# Migration Guide: 1.x to 2.0
# Migration Guide

## 1.x → 2.3.0 (Latest)

This guide covers breaking changes introduced in 2.3.0. If you are upgrading from 1.x, also read the [1.x → 2.0](#1x-to-20) section below.

### Breaking Changes in 2.3.0

#### Python 3.10 now required

The minimum supported Python version has been raised from `>=3.9.2` to `>=3.10`. If you are running Python 3.9, you must upgrade before installing this version.

```bash
python --version # must be 3.10 or later
pip install "youdotcom>=2.3.0"
```

#### Search API: `count` default changed

`you.search.unified()` now defaults `count` to `10` (previously `None`/no default). If your code omits `count` and relies on the API-server default, you will now always receive 10 results.

```python
# Before (2.x < 2.3.0): count was unset, server decided
res = you.search.unified(query="AI news")

# After (2.3.0+): equivalent explicit call
res = you.search.unified(query="AI news", count=10)
```

#### Contents API: `crawl_timeout` type changed

`crawl_timeout` has changed from `float` to `int`. Passing a float (e.g., `crawl_timeout=5.5`) will now raise a validation error.

```python
# Before: float was accepted
res = you.contents.generate(urls=["https://example.com"], crawl_timeout=5.5)

# After: use int
res = you.contents.generate(urls=["https://example.com"], crawl_timeout=5)
```

---

## 1.x to 2.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 2.3.0 section is useful and well-structured.

Two minor issues to fix:

  1. Broken link to examples/: Line 331 links to examples/ but only examples/api-example-calls.py exists (not a directory). Consider changing to [examples/api-example-calls.py](examples/api-example-calls.py) or removing the link.

  2. Missing retries behavioral change: The retries fix (max_elapsed_time exhaustion now raises instead of returning the failed response) is a behavior change that existing users relying on response-code checking after retries might hit. Worth a brief note in this section.

Suggested change
## 1.x to 2.0
---
## 1.x to 2.0


This guide helps you upgrade your code from You.com Python SDK 1.x to 2.0.

Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,18 @@ underlying connection when the context is exited.
```python
import os
from youdotcom import You
from youdotcom.models import (
ExpressAgentRunsRequest,
WebSearchTool,
ResponseCreated,
ResponseStarting,
ResponseOutputItemAdded,
ResponseOutputContentFull,
ResponseOutputTextDelta,
ResponseOutputItemDone,
ResponseDone,
)
from youdotcom.utils import eventstreaming


with You(
Expand Down Expand Up @@ -287,7 +299,7 @@ with You(

elif isinstance(event_data, ResponseDone):
print("\n🎉 Response completed!")
print(f" Runtime: {event_data.response.run_time_ms} seconds")
print(f" Runtime: {event_data.response.run_time_ms} ms")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. run_time_ms is in milliseconds, not seconds. The unit label "seconds" was misleading — "ms" is correct.

One small style note: the rest of the streaming example output uses emoji (🔍, ✍️, 🌐, 🎉) which is fine for illustration, but the label should match the field name to avoid confusion. run_time_ms → "ms" is the right fix.

print(f" Finished: {event_data.response.finished}")

else:
Expand Down
5 changes: 2 additions & 3 deletions src/youdotcom/sdkconfiguration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
)
from .httpclient import AsyncHttpClient, HttpClient
from .utils import Logger, RetryConfig, remove_suffix
from dataclasses import dataclass
from pydantic import Field
from dataclasses import dataclass, field
from typing import Callable, Dict, Optional, Tuple, Union
from youdotcom import models
from youdotcom.types import OptionalNullable, UNSET
Expand All @@ -36,7 +35,7 @@ class SDKConfiguration:
sdk_version: str = __version__
gen_version: str = __gen_version__
user_agent: str = __user_agent__
retry_config: OptionalNullable[RetryConfig] = Field(default_factory=lambda: UNSET)
retry_config: OptionalNullable[RetryConfig] = field(default_factory=lambda: UNSET)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct fix. SDKConfiguration is a @dataclass, and Pydantic's Field(default_factory=...) only works inside Pydantic BaseModel subclasses. Using it in a plain dataclass would set the default value to the FieldInfo object itself rather than calling the factory, meaning retry_config would always be a FieldInfo rather than UNSET. Switching to dataclasses.field(default_factory=...) is correct.

Worth noting: this is in auto-generated source (# Code generated by Speakeasy). You should verify that the upstream Speakeasy template has been patched so it doesn't regenerate the broken version on the next SDK refresh.

timeout_ms: Optional[int] = None

def get_server_details(self) -> Tuple[str, Dict[str, str]]:
Expand Down
18 changes: 6 additions & 12 deletions src/youdotcom/utils/retries.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,10 @@ def do_request() -> httpx.Response:
if res.status_code == parsed_code:
raise TemporaryError(res)
except (httpx.NetworkError, httpx.TimeoutException) as exception:
if retries.config.retry_connection_errors:
raise
if not retries.config.retry_connection_errors:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix — logic was completely inverted.

Before:

if retry_connection_errors:   # True → raise (prevented retry!)
    raise
raise PermanentError(...)     # False → also prevented retry

Both branches prevented retrying.

After this fix, when retry_connection_errors=True the raw NetworkError/TimeoutException propagates up to the outer retry_with_backoff loop, where it will be caught, the back-off sleep applied, and the request retried. When retry_connection_errors=False it wraps in PermanentError to short-circuit the loop immediately. Correct.

One minor nit: it would help to add a one-line comment explaining why we re-raise the unwrapped exception here vs. wrapping in PermanentError, since the asymmetry is non-obvious:

Suggested change
if not retries.config.retry_connection_errors:
except (httpx.NetworkError, httpx.TimeoutException) as exception:
if not retries.config.retry_connection_errors:
# Wrap so the outer loop treats this as non-retryable
raise PermanentError(exception) from exception
# Let the raw error propagate so the outer loop retries it
raise

raise PermanentError(exception) from exception

raise PermanentError(exception) from exception
raise
except TemporaryError:
raise
except Exception as exception:
Expand Down Expand Up @@ -189,10 +189,10 @@ async def do_request() -> httpx.Response:
if res.status_code == parsed_code:
raise TemporaryError(res)
except (httpx.NetworkError, httpx.TimeoutException) as exception:
if retries.config.retry_connection_errors:
raise
if not retries.config.retry_connection_errors:
raise PermanentError(exception) from exception

raise PermanentError(exception) from exception
raise
except TemporaryError:
raise
except Exception as exception:
Expand Down Expand Up @@ -229,9 +229,6 @@ def retry_with_backoff(
except Exception as exception: # pylint: disable=broad-exception-caught
now = round(time.time() * 1000)
if now - start > max_elapsed_time:
if isinstance(exception, TemporaryError):
return exception.response

raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavioral change that may surprise callers: max_elapsed_time exhaustion now always raises.

Before this change, when all retry time was exhausted and the last error was a TemporaryError (e.g. a 429 or 503 response), the function would silently return that non-2xx httpx.Response. Any caller that checked response.status_code after a retry-exhausted request would see the error without hitting an exception.

After this change, the function unconditionally raises — callers that relied on the old return-on-timeout pattern will now receive an unhandled exception.

The new behavior is more correct (returning a failed response silently is bad), but this is a de-facto behavioral breaking change. Consider:

  1. Documenting this in the CHANGELOG under 2.3.0 "Changed".
  2. Making sure the SDK's own exception-handling layer converts the re-raised TemporaryError into a user-facing typed error (e.g. YouDefaultError) rather than leaking the internal TemporaryError class to callers.


sleep = _get_sleep_interval(
Expand Down Expand Up @@ -259,9 +256,6 @@ async def retry_with_backoff_async(
except Exception as exception: # pylint: disable=broad-exception-caught
now = round(time.time() * 1000)
if now - start > max_elapsed_time:
if isinstance(exception, TemporaryError):
return exception.response

raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The async path has the same max_elapsed_time change (see retry_with_backoff_async). Same CHANGELOG note applies.


sleep = _get_sleep_interval(
Expand Down
6 changes: 3 additions & 3 deletions src/youdotcom/utils/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def _parse_security_scheme_value(
elif sub_type == "query":
query_params[header_name] = [value]
else:
raise ValueError("sub type {sub_type} not supported")
raise ValueError(f"sub type {sub_type} not supported")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix. All three ValueError messages were missing the f prefix, so they would show the literal string "sub type {sub_type} not supported" instead of embedding the actual value. These are important for debugging unexpected security scheme configurations.

Since this is auto-generated code, same note as sdkconfiguration.py: verify the Speakeasy template is fixed upstream.

elif scheme_type == "openIdConnect":
headers[header_name] = _apply_bearer(value)
elif scheme_type == "oauth2":
Expand All @@ -158,9 +158,9 @@ def _parse_security_scheme_value(
elif sub_type == "custom":
return
else:
raise ValueError("sub type {sub_type} not supported")
raise ValueError(f"sub type {sub_type} not supported")
else:
raise ValueError("scheme type {scheme_type} not supported")
raise ValueError(f"scheme type {scheme_type} not supported")


def _apply_bearer(token: str) -> str:
Expand Down
8 changes: 5 additions & 3 deletions tests/test_research.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ def test_research_with_sources(self, server_url, api_key):

assert isinstance(res, ResearchResponse)
assert res.output is not None
if res.output.sources:
for source in res.output.sources:
assert source.url is not None
assert res.output.sources is not None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good strengthening of the assertion. The previous if res.output.sources: guard was effectively a no-op test — if the mock server returned sources=None or [] the test would silently pass without verifying anything. The explicit assert now catches that.

However, these tests depend on the mock server handling POST /v1/research, which it does nottests/mockserver/internal/handler/generated_handlers.go only registers handlers for /v1/search, /v1/agents/runs, and /v1/contents. A request to /v1/research hits the fallback root handler (404 Not Found), so the SDK raises a YouDefaultError and none of these assertions are ever reached. The tests should either:

  1. Be skipped with pytest.mark.skip until the mock server is extended, or
  2. Be converted to live-only tests guarded by pytestmark = pytest.mark.skipif(not os.getenv("YOU_API_KEY_AUTH"), ...)

The mock server binary also needs to be rebuilt for Linux (see the separate comment on the binary).

assert len(res.output.sources) > 0
for source in res.output.sources:
assert source.url is not None


class TestResearchAsync:
Expand All @@ -96,6 +97,7 @@ async def test_basic_research_async(self, server_url, api_key):
assert isinstance(res, ResearchResponse)
assert res.output is not None
assert res.output.content is not None
assert len(res.output.content) > 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistent with the sources fix above — good to add this. The same mock server gap applies here: test_basic_research_async will also fail against the mock server since /v1/research returns 404.



class TestResearchErrors:
Expand Down