Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ jobs:
- name: Set up uv
uses: astral-sh/setup-uv@v7

- name: Bump patch version
- name: Bump version
id: bump
run: |
set -euo pipefail
uv version --bump patch --no-sync
uv version --bump minor --no-sync
echo "version=$(uv version --short)" >> "$GITHUB_OUTPUT"

- name: Commit version bump
Expand Down
149 changes: 149 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Migration guide: requests → httpx2

This release replaces `requests` with [`httpx2`](https://httpx2.pydantic.dev/) (Pydantic
Services' actively maintained continuation of `httpx`) and restructures the SDK around a
first-class `Client` (sync) and `AsyncClient` (async), following the shape used by the
Anthropic and OpenAI Python SDKs. Most existing code keeps working unmodified; this document
lists every breaking and behavior change.

## New: unified `Client` / `AsyncClient`

```python
import amzn_selling_partner as sp

client = sp.Client() # or sp.AsyncClient()
client.reports.create_report(...)
client.vendor.orders.get_purchase_orders(...)
```

Both accept the same constructor kwargs as before (`selling_partner_region`, all SP-API/AWS
credential kwargs, `sandbox`), plus new options: `timeout` (seconds, default `60.0`),
`max_retries` (default `2`), `http_client=`/`transport=` (inject or mock the underlying
httpx2 client), and `limits=` (connection pool limits, default `max_connections=100,
max_keepalive_connections=50`). A caller-supplied `http_client=` is used as-is for the
actual HTTP transport, but `Client`/`AsyncClient` still resolve the SP-API base URL and
apply SigV4/LWA auth on top of it — you don't need (and can't) pre-configure those on the
client you pass in.

**Both must be closed** to release their connection pool — use them as a context manager
(`with sp.Client() as client:` / `async with sp.AsyncClient() as client:`) or call
`client.close()` / `await client.aclose()` explicitly. The old `requests.Session`-backed
clients didn't require this.

## New: `DefaultHttpxClient` / `DefaultAsyncHttpxClient` / `DefaultAioHttpClient`

Matching the OpenAI Python SDK's convention: `DefaultHttpxClient`/`DefaultAsyncHttpxClient`
are `httpx2.Client`/`httpx2.AsyncClient` subclasses carrying this SDK's own timeout and
connection-pool defaults, meant to be built on top of (via `http_client=`) instead of a bare
`httpx2.Client()`, which would otherwise silently drop those defaults. `DefaultAioHttpClient`
is the async one wired to an aiohttp-backed transport (requires the `aiohttp` extra; raises a
clear `RuntimeError` on construction if it isn't installed). None of these are selected
automatically — you always opt in explicitly via `http_client=`.

## Deprecated (still works, now warns): per-resource `Client()`

`amzn_selling_partner.reports.Client()` and `amzn_selling_partner.vendor.orders.Client()`
still exist with the same constructor kwargs and the same public methods, but now emit a
`DeprecationWarning` and internally delegate to a `Client` they construct. Prefer
`sp.Client(...).reports` / `sp.Client(...).vendor.orders` going forward.

## Removed (not shimmed): `amzn_selling_partner.client.auth`

`ClientSessionAuth`, `ClientSessionAuthAccessToken`, `ClientSessionAuthTemporaryCredentials`,
and their `*Error` classes are gone, along with `BaseClient().http_session`. These were
internal wiring for the `requests`-based auth hook, never documented public API beyond the
endpoint-string helpers — `amzn_selling_partner.client.BaseClient` keeps only
`get_api_endpoint()` / `get_resource_path()` / `get_resource_endpoint()` /
`get_operation_endpoint()` (also now deprecated in favor of `Client`/`AsyncClient`). If you
were reaching into the removed classes directly, there is no replacement — construct a
`Client`/`AsyncClient` instead and let it manage auth internally.

## New: automatic retries

Requests now automatically retry on `408/409/429/500/502/503/504` responses and on
connection/timeout errors, up to `max_retries` times (default `2`), with exponential backoff
and jitter that honors a `Retry-After` header when present. Pass `max_retries=0` to a
`Client`/`AsyncClient` to restore the old (no automatic retry) behavior exactly.

## New: error hierarchy

Errors now raise from `amzn_selling_partner._exceptions`: `APIStatusError` (with `.response`,
`.status_code`, `.body`) and its per-status subclasses (`BadRequestError`,
`AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`,
`UnprocessableEntityError`, `RateLimitError`, `InternalServerError`) for HTTP error responses;
`APIConnectionError`/`APITimeoutError` for network/timeout failures. Previously, `requests`'
own `HTTPError`/`ConnectionError`/`Timeout` propagated directly — catch the new types instead.

## New: `with_raw_response`

Every resource exposes `.with_raw_response`, returning the raw `httpx2.Response` instead of a
parsed pydantic model — e.g. `client.reports.with_raw_response.get_report(...)`. **Asymmetry
to note:** `with_raw_response.create_report(...)` returns only the initial `POST reports`
response; the parsed `create_report()` additionally performs a follow-up `GET report` and
returns *that* result, which `with_raw_response` does not replicate.

## Pydantic v1 → v2

All request/response models (in `reports.models` and `vendor.orders.models`) are now pydantic
v2 `BaseModel`s. User-visible effects:

- `.dict()` → `.model_dump()`, `.json()` → `.model_dump_json()`, `.copy()` → `.model_copy()`.
The old v1 methods still exist as deprecated compatibility shims in pydantic v2 and continue
to work, but emit deprecation warnings — update call sites when convenient.
- Validation errors now raise `pydantic.ValidationError` in v2's format (different `.errors()`
structure than v1). If you catch `pydantic.v1.error_wrappers.ValidationError` specifically,
switch to plain `pydantic.ValidationError`.
- Unknown/extra fields on response models are still silently ignored (pydantic v1's default,
preserved deliberately for forward-compatibility with SP-API responses that add fields over
time) — `ReportOptions` remains the one model that rejects extra fields (`extra="forbid"`),
matching its v1 behavior.
- The `pydantic` dependency pin moves from an exact `==1.10.13` to a range `>=2,<3` — a
deliberate, minor deviation from this repo's usual exact-pin convention, since pinning a
fast-moving major version exactly would create constant busywork for a change this small.

