A Python client for Amazon's Product Advertising API 5.0, with the awkward parts handled: request signing, the ten-item batch limit, throttling, partial responses, and a daily quota you can exhaust in an afternoon.
No runtime dependencies. 156 tests, none of which touch the network.
python demo.py # everything below, with no credentials
python -m pytest amzn/tests -qScraping Amazon breaks their terms, and they enforce it. It also breaks every time a page template changes. PA-API is the sanctioned route: you register as an Associate, you get keys, and you get structured data with a quota attached.
The quota is the whole design constraint. A new account starts at roughly one request per second and a few thousand a day, rising with the sales attributed to it. That is little enough that wasting a request on a typo in a field name, or retrying something that can never succeed, is a real cost. Most of this library exists to not waste requests.
PA-API uses AWS Signature Version 4 with the service name
ProductAdvertisingAPI, and it rejects a bad signature with the same unhelpful
message it uses for a bad partner tag. Each locale also signs against a specific
region: amazon.ae is eu-west-1, amazon.com is us-east-1, amazon.co.jp is
us-west-2. Sign against the wrong one and the error never mentions regions.
The signing-key derivation is checked in the test suite against the vector AWS publishes in its own documentation, so it is verified against Amazon's arithmetic and not merely against itself.
GetItems accepts ten ASINs. Ask for 23 and you get three requests, merged into
one result. A batch that gets rejected outright does not take the successful
batches down with it; its ASINs come back in the result as errors so nothing
disappears silently.
This is the part most wrappers get wrong. A single 200 can carry ItemsResult
with the items Amazon resolved and a top-level Errors array for the ones it
did not. Raise on Errors and you throw away good data. Ignore it and a rejected
product looks the same as one that does not exist.
So a lookup returns items, errors and a third list: ASINs you asked for that came back neither way, which does happen and is otherwise invisible.
Amazon puts the ASIN for those errors in the message text and not in a field, so recovering which product an error refers to means pulling it out of the sentence. The library matches against what you requested first, since falling back to any ten-character token picks up unrelated references.
A price lives at Offers.Listings[0].Price.Amount, and every link in that chain
can be absent on a product that is perfectly valid. Indexing straight through it
works until the first item with no buy-box offer. Responses are flattened into a
plain object where a missing field is None and nothing raises.
Where an item has several offers, the buy-box one is reported, since that is the price a shopper sees. In the bundled fixture the first listing in the array is a third-party seller at AED 419 and the buy-box is Amazon at AED 349; the client reports 349.
Throttling and 5xx are retried with exponential backoff and full jitter. Jitter
matters when several workers are throttled together: without it they all retry on
the same beat and stay throttled. A Retry-After from Amazon overrides the
guess.
Validation and authentication failures are never retried. A rejected partner tag will still be rejected on the third attempt, and each attempt costs quota.
The limiter enforces both the per-second rate and the daily cap. When the day is exhausted it raises rather than sleeping until midnight, because a process that blocks for six hours is worse than one that tells you. Retries count against the quota, as they do at Amazon's end.
client.usage() reports requests made, quota used and remaining, and cache hits.
Repeat lookups inside a run are served from memory. The Associates terms limit how long pricing data may be held, so the TTL is clamped to a day: a careless setting cannot quietly become a compliance problem. Check the current terms yourself.
from amzn import resources as res
from amzn.client import Client, Credentials
client = Client(
credentials=Credentials(access_key="...", secret_key="...",
partner_tag="yourtag-21"),
marketplace="ae",
)
result = client.get_items(["B0EXAMPLE1", "B0EXAMPLE2"], res.PRICING)
for item in result.items:
print(item.asin, item.title, item.price, item.in_stock)
for err in result.errors:
print("rejected:", err.asin, err.code)
print("no answer for:", result.missing)
items, total = client.search_items("wireless headphones", min_price=10000)Resource bundles are MINIMAL, PRICING and CATALOGUE. Names are validated
locally before a request goes out, and an unknown one raises with the nearest
valid suggestion, because PA-API rejects the whole request for a single typo.
Twenty marketplaces are in the table, including ae, sa and eg.
No live call has been made from this code. I have no Associates keys for it. The signing is verified against AWS's published test vector and every behaviour above is covered by tests against a fake transport, but the first real request will be yours. Treat the marketplace table and the quota figures as needing a check against Amazon's current documentation, since both have changed before.
The transport is injected, so swapping the standard-library one for httpx or
requests, or pointing it at a recorded fixture, is a constructor argument.
The cache is per-process and in memory. Sharing it across workers means backing the same two-method interface with Redis.
amzn/
sigv4.py request signing, pure functions
marketplaces.py host and region per locale
resources.py the Resources whitelist and bundles
errors.py error taxonomy, retryable or not
ratelimit.py token bucket plus daily cap
cache.py TTL cache, clamped to a day
parse.py flatten responses, handle partial success
client.py batching, retry, caching, quota accounting
fixtures/ a recorded partial-success response
tests/ 156 tests, no network
demo.py the whole thing offline
Python 3.11 or newer. MIT licensed.
