Provider pattern
A provider is a class that turns some external seller catalogue into a list of
SellerRecord objects. enrichment-scraper has one — SmartScoutProvider — and the
abstraction exists so a second can be added without touching the dispatcher, the registry, the
queue service, or any existing provider.
The split is deliberate: everything that is the same for every HTTP seller API lives in the base class, and everything that is specific to one vendor lives in the subclass.
| Owner | Concern |
|---|---|
|
Retries, timeout backoff, 401 re-authentication, 429 throttling, 5xx handling, proactive rate-limit sleeping, and safe row mapping |
The subclass |
Request headers, pagination, request-body shape, and mapping one raw row to a |
BaseProvider
lambdas/enrichment_scraper/providers/base.py. Generic over the raw row type, so a subclass
declares the Pydantic model its API returns:
RawSellerT = TypeVar("RawSellerT", bound=BaseModel)
class BaseProvider(ABC, Generic[RawSellerT]):
...
class SmartScoutProvider(BaseProvider[SmartScoutSeller]):
...
Abstract methods
| Method | Contract |
|---|---|
|
Paginate the source and return matching records. |
|
Map one raw API row to the domain model. May raise |
|
Full headers for every outbound request — auth, content type, fingerprint, tracing ids. |
|
|
Overridable hook
_on_unauthorized() is called on HTTP 401 before the single retry. The default is a no-op;
SmartScoutProvider overrides it to call self._auth.invalidate().
This is the seam that makes token refresh work without the base class knowing anything about
tokens. _post_with_retry() rebuilds headers on every attempt, so invalidating the cache in
the hook is enough — the retry picks up a fresh token through _build_request_headers().
Inherited methods
| Method | Behaviour |
|---|---|
|
Wraps |
|
POST with the full failure matrix below. |
|
Sleeps until the reset time when |
|
Parses |
SellerFilters
The universal filter contract. Each provider translates it into whatever its API wants.
@dataclass
class SellerFilters:
monthly_revenue_min: float | None = None
monthly_revenue_max: float | None = None
categories: list[str] = field(default_factory=list)
marketplace: str = "amazon"
Revenue is monthly here, while the ICP_REVENUE_MIN / ICP_REVENUE_MAX settings are
annual — the handler divides by twelve when building the filters. See
Seller discovery.
Failure handling
_post_with_retry() treats each failure class differently, and the distinctions are the point
of the method:
| Condition | Behaviour |
|---|---|
|
Exponential backoff — |
HTTP 401 |
Call |
HTTP 429 |
Sleep on |
HTTP 5xx |
Log a truncated body and skip the page. Continue with the next page. |
|
Skip that row only, via |
|
Skip the whole page. |
Two details carry the design. A 429 does not consume a retry, because rate limiting is not a failure — it is the server telling you to wait, and counting it would exhaust the budget on a request that was always going to succeed. And a 5xx skips the page rather than aborting the run, so one bad page does not cost the whole invocation’s progress.
SmartScoutProvider
providers/smartscout/provider.py.
Headers
Every request, including login, carries the browser fingerprint headers in
utils/constants.py as SMARTSCOUT_BROWSER_HEADERS. Search requests add:
Authorization: Bearer <jwt>
Content-Type: application/json-patch+json
Request-Id: <fresh uuid4 per request>
The unusual application/json-patch+json content type and the per-request Request-Id are
what the API expects; neither is a mistake.
Pagination
SmartScout uses the AG Grid infinite row model, so pages are row ranges rather than page numbers:
Page 1: { "endRow": 1000 } (1)
Page 2: { "startRow": 1000, "endRow": 2000 }
Page 3: { "startRow": 2000, "endRow": 3000 }
| 1 | startRow is omitted, not sent as 0. |
The model declares startRow: int | None = None and the body is serialised with
exclude_none=True, which is what drops the field on the first page — matching the API’s own
convention.
The first response carries totalRowCount. Pagination stops when startRow >= totalRowCount,
when max_pages_per_run is reached, or when a page returns an empty payload.
fetch_sellers() returns (records, next_start_row), where 0 means the result set was fully
consumed and the next run should restart from the beginning. The handler persists that cursor
as enrichment.amazon.next_start_row — see
Runtime state in configs.
Request body
{
"loadDefaultData": false,
"requiresFilter": false,
"filter": {
"estimateSalesMin": 8333.33,
"estimateSalesMax": 83333.33
},
"pageFilter": {
"endRow": 1000,
"includeTotalRowCount": true,
"sortModel": [],
"fields": [
"name", "amazonSellerId", "primaryCategoryId", "primarySubCategory",
"estimateSales", "percentFba", "numberWinningBrands", "numberAsins",
"numberTopAsins", "state", "country", "businessName",
"moMGrowth", "moMGrowthCount", "startedSellingDate", "note"
]
}
}
estimateSales is monthly USD. Both bounds are optional — omitting them fetches unfiltered.
When ICP_CATEGORIES is set, primaryCategoryId is added to filter, and only the first
category is sent per request.
The explicit fields list is a cost control: the API returns far more columns than the mapping
uses, and naming them keeps responses small across thousands of rows.
SmartScoutSeller
models/smartscout.py. model_config = ConfigDict(extra="allow").
| Field | Type | Notes |
|---|---|---|
|
|
The only required field — the primary identifier |
|
|
Display name |
|
|
Legal or registered name |
|
|
An integer id, not a string |
|
|
Human-readable subcategory |
|
|
Monthly revenue estimate, USD |
|
|
Average item price |
|
|
FBA proportion |
|
|
Brands where the seller wins the buy box |
|
|
Total ASINs |
|
|
High-performing ASINs |
|
|
Location |
|
|
Month-over-month growth |
|
|
Months of consecutive growth |
|
|
Longer growth windows |
|
|
— |
|
|
Defaults to |
|
|
ISO datetime string, parsed best-effort |
|
|
Free text |
extra="allow" is load-bearing. The SmartScout API is undocumented and changes without
notice; strict parsing would turn a new column into a failed page. Unknown fields survive into
SellerRecord.raw, so a field discovered later is already captured in historical rows.
Mapping to SellerRecord
SellerRecord field |
Source |
|---|---|
|
|
|
Always |
|
|
|
Built as |
|
|
|
|
|
|
|
Parsed from |
|
|
|
|
|
Direct |
|
Always |
|
Full |
SellerRecord is frozen (ConfigDict(str_strip_whitespace=True, frozen=True)) with validators
enforcing non-negative revenue and ASIN counts. Immutability means a record cannot be mutated
between mapping and publication, so what reaches the queue is what the provider produced.
SmartScout authentication
providers/smartscout/auth.py. SmartScoutAuth owns the whole JWT lifecycle: obtaining,
caching in memory, persisting to Secrets Manager, and deciding when to refresh.
The login endpoint returns an envelope, not a bare token:
{
"token": "eyJhbGci...",
"refreshToken": null,
"requireTwoFactor": false,
"requireMobilePhoneConfirmation": false
}
token is extracted and decoded with PyJWT — without signature verification, since the
only claim needed is exp.
Resolution order
get_token()
│
├─ in-memory cache valid (>5 min remaining)? ──────────► return
│
├─ SMARTSCOUT_TOKEN + SMARTSCOUT_TOKEN_EXPIRES_AT
│ in the shared secret still valid? ──────────────────► cache, return
│
└─ authenticate
POST /api/authentication/login with SMARTSCOUT_USERNAME / _PASSWORD
decode exp, convert to UTC
read-modify-write the shared secret
cache, return
Three layers, each avoiding work the next would cost. The in-memory cache serves a warm container; the secret serves a cold container, which is the case that matters — Lambda recycles containers constantly, and without the shared cache every cold start would re-authenticate.
A five-minute buffer treats a token as expired before its actual exp, so a token cannot
lapse mid-page.
|
Writing only the two session keys would destroy every other credential in the shared secret.
This is why |
invalidate() clears the in-memory cache only; the secret is untouched, so the next
get_token() re-evaluates the stored session before authenticating.
Forcing a fresh login
Delete the two session keys and the next invocation authenticates:
aws secretsmanager get-secret-value --secret-id agentcore-configs \
--query SecretString --output text \
| jq 'del(.SMARTSCOUT_TOKEN, .SMARTSCOUT_TOKEN_EXPIRES_AT)' \
> /tmp/secret.json
aws secretsmanager put-secret-value --secret-id agentcore-configs \
--secret-string file:///tmp/secret.json && rm /tmp/secret.json
Omitting those keys from a manual put-secret-value is harmless for the same reason — it costs
one extra authentication.
Adding a provider
Three steps, and no change to registry.py, handler.py, queue.py, or any existing model.
-
Response model —
models/<vendor>.py, withConfigDict(extra="allow"):class JungleScoutSeller(BaseModel): model_config = ConfigDict(extra="allow") ... -
Provider —
providers/<vendor>/provider.py:class JungleScoutProvider(BaseProvider[JungleScoutSeller]): def _build_request_headers(self) -> dict[str, str]: ... def _to_seller_record(self, raw: JungleScoutSeller) -> SellerRecord: ... def fetch_sellers(self, filters: SellerFilters, start_from_row: int = 0) -> tuple[list[SellerRecord], int]: ...Use
_post_with_retry()andmap_seller()from the base class. Override_on_unauthorized()if the vendor uses tokens. -
Call it from a handler — results compose, because every provider returns the same type:
smartscout_records, next_row = SmartScoutProvider().fetch_sellers(filters, start_row) jungle_records, _ = JungleScoutProvider().fetch_sellers(filters) queue.publish(smartscout_records + jungle_records)
Keep vendor authentication inside the vendor’s own package. SmartScoutAuth lives in
providers/smartscout/ rather than in a shared services/ module because its JWT flow is
proprietary and means nothing to another provider. A shared auth module would accumulate every
vendor’s quirks.
Add the source’s value to DataSource in models/seller.py — it already carries
jungle_scout and walmart_scraper members. See Enumerations.
Design decisions
- Why retry logic lives in the base class
-
Every seller API fails the same handful of ways. Centralising them means a new provider gets robust HTTP handling for free, and a fix to backoff behaviour applies everywhere at once.
- Why a registry rather than
if/elif -
The dispatcher never learns about a specific handler. Adding an event type is three lines in
registry.pyand no change to the entrypoint. See Handler and registry pattern. - Why SHA256 for
MessageDeduplicationId -
SQS FIFO caps the id at 128 characters. A SHA256 hex digest is exactly 64 and deterministic, so the same seller and marketplace always produce the same id — which is what makes a rerun idempotent rather than duplicative. See Publishing events.
- Why the JWT is stored in Secrets Manager
-
A container is reused for warm invocations but discarded soon after. An in-memory-only cache would re-authenticate on every cold start; the shared secret lets any container inherit a valid token.
Tests
cd lambdas/enrichment_scraper && uv run pytest tests -v
tests/providers/smartscout/test_provider.py covers mapping, pagination, and the whole
failure matrix; test_auth.py covers the three-layer token resolution and
invalidation. tests/integration/test_smartscout.py hits the live API and needs credentials —
run it with uv run pytest -m integration -v. See Testing Lambdas.