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
8 changes: 4 additions & 4 deletions .fern/metadata.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"cliVersion": "5.143.0",
"cliVersion": "5.170.5",
"generatorName": "fernapi/fern-python-sdk",
"generatorVersion": "5.34.1",
"generatorVersion": "5.41.4",
"generatorConfig": {
"package_name": "newscatcher_catchall",
"client": {
"class_name": "CatchAllApi",
"filename": "client.py"
}
},
"originGitCommit": "6ef7448ab601e5b372caec45717b0390a4150d2c",
"originGitCommit": "b7764dca3eb740a480b120edb87932f928b9210e",
"originGitCommitIsDirty": true,
"invokedBy": "ci",
"requestedVersion": "AUTO",
"ciProvider": "unknown",
"sdkVersion": "4.0.0"
"sdkVersion": "4.1.0"
}
8 changes: 7 additions & 1 deletion .fern/replay.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,4 @@ On the other hand, contributions to the README are always very welcome!




10 changes: 10 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## [4.1.0] - 2026-10-22
### Added
- **`RequestOptions.retry_remote_protocol_errors`** — new optional `bool` field that opts a request into retrying on `httpx.RemoteProtocolError` (server disconnect / incomplete response); defaults to `False` and is bounded by `max_retries`.

### Changed
- **`RemoteProtocolError` retry behavior** — the HTTP client no longer retries `httpx.RemoteProtocolError` automatically; it now raises immediately unless `retry_remote_protocol_errors: True` is set in `RequestOptions`, since the server may have already applied the request.
- **Header merging** — duplicate headers with different casing (e.g. `PLAID-SECRET` and `plaid-secret`) are now deduplicated case-insensitively, with the last value winning; this prevents sending two headers where only one was intended.
- **`x-api-key` header redaction** — the `x-api-key` header is now redacted alongside `Authorization` in any debug or log output produced by the HTTP client.
- **Path parameter dot-segment rejection** — `encode_path_param()` and `quote_path_param()` now raise `ValueError` when a value is `.` or `..`, preventing silent URL path rewriting by HTTP clients.

## [4.0.0] - 2026-09-30
### Breaking Changes
- **`MonitorsClient` / `AsyncMonitorsClient`** — renamed to `EventMonitorsClient` / `AsyncEventMonitorsClient`; update all imports and type annotations accordingly.
Expand Down
1,053 changes: 563 additions & 490 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ license-files = ["LICENSE"]

[tool.poetry]
name = "newscatcher-catchall-sdk"
version = "4.0.0"
version = "4.1.0"
description = ""
readme = "README.md"
authors = []
Expand Down
4 changes: 2 additions & 2 deletions src/newscatcher_catchall/core/client_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ def get_headers(self) -> typing.Dict[str, str]:
import platform

headers: typing.Dict[str, str] = {
"User-Agent": "newscatcher-catchall-sdk/0.0.0-fern-placeholder",
"User-Agent": "newscatcher-catchall-sdk/4.1.0",
"X-Fern-Language": "Python",
"X-Fern-Runtime": f"python/{platform.python_version()}",
"X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}",
"X-Fern-SDK-Name": "newscatcher-catchall-sdk",
"X-Fern-SDK-Version": "4.0.0",
"X-Fern-SDK-Version": "4.1.0",
**(self.get_custom_headers() or {}),
}
headers["x-api-key"] = self.api_key
Expand Down
81 changes: 58 additions & 23 deletions src/newscatcher_catchall/core/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,33 @@ def _should_retry(response: httpx.Response) -> bool:
)


_AUTH_HEADERS: typing.FrozenSet[str] = frozenset({"x-api-key"})


def _redact_headers(headers: typing.Dict[str, str]) -> typing.Dict[str, str]:
return {k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()}
return {
k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS or k.lower() in _AUTH_HEADERS else v)
for k, v in headers.items()
}


def _merge_headers(*header_dicts: typing.Optional[typing.Dict[str, typing.Any]]) -> typing.Dict[str, typing.Any]:
"""
Merge header dicts left to right. Header names are case-insensitive, so a later
`x-api-key` replaces an earlier `X-API-Key` instead of producing two headers.
"""
merged: typing.Dict[str, typing.Any] = {}
keys_by_lower: typing.Dict[str, str] = {}
for header_dict in header_dicts:
if not header_dict:
continue
for key, value in header_dict.items():
existing_key = keys_by_lower.get(key.lower())
if existing_key is not None:
del merged[existing_key]
keys_by_lower[key.lower()] = key
merged[key] = value
return merged