## Dependencies

- Removed: `requests`, `requests_aws4auth`.
- Added: `httpx2` (runtime dependency).
- New optional extra `aiohttp` (`aiohttp[speedups]`, `httpx_aiohttp[httpx2]`): install with
`uv sync --extra aiohttp` (or `pip install amzn-selling-partner[aiohttp]`), then opt into
the aiohttp-backed transport explicitly with `AsyncClient(http_client=sp.DefaultAioHttpClient())`.
It is never selected automatically just because the extra is installed.
- New dev-only dependency: `pytest-asyncio` (async test support). Not shipped to end users.

## Testing: `responses` → `httpx2.MockTransport`

This repo's own test suite no longer uses the `responses` library (which patches `requests`
globally and has no equivalent for `httpx2`). If your own tests mocked this SDK's HTTP calls
via `responses`, that will stop working — switch to `httpx2.MockTransport`, passed as
`transport=` to `Client`/`AsyncClient`:

```python
import httpx2
import amzn_selling_partner as sp

def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json={"reportId": "report-1"})

client = sp.Client(transport=httpx2.MockTransport(handler), ...)
```

Note that this SDK's auth flow performs its own LWA token-refresh HTTP call through the same
transport — a `MockTransport` handler needs to also answer `POST
https://api.amazon.com/auth/o2/token` (see `tests/conftest.py`'s `with_lwa()` helper in this
repo for the pattern).

## Known limitation: async credential refresh runs in a thread

`AsyncClient`'s AWS SigV4 signing and STS `assume_role` credential refresh are backed by
`boto3`/`botocore`, which are synchronous under the hood. To avoid blocking the event loop,
these are offloaded via `asyncio.to_thread` inside the async auth flow. This means each
credential refresh briefly occupies a thread-pool worker; if you run many concurrent
`AsyncClient`s with a constrained thread pool, size `asyncio`'s default executor accordingly.
This is a known limitation, not a bug — there is no async-native AWS SDK in use here.

## Python version support unchanged

`requires-python` stays `>=3.10`. Request timeouts are enforced via httpx2's own per-request
`timeout=` configuration (not `asyncio.timeout()`, which needs Python 3.11+), matching the
approach used by the OpenAI Python SDK — so Python 3.10 remains fully supported.
94 changes: 71 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,53 +83,101 @@ AWS_SELLING_PARTNER_ROLE_SESSION_NAME=
```

```python
import amzn_selling_partner.vendor as sp
import amzn_selling_partner as sp

sp_vendor_orders_client = sp.vendor.orders.Client()
client = sp.Client()

