Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
27 changes: 27 additions & 0 deletions docs/guides/settings/overriding_outdated_headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Overriding Outdated Headers

You may sometimes encounter issues where the TickTick API returns a 429, similar to [sebpretzer/pyticktick#226](https://github.com/sebpretzer/pyticktick/issues/226) or [lazeroffmichael/ticktick-py#53](https://github.com/lazeroffmichael/ticktick-py/issues/53). The standard use case for 429 codes is that you are being [rate limited](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429). But TickTick also returns a 429 due to outdated headers that are no longer valid.

To work around this, you can override the headers sent by setting the `v2_user_agent` and `v2_x_device` settings. For example:

```python
from pyticktick import Client

client = Client(
v2_user_agent="Mozilla/5.0 (rv:145.0) Firefox/145.0",
v2_x_device={
"platform": "web",
"version": 8000,
"id": "694241d132d12fcc26e7a4d8",
},
)
```

???+ question "Where do I find valid values for these headers?"

In order to find valid values for these headers, you can inspect the network requests made by the TickTick app in your browser's developer tools. Look for requests to `https://api.ticktick.com` and check the `User-Agent` and `X-Device` headers.

Not all values that are specified in your browser's request are required. The following is usually sufficient:

- `User-Agent`: A string that identifies the browser version (e.g., `Mozilla/5.0 (rv:145.0) Firefox/145.0`).
- `X-Device`: A JSON string with at least the `platform`, `version`, and `id` fields (e.g., `{"platform":"web", "version":8000, "id":"694241d132d12fcc26e7a4d8"}`), where `id` is a [MongoDB ObjectId-like string](https://www.mongodb.com/docs/manual/reference/method/ObjectId/).
1 change: 1 addition & 0 deletions mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ nav:
- Installation: guides/installation.md
- Settings:
- Overriding Models That Forbid Extra Fields: guides/settings/overriding_models_that_forbid_extra_fields.md
- Overriding Outdated Headers: guides/settings/overriding_outdated_headers.md
- The TickTick API:
- Register a V1 App: guides/ticktick_api/register_v1_app.md
- Generate a V1 Token: guides/ticktick_api/generate_v1_token.md
Expand Down
53 changes: 44 additions & 9 deletions src/pyticktick/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

from __future__ import annotations

import json
import warnings
Comment thread
sebpretzer marked this conversation as resolved.
import webbrowser
from time import time
Expand Down Expand Up @@ -38,6 +37,8 @@
from pyticktick.models.v1.responses.oauth import OAuthTokenV1
from pyticktick.models.v2.responses.user import UserSignOnV2, UserSignOnWithTOTPV2

TICKTICK_INCORRECT_HEADER_CODE = 429


class TokenV1(BaseModel): # noqa: DOC601, DOC603
"""Model for the V1 API token.
Expand Down Expand Up @@ -67,6 +68,28 @@ def _validate_expiration(cls, v: int) -> int:
return v


class V2XDevice(BaseModel): # noqa: DOC601, DOC603
"""Model for the V2 API X-Device header.

The X-Device header is used to mimic a web browser request. It requires a
`platform`, `version`, and `id`. The `id` is a randomly generated MongoDB
ObjectId() string.

You are able to pass in extra fields, as the X-Device header may change over time,
but these three fields are the only known required ones, as found via trial and
error.
"""

model_config = ConfigDict(extra="allow")

platform: str = Field(default="web", description="The platform of the device.")
version: int = Field(default=6430, description="The version of the device.")
id: str = Field(
default_factory=lambda: str(BsonObjectId()),
description="Randomly generated id, should be a MongoDB ObjectId().",
)


class Settings(BaseSettings): # noqa: DOC601, DOC603
"""Settings for the pyticktick client.

Expand Down Expand Up @@ -184,6 +207,12 @@ class Settings(BaseSettings): # noqa: DOC601, DOC603
v2_token (Optional[str]): The cookie token for the V2 API.
v2_base_url (HttpUrl): The base URL for the V2 API. Defaults to
`https://api.ticktick.com/api/v2/`.
v2_user_agent (str): The User-Agent header for the V2 API, used to mimic a web
browser request. Defaults to
`Mozilla/5.0 (rv:145.0) Firefox/145.0`.
v2_x_device (V2XDevice): The X-Device header for the V2 API, used to mimic a web
browser request. Defaults to a JSON string with platform `web`, version
`6430`, and a random MongoDB ObjectId string as the ID.
override_forbid_extra (bool): Whether to override forbidding extra fields.
"""

Expand Down Expand Up @@ -237,6 +266,14 @@ class Settings(BaseSettings): # noqa: DOC601, DOC603
default=HttpUrl("https://api.ticktick.com/api/v2/"),
description="The base URL for the V2 API.",
)
v2_user_agent: str = Field(
default="Mozilla/5.0 (rv:145.0) Firefox/145.0",
description="The User-Agent header for the V2 API, used to mimic a web browser request.", # noqa: E501
)
v2_x_device: V2XDevice = Field(
default=V2XDevice(),
description="The X-Device header for the V2 API, used to mimic a web browser request.", # noqa: E501
)

override_forbid_extra: bool = Field(
default=False,
Expand Down Expand Up @@ -356,6 +393,10 @@ def _v2_signon(
content = _content.decode()
else:
content = _content
if e.response.status_code == TICKTICK_INCORRECT_HEADER_CODE:
msg = "This may be related to your request headers, for more information, see: https://pyticktick.pretzer.io/guides/settings/overriding_outdated_headers/" # noqa: E501
logger.warning(msg)

msg = f"Response [{e.response.status_code}]:\n{content}"
logger.error(msg)
raise ValueError(msg) from e
Expand Down Expand Up @@ -545,14 +586,8 @@ def v2_headers(self) -> dict[str, str]:
dict[str, str]: The headers dictionary for the V2 API.
"""
return {
"User-Agent": "Mozilla/5.0 (rv:145.0) Firefox/145.0",
"X-Device": json.dumps(
{
"platform": "web",
"version": 6430,
"id": str(BsonObjectId()),
},
),
"User-Agent": self.v2_user_agent,
"X-Device": self.v2_x_device.model_dump_json(),
}

@property
Expand Down
54 changes: 53 additions & 1 deletion tests/unit/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pydantic import SecretStr, ValidationError

from pyticktick import Settings
from pyticktick.settings import TokenV1
from pyticktick.settings import TokenV1, V2XDevice

pytestmark = pytest.mark.filterwarnings("ignore:Unable to initialize")

Expand Down Expand Up @@ -182,6 +182,58 @@ def test_v2_settings_initialize(
assert settings.v2_token == test_v2_token


@pytest.mark.filterwarnings("ignore:Cannot signon to v1")
@pytest.mark.parametrize("from_env", [True, False])
@pytest.mark.parametrize("user_agent", ["Mozilla/5.0 (rv:145.0) Firefox/145.0"])
@pytest.mark.parametrize(
"v2_x_device",
[{"platform": "web", "version": 1000, "id": "69436d1b5890154a76755e9d"}],
)
def test_v2_settings_v2_headers(
monkeypatch,
test_v2_username,
test_v2_password,
test_v2_token,
from_env,
user_agent,
v2_x_device,
):
if from_env:
monkeypatch.setenv("PYTICKTICK_V2_USERNAME", test_v2_username)
monkeypatch.setenv("PYTICKTICK_V2_PASSWORD", test_v2_password)
monkeypatch.setenv("PYTICKTICK_V2_TOKEN", test_v2_token)

monkeypatch.setenv("PYTICKTICK_V2_USER_AGENT", user_agent)
monkeypatch.setenv("PYTICKTICK_V2_X_DEVICE_PLATFORM", v2_x_device["platform"])
monkeypatch.setenv(
"PYTICKTICK_V2_X_DEVICE_VERSION", str(v2_x_device["version"])
)
monkeypatch.setenv("PYTICKTICK_V2_X_DEVICE_ID", v2_x_device["id"])
settings = Settings()

else:
settings = Settings.model_validate(
{
"v2_username": test_v2_username,
"v2_password": test_v2_password,
"v2_token": test_v2_token,
"v2_user_agent": user_agent,
"v2_x_device": v2_x_device,
},
)

assert settings.v2_user_agent == user_agent

assert isinstance(settings.v2_x_device, V2XDevice)
assert settings.v2_x_device.platform == v2_x_device["platform"]
assert settings.v2_x_device.version == v2_x_device["version"]
assert settings.v2_x_device.id == v2_x_device["id"]

assert isinstance(settings.v2_headers, dict)
assert settings.v2_headers.get("User-Agent") == user_agent
assert json.loads(settings.v2_headers.get("X-Device", "{}")) == v2_x_device


@pytest.mark.filterwarnings("ignore:Cannot signon to v1")
def test_v2_settings__get_v2_token(
mocker,
Expand Down
Loading
Loading