def _build_url(base_url: str, path: typing.Optional[str]) -> str:
Expand Down Expand Up @@ -425,11 +450,11 @@ def request(
_request_url = _build_url(base_url, path)
_request_headers = jsonable_encoder(
remove_none_from_dict(
{
**self.base_headers(),
**(headers if headers is not None else {}),
**(request_options.get("additional_headers", {}) or {} if request_options is not None else {}),
}
_merge_headers(
self.base_headers(),
headers,
request_options.get("additional_headers") if request_options is not None else None,
)
)
)
_request_headers = drop_content_type_without_body(
Expand Down Expand Up @@ -463,7 +488,12 @@ def request(
files=request_files,
timeout=timeout,
)
except (httpx.ConnectError, httpx.RemoteProtocolError):
except (httpx.ConnectError, httpx.RemoteProtocolError) as error:
# A protocol error may occur after the server applied the request, even while reading a 200 body.
if isinstance(error, httpx.RemoteProtocolError) and not (
request_options is not None and request_options.get("retry_remote_protocol_errors", False)
):
raise
if retries < max_retries:
time.sleep(_retry_timeout_from_retries(retries=retries))
return self.request(
Expand Down Expand Up @@ -594,11 +624,11 @@ def stream(
_request_url = _build_url(base_url, path)
_request_headers = jsonable_encoder(
remove_none_from_dict(
{
**self.base_headers(),
**(headers if headers is not None else {}),
**(request_options.get("additional_headers", {}) if request_options is not None else {}),
}
_merge_headers(
self.base_headers(),
headers,
request_options.get("additional_headers") if request_options is not None else None,
)
)
)
_request_headers = drop_content_type_without_body(
Expand Down Expand Up @@ -735,11 +765,11 @@ async def request(
_request_url = _build_url(base_url, path)
_request_headers = jsonable_encoder(
remove_none_from_dict(
{
**_headers,
**(headers if headers is not None else {}),
**(request_options.get("additional_headers", {}) or {} if request_options is not None else {}),
}
_merge_headers(
_headers,
headers,
request_options.get("additional_headers") if request_options is not None else None,
)
)
)
_request_headers = drop_content_type_without_body(
Expand Down Expand Up @@ -773,7 +803,12 @@ async def request(
files=request_files,
timeout=timeout,
)
except (httpx.ConnectError, httpx.RemoteProtocolError):
except (httpx.ConnectError, httpx.RemoteProtocolError) as error:
# A protocol error may occur after the server applied the request, even while reading a 200 body.
if isinstance(error, httpx.RemoteProtocolError) and not (
request_options is not None and request_options.get("retry_remote_protocol_errors", False)
):
raise
if retries < max_retries:
await asyncio.sleep(_retry_timeout_from_retries(retries=retries))
return await self.request(
Expand Down Expand Up @@ -907,11 +942,11 @@ async def stream(
_request_url = _build_url(base_url, path)
_request_headers = jsonable_encoder(
remove_none_from_dict(
{
**_headers,
**(headers if headers is not None else {}),
**(request_options.get("additional_headers", {}) if request_options is not None else {}),
}
_merge_headers(
_headers,
headers,
request_options.get("additional_headers") if request_options is not None else None,
)
)
)
_request_headers = drop_content_type_without_body(
Expand Down
18 changes: 15 additions & 3 deletions src/newscatcher_catchall/core/jsonable_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from pathlib import PurePath
from types import GeneratorType
from typing import Any, Callable, Dict, List, Optional, Set, Union
from urllib.parse import quote
from urllib.parse import quote, unquote

import pydantic
from .datetime_utils import serialize_datetime
Expand Down Expand Up @@ -118,7 +118,11 @@ def encode_path_param(obj: Any) -> str:
"""
if isinstance(obj, bool):
return "true" if obj else "false"
return str(jsonable_encoder(obj))
value = str(jsonable_encoder(obj))
# The value is not percent-encoded here, so check every segment it would add to the path.
for segment in unquote(value).split("/"):
_reject_dot_segment(segment)
return value


def quote_path_param(obj: Any) -> str:
Expand All @@ -130,4 +134,12 @@ def quote_path_param(obj: Any) -> str:
"""
if isinstance(obj, bool):
return "true" if obj else "false"
return quote(str(jsonable_encoder(obj)), safe="")
value = str(jsonable_encoder(obj))
_reject_dot_segment(value)
return quote(value, safe="")


def _reject_dot_segment(value: str) -> None:
# "." and ".." are dot-segments that HTTP clients resolve, which would change the request path.
if value in (".", ".."):
raise ValueError(f'Invalid path parameter value "{value}": "." and ".." are not allowed.')
64 changes: 59 additions & 5 deletions src/newscatcher_catchall/core/pydantic_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Callable,
ClassVar,
Dict,
FrozenSet,
List,
Mapping,
Optional,
Expand All @@ -37,8 +38,8 @@
IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.")

if IS_PYDANTIC_V2:
_datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined]
_date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined]
_datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined,var-annotated]
_date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined,var-annotated]

def parse_datetime(value: Any) -> dt.datetime: # type: ignore[misc]
if isinstance(value, dt.datetime):
Expand Down Expand Up @@ -129,7 +130,7 @@ def _decimal_encoder(dec_value: Any) -> Any:
from pydantic.typing import is_union as is_union # type: ignore[no-redef]

from .datetime_utils import serialize_datetime
from .serialization import convert_and_respect_annotation_metadata
from .serialization import convert_and_respect_annotation_metadata, get_field_to_alias_mapping
from typing_extensions import TypeAlias

T = TypeVar("T")
Expand Down Expand Up @@ -237,6 +238,59 @@ def _coerce_keys_to_aliases(model: type, data: Any) -> Any:
return rewritten


_declared_keys_cache: "weakref.WeakKeyDictionary[type, Tuple[FrozenSet[str], FrozenSet[str]]]" = (
weakref.WeakKeyDictionary()
)


def _get_declared_keys(model: type) -> Tuple[FrozenSet[str], FrozenSet[str]]:
"""
Keys that refer to a declared field (field names, Pydantic aliases and FieldMetadata aliases), together with
their casefolded forms. Computed once per model class.
"""
cached = _declared_keys_cache.get(model)
if cached is None:
fields: Mapping[str, Any] = (
getattr(model, "model_fields", {}) if IS_PYDANTIC_V2 else getattr(model, "__fields__", {})
)
keys: Set[str] = set(fields)
for field in fields.values():
alias = getattr(field, "alias", None)
if isinstance(alias, str):
keys.add(alias)
keys.update(get_field_to_alias_mapping(model).values())
cached = (frozenset(keys), frozenset(key.casefold() for key in keys))
_declared_keys_cache[model] = cached
return cached


def _allows_extra_fields(model: type) -> bool:
if IS_PYDANTIC_V2:
return getattr(model, "model_config", {}).get("extra") == "allow"
extra = getattr(getattr(model, "__config__", None), "extra", None)
return getattr(extra, "value", extra) == "allow"


def _drop_extras_colliding_with_fields(model: type, data: Any) -> Any:
"""
Drop unknown keys that match a declared field case-insensitively (e.g. `VALUE` next to `value`). Kept as
extras, they would be sent again after the declared field, and a case-insensitive decoder such as Go's
encoding/json would read them instead of it.
"""
if not isinstance(data, Mapping) or not _allows_extra_fields(model):
return data

declared_keys, declared_casefolded = _get_declared_keys(model)
colliding = {
key
for key in data
if isinstance(key, str) and key not in declared_keys and key.casefold() in declared_casefolded
}
if not colliding:
return data
return {key: value for key, value in data.items() if key not in colliding}


def parse_obj_as(type_: Type[T], object_: Any) -> T:
# convert_and_respect_annotation_metadata is required for TypedDict aliasing.
#
Expand Down Expand Up @@ -279,7 +333,7 @@ class UniversalBaseModel(pydantic.BaseModel):
@pydantic.model_validator(mode="before") # type: ignore[attr-defined]
@classmethod
def _coerce_field_names_to_aliases(cls, data: Any) -> Any:
return _coerce_keys_to_aliases(cls, data)
return _coerce_keys_to_aliases(cls, _drop_extras_colliding_with_fields(cls, data))

@pydantic.model_serializer(mode="plain", when_used="json") # type: ignore[attr-defined]
def serialize_model(self) -> Any: # type: ignore[name-defined]
Expand All @@ -295,7 +349,7 @@ class Config:

@pydantic.root_validator(pre=True)
def _coerce_field_names_to_aliases(cls, values: Any) -> Any:
return _coerce_keys_to_aliases(cls, values) # type: ignore[arg-type]
return _coerce_keys_to_aliases(cls, _drop_extras_colliding_with_fields(cls, values)) # type: ignore[arg-type]

@classmethod
def model_construct(cls: Type["Model"], _fields_set: Optional[Set[str]] = None, **values: Any) -> "Model":
Expand Down
3 changes: 3 additions & 0 deletions src/newscatcher_catchall/core/request_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class RequestOptions(typing.TypedDict, total=False):

- max_retries: int. The max number of retries to attempt if the API call fails.

- retry_remote_protocol_errors: bool. Retry server disconnects and incomplete responses. Defaults to False, since the server may already have applied the request. Enable only when replaying the request is safe; bounded by max_retries.

- additional_headers: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's header dict

- additional_query_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's query parameters dict
Expand All @@ -32,6 +34,7 @@ class RequestOptions(typing.TypedDict, total=False):
timeout: NotRequired[float]
timeout_in_seconds: NotRequired[int]
max_retries: NotRequired[int]
retry_remote_protocol_errors: NotRequired[bool]
additional_headers: NotRequired[typing.Dict[str, typing.Any]]
additional_query_parameters: NotRequired[typing.Dict[str, typing.Any]]
additional_body_parameters: NotRequired[typing.Dict[str, typing.Any]]
Expand Down
Loading
Loading