purchase_orders = sp_vendor_orders_client.get_purchase_orders()
purchase_orders = client.vendor.orders.get_purchase_orders()
print(purchase_orders)

purchase_order = sp_vendor_orders_client.get_purchase_order("<purchase-order-number>")
purchase_order = client.vendor.orders.get_purchase_order("<purchase-order-number>")
print(purchase_order)
```

An async client is available too, with the same resources and method signatures:

```python
import asyncio

import amzn_selling_partner as sp


async def main() -> None:
async with sp.AsyncClient() as client:
purchase_orders = await client.vendor.orders.get_purchase_orders()
print(purchase_orders)


asyncio.run(main())
```

`Client`/`AsyncClient` manage an HTTP connection pool and should be closed when you're done with
them — use them as a context manager (as above), or call `client.close()` /
`await client.aclose()` explicitly.

> **Note:** `amzn_selling_partner.reports.Client()` and `amzn_selling_partner.vendor.orders.Client()`
> (constructing a client per resource, without a region/sandbox namespace) still work but are
> deprecated in favor of `sp.Client()`/`sp.AsyncClient()`. See [MIGRATION.md](MIGRATION.md).

### Handling exceptions

Unsuccessful requests raise exceptions. The errors should handled as `Requests` library exceptions.
To read more about that: https://requests.readthedocs.io/en/latest/user/quickstart/#errors-and-exceptions
Unsuccessful requests raise one of the exceptions in `amzn_selling_partner._exceptions`:
`APIStatusError` (and its per-status subclasses, e.g. `RateLimitError`, `NotFoundError`) for HTTP
error responses, or `APIConnectionError`/`APITimeoutError` for network/timeout failures. Requests
are automatically retried on `429`/`5xx` responses and connection/timeout errors (honoring
`Retry-After`) up to `max_retries` times (default `2`); pass `max_retries=0` to disable retries.

### Per-client configuration

Configure individual clients with keyword arguments. For instance, you can make a request with a
specific [selling partner region](https://developer-docs.amazon.com/sp-api/docs/sp-api-endpoints) or
use sandbox.
Configure a client with keyword arguments. For instance, you can make requests against a specific
[selling partner region](https://developer-docs.amazon.com/sp-api/docs/sp-api-endpoints), enable
sandbox mode, or tune HTTP behavior:

```python
import amzn_selling_partner.vendor as sp
import amzn_selling_partner as sp

na_sp_vendor_orders_client = sp.vendor.orders.Client(
selling_partner_region=client.SellingPartnerRegion.NORTH_AMERICA
)
na_client = sp.Client(selling_partner_region=sp.SellingPartnerRegion.NORTH_AMERICA)
eu_client = sp.Client(selling_partner_region=sp.SellingPartnerRegion.EUROPE)
fe_client = sp.Client(selling_partner_region=sp.SellingPartnerRegion.FAR_EAST)

eu_sp_vendor_orders_client = sp.vendor.orders.Client(
selling_partner_region=client.SellingPartnerRegion.EUROPE
)
sandbox_client = sp.Client(sandbox=True)

fe_sp_vendor_orders_client = sp.vendor.orders.Client(
selling_partner_region=client.SellingPartnerRegion.FAR_EAST
)
tuned_client = sp.Client(timeout=30.0, max_retries=0)
```

### Enable Sandbox
> **Note:** some endpoints are not available on sandbox. To read more about that: https://developer-docs.amazon.com/sp-api/docs/the-selling-partner-api-sandbox

### Custom HTTP clients

Pass a pre-built `httpx2.Client`/`httpx2.AsyncClient` as `http_client=` to fully control the
underlying transport (proxies, custom mounts, connection limits, etc.) — it's used as-is, with
`Client`/`AsyncClient` still applying SP-API auth and endpoint resolution on top:

```python
import amzn_selling_partner.vendor as sp
import amzn_selling_partner as sp

sp_vendor_orders_client = sp.vendor.orders.Client(sandbox=True)
client = sp.Client(http_client=sp.DefaultHttpxClient(proxy="http://localhost:8030"))
```

> **Note:** some endpoints are not available on sandbox. To read more about that: https://developer-docs.amazon.com/sp-api/docs/the-selling-partner-api-sandbox
`DefaultHttpxClient`/`DefaultAsyncHttpxClient` are thin `httpx2.Client`/`httpx2.AsyncClient`
subclasses carrying this SDK's own timeout/connection-pool defaults — build on top of them
instead of a bare `httpx2.Client()` so you don't lose those defaults.

### Optional aiohttp transport

`AsyncClient` uses httpx2's default transport unless you explicitly opt into aiohttp — it is
never selected automatically, even if the `aiohttp` extra happens to be installed:

```sh
uv sync --extra aiohttp
```

```python
import amzn_selling_partner as sp

async with sp.AsyncClient(http_client=sp.DefaultAioHttpClient()) as client:
...
```

## Development

Expand Down
31 changes: 21 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "amzn-selling-partner"
version = "0.1.9"
version = "1.0.0"
description = "Amazon Selling Partner API for Python"
authors = [
{ name = "Danilo Britto", email = "dbritto.dev@gmail.com" },
Expand All @@ -27,22 +27,30 @@ classifiers = [
"Topic :: Security",
]
dependencies = [
"requests==2.34.2", "requests_aws4auth==1.2.2", "boto3==1.43.86",
"pydantic==1.10.13"
"httpx2==2.12.0", "boto3==1.43.86",
"pydantic>=2,<3"
]
readme = "README.md"
requires-python = ">=3.10"

[project.optional-dependencies]
# Aiohttp-backed async transport (httpx2's default async transport is used otherwise).
aiohttp = ["aiohttp[speedups]>=3.10", "httpx_aiohttp[httpx2]>=0.2"]
dev = [
"ruff==0.16.5", "pytest==9.1.1", "coverage==7.10.6",
"ruff==0.16.5", "pytest==9.1.1", "pytest-asyncio==1.4.0", "coverage==7.10.6",
"pytest-cov==7.1.0", "nox>=2024.4.15", "bandit==1.9.4", "safety==2.3.4",
"responses==0.26.3", "ty==0.0.77"
"ty==0.0.77",
# Also declared in the `aiohttp` extra; included here so lint/type-check/test tooling can
# resolve and exercise the optional aiohttp-backed transport code path.
"aiohttp[speedups]>=3.10", "httpx_aiohttp[httpx2]>=0.2"
]
lint = ["ruff==0.16.5"]
test = [
"pytest==9.1.1", "coverage==7.10.6", "pytest-cov==7.1.0", "nox>=2024.4.15",
"responses==0.26.3"
"pytest==9.1.1", "pytest-asyncio==1.4.0", "coverage==7.10.6", "pytest-cov==7.1.0",
"nox>=2024.4.15",
# Exercises DefaultAioHttpClient (see tests/test_http_client.py) across the full CI
# Python matrix, not just the `dev` environment.
"aiohttp[speedups]>=3.10", "httpx_aiohttp[httpx2]>=0.2"
]
security-test = ["bandit==1.9.4", "safety==2.3.4", "setuptools<81"]

Expand Down Expand Up @@ -82,10 +90,12 @@ ignore = ["E501", "B904", "B008"]
convention = "google"

[tool.ruff.lint.per-file-ignores]
"tests/*.py" = ["S101", "S106"]
"tests/*.py" = ["S101", "S105", "S106", "PLR2004", "PLW0108"]

[tool.ruff.lint.pylint]
max-args = 10
# Client/AsyncClient constructors take every SP-API/AWS credential kwarg plus the new
# httpx2-era client options (timeout, max_retries, http_client, transport, limits).
max-args = 15

[tool.ruff.format]
quote-style = "double"
Expand All @@ -98,11 +108,12 @@ deprecated = "ignore"
minversion = "6.0"
addopts = "-ra -q"
testpaths = ["tests"]
asyncio_mode = "auto"
pythonpath = ["src"]

[tool.coverage.run]
branch = true
source = ["amzn_selling_partner"]
omit = ["src/amzn_selling_partner/client/auth.py"]

[tool.coverage.report]
fail_under = 90
16 changes: 15 additions & 1 deletion src/amzn_selling_partner/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,21 @@
from importlib.metadata import version

from . import client, reports, utils, vendor
from ._client import AsyncClient, Client
from ._regions import SellingPartnerRegion
from ._transports import DefaultAioHttpClient, DefaultAsyncHttpxClient, DefaultHttpxClient

__version__ = version("amzn-selling-partner")

__all__ = ["client", "reports", "utils", "vendor"]
__all__ = [
"AsyncClient",
"Client",
"SellingPartnerRegion",
"DefaultHttpxClient",
"DefaultAsyncHttpxClient",
"DefaultAioHttpClient",
"client",
"reports",
"utils",
"vendor",
]
Loading
Loading