From 367f3c1ee467cf01e928202a90febf1ae12cb565 Mon Sep 17 00:00:00 2001 From: Ernesto Vargas Date: Mon, 26 Oct 2026 23:53:45 -0600 Subject: [PATCH 1/3] Add Python SDK: generated pydantic models and thin httpx client Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Y4ijWAJZzDChcsSYmgTYcD --- .github/workflows/ci.yml | 31 + .gitignore | 6 + README.md | 50 + openapi/openapi.yaml | 2713 +++++++++++++++++++++++++ pyproject.toml | 43 + scripts/generate.py | 61 + src/sitewarm/__init__.py | 28 + src/sitewarm/_client.py | 412 ++++ src/sitewarm/_errors.py | 98 + src/sitewarm/_generated/__init__.py | 1 + src/sitewarm/_generated/models.py | 694 +++++++ src/sitewarm/_generated/operations.py | 50 + src/sitewarm/_retry.py | 30 + src/sitewarm/models.py | 2 + src/sitewarm/py.typed | 0 tests/conftest.py | 0 tests/test_client.py | 242 +++ tests/test_spec.py | 35 + 18 files changed, 4496 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 openapi/openapi.yaml create mode 100644 pyproject.toml create mode 100644 scripts/generate.py create mode 100644 src/sitewarm/__init__.py create mode 100644 src/sitewarm/_client.py create mode 100644 src/sitewarm/_errors.py create mode 100644 src/sitewarm/_generated/__init__.py create mode 100644 src/sitewarm/_generated/models.py create mode 100644 src/sitewarm/_generated/operations.py create mode 100644 src/sitewarm/_retry.py create mode 100644 src/sitewarm/models.py create mode 100644 src/sitewarm/py.typed create mode 100644 tests/conftest.py create mode 100644 tests/test_client.py create mode 100644 tests/test_spec.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..864cef7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python: ["3.10", "3.14"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ matrix.python }} + - run: pip install -e . datamodel-code-generator==0.86.0 pytest pytest-asyncio pyyaml mypy types-PyYAML + - name: Generated code is up to date + run: | + python scripts/generate.py + git diff --exit-code -- src/sitewarm/_generated + - run: python -m pytest -q + - run: python -m mypy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbd1f1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.egg-info/ +dist/ +.mypy_cache/ +.pytest_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5289724 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# sitewarm (Python) + +Python client for the sitewarm API. Pydantic models are generated from the OpenAPI 3.1 spec in [`openapi/openapi.yaml`](openapi/openapi.yaml) with [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator); the runtime is a thin, hand-written [`httpx`](https://www.python-httpx.org) client (sync and async). Python 3.10+. + +**Status: pre-release (0.1.0, not published to PyPI).** The API and this client can still change. The spec describes the whole planned surface; the service may not offer every operation yet. + +```python +from sitewarm import SiteWarm + +with SiteWarm(api_key=KEY, base_url="https:///v1") as sw: + site = sw.sites.create({"hostname": "example.com"}) + sitemap = sw.sitemaps.create(site["id"], {"url": "https://example.com/sitemap.xml"}) + started = sw.warm_runs.create(site["id"], {"source": {"type": "sitemap", "sitemap_id": sitemap["id"]}}) + run = sw.warm_runs.wait(started["id"]) + for r in sw.warm_runs.list_all_results(run["id"], final_state="miss"): + print(r["url"], r["final_state"]) +``` + +A site must be verified before a run is accepted; verification happens outside this API. + +## Surface + +- Resource helpers: `sites`, `sitemaps`, `warm_runs` (`create`, `get`, `cancel`, `results`, `list_all_results`, `wait`), `cache_status_reports`. Responses are plain `dict`s (decoded JSON). +- Every operation of the spec is reachable through `sw.call("", path={...}, query={...}, body=...)`, for example `sw.call("listSchedules", path={"siteId": site_id})`; `call_raw` also returns status and headers. The operation table is generated and a test fails if it drifts from the spec. +- `AsyncSiteWarm` has the same `call`, `call_raw`, `pages` and `paginate` (use `async with` / `async for`). +- Typing and validation: `sitewarm.models` holds the generated pydantic models. You can pass a model as a request body, or validate a response with `models.WarmRun.model_validate(run)`. +- Pagination: `sw.paginate(op, ...)` yields items, `sw.pages(op, ...)` yields pages. Cursors are opaque. +- Webhook callbacks (the `webhooks:` section of the spec) are server-to-customer and not covered. + +## Behavior + +- **Auth**: `Authorization: Bearer `. The key is not in `repr()`, error messages or logs (the SDK never logs). `api_key` must be printable ASCII without whitespace. `base_url` must be https (http only for localhost) and have no credentials; redirects are not followed, so the key never goes to another origin. +- **No default `base_url`**: pass the API host you were given. +- **Retries**: up to `max_retries` (3) on `429`, `5xx` (not `501`) and network errors, with exponential backoff and jitter capped at `max_retry_delay` (30 s). `Retry-After` is honored; if it exceeds the cap the error is raised instead of sleeping. `429` is always retried; `5xx` and network errors only when repeating is safe (not a POST, or a POST with an `Idempotency-Key`). +- **Idempotency**: every POST that accepts `Idempotency-Key` sends one (a random UUID unless you pass `idempotency_key`), generated once per call and reused by all its retries, so a retried `warm_runs.create` never starts two runs. +- **Errors**: `SiteWarmApiError` (`status`, `problem` as RFC 9457, `code`, `retry_after_seconds`, `rate_limit`) with subclasses for 401, 403, 404, 409, 400/422, 429 and 5xx; `SiteWarmConnectionError` and `SiteWarmTimeoutError` for transport; `SiteWarmWaitTimeoutError` from `warm_runs.wait`, which does not cancel the run. + +## Development + +``` +uv venv && uv pip install -e . --group dev # or: pip install -e . then the dev group +.venv/bin/python scripts/generate.py # regenerate src/sitewarm/_generated from openapi/openapi.yaml +.venv/bin/python -m pytest && .venv/bin/python -m mypy +``` + +To update the spec, replace `openapi/openapi.yaml` and run the generator; CI fails if `src/sitewarm/_generated` is out of date. The package carries the `Private :: Do Not Upload` classifier, so PyPI refuses an upload until that line is removed on purpose. + +## License + +Apache-2.0, see [LICENSE](LICENSE). diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..95445e3 --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,2713 @@ +openapi: 3.1.0 +info: + title: sitewarm public API + version: 0.1.0 + summary: Manage sites, warm runs, schedules, webhooks and API keys of the hosted sitewarm service. + description: | + This document is the source of truth for the public REST API; the server, the SDKs and the contract + tests are built from it. + + ## Vocabulary + + A **site** is one hostname. A **vantage + point** is the place a run's requests are sent from (`region` is an attribute of it). The + **cache status** of a URL is one of the normalized states in `CacheState`; `GET` results also carry + the raw CDN header value untouched in `cdn_cache_header`. Indexability is a separate field, never a + cache state. + + ## Conventions + + - **Versioning.** The base path is `/v1`. Breaking changes need a new major path. + - **Authentication.** Every operation takes a scoped API key as a bearer token + (`Authorization: Bearer `). Session cookies are never accepted. Each operation lists the + scope it needs. A request for a resource of another organization returns `404`, not `403`. + - **Idempotency.** Send `Idempotency-Key` on `POST`. Repeating a request with the same key and the + same body returns the first response and sets `Idempotent-Replayed: true`. The same key with a + different body is a `409` problem of type `idempotency-key-reused`. The warm-run trigger requires + the header; the other `POST` operations accept it. + - **Pagination.** Lists use opaque cursors: pass `cursor` from `page.next_cursor` of the previous + page. Clients must not parse cursors. + - **Errors.** Every error is `application/problem+json` as in RFC 9457. The `type` member is a URI + reference naming one of the problem types listed in `ProblemType`. + - **Rate limits.** Every response except `500` carries `RateLimit-Limit`, `RateLimit-Remaining` and + `RateLimit-Reset`. A `429` also carries `Retry-After`. + - **Async work.** Starting a warm run answers `202 Accepted` with a `Location` header pointing at + the warm-run resource, which is the status resource to poll. Cancelling is also asynchronous. + - **Webhooks.** Events are delivered with an HMAC-SHA256 signature, see the `webhooks` section. + - **Time and ids.** Timestamps are RFC 3339 UTC. Ids are opaque strings. Durations are strings + such as `10s` or `5m`, as in the CLI. + - **Sensitive data.** The API never returns `Cookie`, `Set-Cookie` or `Authorization` values from a + customer site, and never accepts them. Header names such as `Cache-Control`, `Age` and `Vary` + are returned as observed. + - **Safety.** A site must be verified before any run. Triggering a run for an unverified + or paused site is rejected with a problem. + contact: + name: sitewarm + url: https://github.com/sitewarm + license: + name: Apache-2.0 + identifier: Apache-2.0 +servers: + - url: /api/v1 + description: | + Relative to the host that serves the API. + +tags: + - name: Sites + description: Hostnames the organization owns, and their sitemaps and URL sets. + - name: Warm runs + description: Warm and verify runs, their status and their results. + - name: Cache status reports + description: Cache status per route group and vantage point, derived from warm runs. + - name: Schedules + description: Recurring warm runs. + - name: Webhooks + description: Outbound event subscriptions signed with HMAC. + - name: API keys + description: Scoped credentials for this API. +security: + - ApiKey: [] +paths: + /sites: + get: + operationId: listSites + summary: List sites + tags: [Sites] + security: + - ApiKey: [sites:read] + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: A page of sites. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/SiteList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + operationId: createSite + summary: Create a site + description: | + Creates an unverified site. Verification is done outside this API; until it passes, runs for + the site are rejected (`site-not-verified`). A site is one hostname: the apex and its `www` are + the same site. + tags: [Sites] + security: + - ApiKey: [sites:write] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SiteCreate' + responses: + '201': + description: The site was created. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Location: + $ref: '#/components/headers/Location' + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/Site' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /sites/{siteId}: + parameters: + - $ref: '#/components/parameters/SiteId' + get: + operationId: getSite + summary: Get a site + tags: [Sites] + security: + - ApiKey: [sites:read] + responses: + '200': + description: The site. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/Site' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + patch: + operationId: updateSite + summary: Update site settings + tags: [Sites] + security: + - ApiKey: [sites:write] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SiteUpdate' + responses: + '200': + description: The updated site. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/Site' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + operationId: deleteSite + summary: Delete a site and its history + description: Deletes the site, its sitemaps, URL sets, schedules and stored results. + tags: [Sites] + security: + - ApiKey: [sites:write] + responses: + '204': + description: The site was deleted. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /sites/{siteId}/sitemaps: + parameters: + - $ref: '#/components/parameters/SiteId' + get: + operationId: listSitemaps + summary: List the sitemaps of a site + tags: [Sites] + security: + - ApiKey: [sites:read] + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: A page of sitemaps. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/SitemapList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + operationId: createSitemap + summary: Register a sitemap + description: The sitemap URL must be on the site's hostname. Sitemap indexes and `.gz` files are accepted. + tags: [Sites] + security: + - ApiKey: [sites:write] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SitemapCreate' + responses: + '201': + description: The sitemap was registered. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Location: + $ref: '#/components/headers/Location' + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/Sitemap' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /sites/{siteId}/sitemaps/{sitemapId}: + parameters: + - $ref: '#/components/parameters/SiteId' + - $ref: '#/components/parameters/SitemapId' + get: + operationId: getSitemap + summary: Get a sitemap + tags: [Sites] + security: + - ApiKey: [sites:read] + responses: + '200': + description: The sitemap. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/Sitemap' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + operationId: deleteSitemap + summary: Remove a sitemap + tags: [Sites] + security: + - ApiKey: [sites:write] + responses: + '204': + description: The sitemap was removed. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /sites/{siteId}/url-sets: + parameters: + - $ref: '#/components/parameters/SiteId' + get: + operationId: listUrlSets + summary: List the URL sets of a site + description: | + A URL set is an explicit, ordered list of URLs, the hosted equivalent of the CLI's `--top-from` + file. The order is the warming priority. + tags: [Sites] + security: + - ApiKey: [sites:read] + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: A page of URL sets (without their URLs). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/UrlSetList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + operationId: createUrlSet + summary: Create a URL set + tags: [Sites] + security: + - ApiKey: [sites:write] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UrlSetWrite' + responses: + '201': + description: The URL set was created. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Location: + $ref: '#/components/headers/Location' + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/UrlSet' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /sites/{siteId}/url-sets/{urlSetId}: + parameters: + - $ref: '#/components/parameters/SiteId' + - $ref: '#/components/parameters/UrlSetId' + get: + operationId: getUrlSet + summary: Get a URL set with its URLs + tags: [Sites] + security: + - ApiKey: [sites:read] + responses: + '200': + description: The URL set. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/UrlSet' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + put: + operationId: replaceUrlSet + summary: Replace a URL set + tags: [Sites] + security: + - ApiKey: [sites:write] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UrlSetWrite' + responses: + '200': + description: The replaced URL set. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/UrlSet' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + operationId: deleteUrlSet + summary: Delete a URL set + tags: [Sites] + security: + - ApiKey: [sites:write] + responses: + '204': + description: The URL set was deleted. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /sites/{siteId}/warm-runs: + parameters: + - $ref: '#/components/parameters/SiteId' + get: + operationId: listWarmRuns + summary: List warm runs of a site + tags: [Warm runs] + security: + - ApiKey: [warm-runs:read] + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + - name: status + in: query + description: Only runs in this lifecycle status. + schema: + $ref: '#/components/schemas/RunStatus' + - name: trigger_type + in: query + description: Only runs started this way. + schema: + $ref: '#/components/schemas/TriggerType' + responses: + '200': + description: A page of warm runs, newest first. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/WarmRunList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + operationId: createWarmRun + summary: Start a warm run (for example after a deploy) + description: | + Queues a run that warms and verifies the URLs of one source, from the vantage points the + organization is entitled to, and answers `202`. Poll `GET /warm-runs/{runId}` (the `Location` + header) until `status` is `completed` or `canceled`, then read `outcome` and the results. + + The run is rejected if the site is not verified (`site-not-verified`), is paused (`site-paused`), + or the organization is over a limit (`quota-exceeded`). + + `source` may be omitted: the run then warms the URLs of the site's discovered sitemaps + (`robots.txt` and `/sitemap.xml`). In the current implementation `source` of type `sitemap` or + `url_set`, `vantage_point_ids` and every option other than `top` and `fail_on` answer `422` + (`validation-failed`) with a JSON Pointer; they are never ignored silently. Only one run per + site may be queued or running (`conflict`). + + `Idempotency-Key` is required: a retried trigger from CI must not start a second run. + + tags: [Warm runs] + security: + - ApiKey: [warm-runs:write] + parameters: + - $ref: '#/components/parameters/IdempotencyKeyRequired' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WarmRunCreate' + examples: + afterDeploy: + summary: Warm the sitemap after a production deploy + value: + source: + type: sitemap + sitemap_id: sm_example + deployment: + id: deploy-1234 + environment: production + ref: 0123abcd + options: + fail_on: [miss, error, noindex] + responses: + '202': + description: The run was accepted and queued. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Location: + $ref: '#/components/headers/Location' + Retry-After: + $ref: '#/components/headers/RetryAfter' + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/WarmRun' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /warm-runs/{runId}: + parameters: + - $ref: '#/components/parameters/RunId' + get: + operationId: getWarmRun + summary: Get a warm run (status resource) + description: | + While the run is `queued` or `running` the response carries `Retry-After` with the suggested + polling delay. `outcome` is null until the run is `completed`. + tags: [Warm runs] + security: + - ApiKey: [warm-runs:read] + responses: + '200': + description: The warm run, with a per-vantage-point summary. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Retry-After: + $ref: '#/components/headers/RetryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/WarmRun' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /warm-runs/{runId}/cancel: + parameters: + - $ref: '#/components/parameters/RunId' + post: + operationId: cancelWarmRun + summary: Cancel a warm run + description: | + Asks the vantage points to stop. URLs not requested yet are recorded as `incomplete`. Answers + `202`; the run becomes `canceled` once every vantage point has stopped. Cancelling a run that is + already `completed` or `canceled` is a no-op that returns `202` with the current state. + tags: [Warm runs] + security: + - ApiKey: [warm-runs:write] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '202': + description: Cancellation was requested. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Location: + $ref: '#/components/headers/Location' + Retry-After: + $ref: '#/components/headers/RetryAfter' + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/WarmRun' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /warm-runs/{runId}/results: + parameters: + - $ref: '#/components/parameters/RunId' + get: + operationId: listWarmRunResults + summary: List per-URL results of a warm run + description: | + One result per URL, profile and vantage point. Results of a run that is still in progress are + partial and may change until the run is `completed`. + tags: [Warm runs] + security: + - ApiKey: [warm-runs:read] + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + - name: final_state + in: query + description: Only results whose final cache status is this state. + schema: + $ref: '#/components/schemas/CacheState' + - name: vantage_point_id + in: query + description: Only results observed from this vantage point. + schema: + type: string + - name: profile + in: query + description: Only results for this request profile (for example `desktop` or `mobile`). + schema: + type: string + - name: url + in: query + description: Only the results of this exact URL. + schema: + type: string + format: uri + - name: route_group + in: query + description: Only results in this route group. + schema: + type: string + responses: + '200': + description: A page of results. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/ResultList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /sites/{siteId}/cache-status-reports: + parameters: + - $ref: '#/components/parameters/SiteId' + get: + operationId: listCacheStatusReports + summary: List cache status reports of a site + description: One report per completed warm run, newest first. + tags: [Cache status reports] + security: + - ApiKey: [cache-reports:read] + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + - name: warm_run_id + in: query + description: Only the report of this warm run. + schema: + type: string + responses: + '200': + description: A page of report summaries (without cells). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/CacheStatusReportList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /cache-status-reports/{reportId}: + parameters: + - $ref: '#/components/parameters/ReportId' + get: + operationId: getCacheStatusReport + summary: Get a cache status report + tags: [Cache status reports] + security: + - ApiKey: [cache-reports:read] + responses: + '200': + description: The report, with one cell per route group and vantage point. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/CacheStatusReport' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /sites/{siteId}/schedules: + parameters: + - $ref: '#/components/parameters/SiteId' + get: + operationId: listSchedules + summary: List schedules of a site + tags: [Schedules] + security: + - ApiKey: [schedules:read] + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: A page of schedules. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + operationId: createSchedule + summary: Create a schedule + description: The cadence is validated against the organization's limits (`quota-exceeded`). + tags: [Schedules] + security: + - ApiKey: [schedules:write] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleCreate' + responses: + '201': + description: The schedule was created. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Location: + $ref: '#/components/headers/Location' + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /schedules/{scheduleId}: + parameters: + - $ref: '#/components/parameters/ScheduleId' + get: + operationId: getSchedule + summary: Get a schedule + tags: [Schedules] + security: + - ApiKey: [schedules:read] + responses: + '200': + description: The schedule. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + patch: + operationId: updateSchedule + summary: Update a schedule + tags: [Schedules] + security: + - ApiKey: [schedules:write] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleUpdate' + responses: + '200': + description: The updated schedule. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + operationId: deleteSchedule + summary: Delete a schedule + tags: [Schedules] + security: + - ApiKey: [schedules:write] + responses: + '204': + description: The schedule was deleted. Runs already started are not affected. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /webhooks: + get: + operationId: listWebhooks + summary: List webhook endpoints + tags: [Webhooks] + security: + - ApiKey: [webhooks:read] + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: A page of webhook endpoints (secrets are never listed). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + operationId: createWebhook + summary: Create a webhook endpoint + description: | + The signing secret is returned once, in this response. Keep it: it cannot be read again, only + replaced with `rotateWebhookSecret`. + tags: [Webhooks] + security: + - ApiKey: [webhooks:write] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookCreate' + responses: + '201': + description: The endpoint was created. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Location: + $ref: '#/components/headers/Location' + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookWithSecret' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /webhooks/{webhookId}: + parameters: + - $ref: '#/components/parameters/WebhookId' + get: + operationId: getWebhook + summary: Get a webhook endpoint + tags: [Webhooks] + security: + - ApiKey: [webhooks:read] + responses: + '200': + description: The endpoint. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/Webhook' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + patch: + operationId: updateWebhook + summary: Update a webhook endpoint + tags: [Webhooks] + security: + - ApiKey: [webhooks:write] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookUpdate' + responses: + '200': + description: The updated endpoint. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/Webhook' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + operationId: deleteWebhook + summary: Delete a webhook endpoint + tags: [Webhooks] + security: + - ApiKey: [webhooks:write] + responses: + '204': + description: The endpoint was deleted. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /webhooks/{webhookId}/rotate-secret: + parameters: + - $ref: '#/components/parameters/WebhookId' + post: + operationId: rotateWebhookSecret + summary: Replace the signing secret of a webhook endpoint + description: | + Returns the new secret once. + tags: [Webhooks] + security: + - ApiKey: [webhooks:write] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + responses: + '200': + description: The endpoint with its new secret. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookWithSecret' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api-keys: + get: + operationId: listApiKeys + summary: List API keys + tags: [API keys] + security: + - ApiKey: [api-keys:read] + parameters: + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Limit' + responses: + '200': + description: A page of API keys (secrets are never listed). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiKeyList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + post: + operationId: createApiKey + summary: Create an API key + description: | + The key secret is returned once, in this response. The scopes of the new key must be a subset of + the scopes of the calling key (`scope-not-allowed` otherwise). If the calling key has an + `expires_at`, the new key must set `expires_at` no later than it (`scope-not-allowed` otherwise): + a key cannot mint a credential that outlives itself. The first key of an organization is created + in the panel, not through this API. + tags: [API keys] + security: + - ApiKey: [api-keys:write] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApiKeyCreate' + responses: + '201': + description: The key was created. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Location: + $ref: '#/components/headers/Location' + Idempotent-Replayed: + $ref: '#/components/headers/IdempotentReplayed' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiKeyWithSecret' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/Unprocessable' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + /api-keys/{apiKeyId}: + parameters: + - $ref: '#/components/parameters/ApiKeyId' + get: + operationId: getApiKey + summary: Get an API key + tags: [API keys] + security: + - ApiKey: [api-keys:read] + responses: + '200': + description: The key metadata. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiKey' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' + delete: + operationId: revokeApiKey + summary: Revoke an API key + description: Revocation is immediate and permanent. A key can revoke itself. + tags: [API keys] + security: + - ApiKey: [api-keys:write] + responses: + '204': + description: The key was revoked. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalError' +webhooks: + warmRunCompleted: + post: + operationId: onWarmRunCompleted + summary: A warm run finished + description: | + Sent when a run becomes `completed`, with its `outcome`. Use it to gate a pipeline without + polling. + + **Signature.** Each delivery carries `Sitewarm-Signature: t=,v1=`, where + `` is HMAC-SHA256, keyed with the endpoint secret, over the string + `.`. Verify it against the raw bytes, compare in constant time, and reject + a timestamp that is too old. A 2xx response acknowledges the delivery; other responses and + timeouts are retried. + tags: [Webhooks] + security: [] + parameters: + - $ref: '#/components/parameters/SignatureHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WarmRunCompletedEvent' + responses: + '200': + description: Delivery acknowledged. + warmRunCanceled: + post: + operationId: onWarmRunCanceled + summary: A warm run was canceled + description: Sent when a run becomes `canceled`. Signed like every webhook delivery. + tags: [Webhooks] + security: [] + parameters: + - $ref: '#/components/parameters/SignatureHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WarmRunCanceledEvent' + responses: + '200': + description: Delivery acknowledged. + siteVerificationChanged: + post: + operationId: onSiteVerificationChanged + summary: A site became verified or lost its verification + description: | + Sent when the verification status of a site changes. When a site stops being verified its jobs + are stopped. Signed like every webhook delivery. + tags: [Webhooks] + security: [] + parameters: + - $ref: '#/components/parameters/SignatureHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SiteVerificationChangedEvent' + responses: + '200': + description: Delivery acknowledged. +components: + securitySchemes: + ApiKey: + type: http + scheme: bearer + bearerFormat: sitewarm API key + description: | + A scoped API key sent as `Authorization: Bearer `. Operations name the scope they need in + their `security` requirement. Session cookies are not accepted. Scopes: + `sites:read`, `sites:write`, `warm-runs:read`, `warm-runs:write`, `cache-reports:read`, + `schedules:read`, `schedules:write`, `webhooks:read`, `webhooks:write`, `api-keys:read`, + `api-keys:write`. + parameters: + Cursor: + name: cursor + in: query + description: Opaque cursor from `page.next_cursor` of the previous page. Omit for the first page. + schema: + type: string + Limit: + name: limit + in: query + description: | + Maximum number of items in the page. The server may return fewer. Default 20, maximum 100. + + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: | + Client-chosen unique value for this request (a random UUID is a good choice). A retry with the + same key and body returns the first response. + schema: + type: string + minLength: 1 + maxLength: 255 + IdempotencyKeyRequired: + name: Idempotency-Key + in: header + required: true + description: | + Client-chosen unique value for this request (a random UUID is a good choice). A retry with the + same key and body returns the first response and does not start a second run. + schema: + type: string + minLength: 1 + maxLength: 255 + SignatureHeader: + name: Sitewarm-Signature + in: header + required: true + description: '`t=,v1=.">`.' + schema: + type: string + SiteId: + name: siteId + in: path + required: true + schema: + type: string + SitemapId: + name: sitemapId + in: path + required: true + schema: + type: string + UrlSetId: + name: urlSetId + in: path + required: true + schema: + type: string + RunId: + name: runId + in: path + required: true + schema: + type: string + ReportId: + name: reportId + in: path + required: true + schema: + type: string + ScheduleId: + name: scheduleId + in: path + required: true + schema: + type: string + WebhookId: + name: webhookId + in: path + required: true + schema: + type: string + ApiKeyId: + name: apiKeyId + in: path + required: true + schema: + type: string + headers: + RateLimit-Limit: + description: Requests allowed in the current window. + schema: + type: integer + RateLimit-Remaining: + description: Requests left in the current window. + schema: + type: integer + RateLimit-Reset: + description: Seconds until the current window resets. + schema: + type: integer + RetryAfter: + description: Seconds to wait before polling or retrying. + schema: + type: integer + minimum: 0 + Location: + description: URL of the created resource or of the status resource to poll. + schema: + type: string + format: uri-reference + IdempotentReplayed: + description: '`true` when this response replays the first response for the same Idempotency-Key.' + schema: + type: boolean + responses: + BadRequest: + description: The request is malformed (`invalid-request`, `idempotency-key-missing`). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Unauthorized: + description: Missing, invalid, expired or revoked API key (`unauthorized`). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Forbidden: + description: | + The key lacks the scope or is not allowed to use the site (`insufficient-scope`, + `scope-not-allowed`), or the organization is not entitled to the feature (`not-entitled`). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + NotFound: + description: The resource does not exist or belongs to another organization (`not-found`). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Conflict: + description: | + The request conflicts with the current state (`conflict`, `idempotency-key-reused`, + `site-not-verified`, `site-paused`). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + Unprocessable: + description: | + The body is well formed but not acceptable (`validation-failed`, `quota-exceeded`). + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + InternalError: + description: | + Unexpected server error (`internal-error`). The body is a problem document with no detail: nothing about the + request, the data or the cause is echoed. Retrying is safe for reads; for `createWarmRun` retry with the + same `Idempotency-Key`. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + TooManyRequests: + description: Rate limit reached (`rate-limited`). Wait for `Retry-After` seconds. + headers: + RateLimit-Limit: + $ref: '#/components/headers/RateLimit-Limit' + RateLimit-Remaining: + $ref: '#/components/headers/RateLimit-Remaining' + RateLimit-Reset: + $ref: '#/components/headers/RateLimit-Reset' + Retry-After: + $ref: '#/components/headers/RetryAfter' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + schemas: + Problem: + type: object + description: RFC 9457 problem details. + required: [type, title, status] + properties: + type: + type: string + format: uri-reference + description: | + Identifies the problem type; one of `ProblemType`, as a URI reference such as + `/problems/site-not-verified`. + examples: + - /problems/site-not-verified + code: + $ref: '#/components/schemas/ProblemType' + title: + type: string + status: + type: integer + detail: + type: string + instance: + type: string + format: uri-reference + errors: + type: array + description: Field errors, present for `validation-failed` and `invalid-request`. + items: + type: object + required: [pointer, detail] + properties: + pointer: + type: string + description: JSON Pointer to the offending member. + detail: + type: string + limit: + type: string + description: For `quota-exceeded`, the name of the limit that was reached (for example `sites`). + plan: + type: string + description: For `quota-exceeded`, the organization's plan as an opaque label. + additionalProperties: true + ProblemType: + type: string + description: Documented problem type slugs. Clients must tolerate new values. + enum: + - invalid-request + - validation-failed + - idempotency-key-missing + - idempotency-key-reused + - unauthorized + - insufficient-scope + - scope-not-allowed + - not-entitled + - not-found + - conflict + - site-not-verified + - site-paused + - quota-exceeded + - rate-limited + - internal-error + PageInfo: + type: object + required: [has_more, next_cursor] + properties: + has_more: + type: boolean + next_cursor: + type: [string, 'null'] + description: Cursor for the next page; null on the last page. + Scope: + type: string + enum: + - sites:read + - sites:write + - warm-runs:read + - warm-runs:write + - cache-reports:read + - schedules:read + - schedules:write + - webhooks:read + - webhooks:write + - api-keys:read + - api-keys:write + CacheState: + type: string + description: | + Normalized cache status of one URL and profile at one vantage point, exactly the engine states. + `unknown` means the CDN does not expose a status (see `unknown_reason`). Clients must tolerate + new values. + enum: + - hit + - stale + - miss + - bypass + - variant-unverifiable + - challenged + - client-error + - redirect-error + - error + - incomplete + - unknown + - skipped-robots + - stale-revision + - expected + - unexpected + FailOnToken: + description: | + A value accepted by the engine's `--fail-on`: any cache state, or an indexability token. + `noindex` also fails on `indexability-error`. + anyOf: + - $ref: '#/components/schemas/CacheState' + - type: string + enum: + - noindex + - indexability-inconclusive + UnknownReason: + type: string + description: | + Why a state is `unknown`. + + enum: + - cdn-hidden + - region-unavailable + Indexability: + type: object + description: Indexability is separate from the cache state. + properties: + finding: + type: [string, 'null'] + enum: [noindex, indexability-error, indexability-inconclusive, null] + description: The engine's finding; null when none was reported. + meta_robots: + type: [string, 'null'] + x_robots_tag: + type: [string, 'null'] + canonical: + type: [string, 'null'] + RunStatus: + type: string + description: | + Lifecycle of a run. Whether the run passed is `outcome`, not the status. `failed` means the + run itself broke (see `error_code`); it is never a verdict about the site. + + enum: [queued, running, completed, canceled, failed] + TriggerType: + type: string + enum: [api, schedule, panel] + Trigger: + type: object + required: [type] + properties: + type: + $ref: '#/components/schemas/TriggerType' + schedule_id: + type: [string, 'null'] + api_key_id: + type: [string, 'null'] + Deployment: + type: object + description: Context of the deploy that caused the run, echoed to the results and webhooks. + properties: + id: + type: string + environment: + type: string + ref: + type: string + description: Commit SHA, tag or other revision label. + Site: + type: object + required: [id, hostname, verification, created_at] + properties: + id: + type: string + hostname: + type: string + description: The site's hostname; apex and `www` are the same site. + verification: + type: object + description: Read-only. Verification is done outside this API. + required: [status] + properties: + status: + type: string + description: | + `verified` only while the proof is valid. A pending, expired or lapsed proof is + `unverified`. + enum: [unverified, verified] + verified_at: + type: [string, 'null'] + format: date-time + paused: + type: boolean + description: | + Read-only here. A paused site gets no new jobs. Omitted until sites can be paused. + + settings: + $ref: '#/components/schemas/SiteSettings' + description: Omitted until site settings are stored. + created_at: + type: string + format: date-time + SiteSettings: + type: object + properties: + device_variants: + type: boolean + description: | + The operator declares that the CDN segments the cache by device, so affected URLs are + reported `variant-unverifiable`. + SiteCreate: + type: object + required: [hostname] + properties: + hostname: + type: string + settings: + $ref: '#/components/schemas/SiteSettings' + SiteUpdate: + type: object + properties: + settings: + $ref: '#/components/schemas/SiteSettings' + SiteList: + type: object + required: [data, page] + properties: + data: + type: array + items: + $ref: '#/components/schemas/Site' + page: + $ref: '#/components/schemas/PageInfo' + Sitemap: + type: object + required: [id, site_id, url, created_at] + properties: + id: + type: string + site_id: + type: string + url: + type: string + format: uri + created_at: + type: string + format: date-time + SitemapCreate: + type: object + required: [url] + properties: + url: + type: string + format: uri + SitemapList: + type: object + required: [data, page] + properties: + data: + type: array + items: + $ref: '#/components/schemas/Sitemap' + page: + $ref: '#/components/schemas/PageInfo' + UrlSet: + type: object + required: [id, site_id, name, url_count, created_at] + properties: + id: + type: string + site_id: + type: string + name: + type: string + url_count: + type: integer + minimum: 0 + urls: + type: array + description: The URLs in warming priority order. Returned by `getUrlSet`, omitted in lists. + items: + type: string + format: uri + created_at: + type: string + format: date-time + UrlSetWrite: + type: object + required: [name, urls] + properties: + name: + type: string + urls: + type: array + description: Absolute URLs on the site's hostname, in priority order. + items: + type: string + format: uri + UrlSetList: + type: object + required: [data, page] + properties: + data: + type: array + items: + $ref: '#/components/schemas/UrlSet' + page: + $ref: '#/components/schemas/PageInfo' + RunSourceSitemap: + type: object + required: [type, sitemap_id] + properties: + type: + const: sitemap + sitemap_id: + type: string + RunSourceUrlSet: + type: object + required: [type, url_set_id] + properties: + type: + const: url_set + url_set_id: + type: string + RunSource: + description: Exactly one source of URLs per run, as in the engine. + oneOf: + - $ref: '#/components/schemas/RunSourceSitemap' + - $ref: '#/components/schemas/RunSourceUrlSet' + discriminator: + propertyName: type + mapping: + sitemap: '#/components/schemas/RunSourceSitemap' + url_set: '#/components/schemas/RunSourceUrlSet' + RunOptions: + type: object + description: | + Engine options for a run. Every field is optional; an omitted field takes the engine or + organization default, which this document does not define. + properties: + top: + type: integer + minimum: 1 + description: Use only the first N URLs of the source, in its order. + fail_on: + type: array + uniqueItems: true + description: | + Cache states and indexability tokens that make the run fail. Omitted means the engine + default. An empty array means nothing fails the run (the "no verification" rule still + applies). + items: + $ref: '#/components/schemas/FailOnToken' + expect_revision_header: + type: string + description: '`NAME=VALUE`. Each verified response must carry this revision, otherwise `stale-revision`.' + examples: + - X-Build-Id=0123abcd + wait_for_header: + type: string + description: '`NAME=VALUE`. Wait until the site serves this header before warming.' + wait_for_url: + type: string + format: uri + description: Wait until this URL is live (use with `expect_text`) before warming. + expect_text: + type: string + settle: + type: string + description: Fixed delay before warming, such as `30s`. + max_wait: + type: string + description: How long to retry a URL until it is a `hit`, such as `5m`. + run_timeout: + type: string + description: Cap for the whole run, such as `15m`. URLs left over become `incomplete`. + vantage_point_ids: + type: array + items: + type: string + description: | + Restrict the run to these vantage points. Omitted means all the organization may use. + + EffectiveRunOptions: + type: object + description: | + The options of a run as accepted. `fail_on` is the list the run was created with, or the list + the engine reports it applied once known; it is null until then (no default is invented). + required: [fail_on] + properties: + top: + type: [integer, 'null'] + minimum: 1 + fail_on: + type: [array, 'null'] + description: The list in effect, or null while the engine default is not yet reported. + items: + $ref: '#/components/schemas/FailOnToken' + additionalProperties: true + WarmRunCreate: + type: object + properties: + source: + $ref: '#/components/schemas/RunSource' + deployment: + $ref: '#/components/schemas/Deployment' + options: + $ref: '#/components/schemas/RunOptions' + Outcome: + type: object + description: Whether the run passes, by the `--fail-on` contract. + required: [failed, failing_states, reason] + properties: + failed: + type: boolean + failing_states: + type: array + description: Tokens from the effective `fail_on` that matched at least one result. + items: + $ref: '#/components/schemas/FailOnToken' + reason: + type: [string, 'null'] + enum: [fail_on_matched, no_verifications, null] + description: | + `no_verifications`: no URL could be verified (for example all `skipped-robots`), which + fails the run even if `skipped-robots` is not in `fail_on`. Null when the run passed. + StateCounts: + type: object + description: Number of results per cache state. States with no results may be omitted. + propertyNames: + $ref: '#/components/schemas/CacheState' + additionalProperties: + type: integer + minimum: 0 + VantagePointSummary: + type: object + required: [vantage_point_id, region, status, n, counts] + properties: + vantage_point_id: + type: string + region: + type: string + status: + $ref: '#/components/schemas/RunStatus' + n: + type: integer + minimum: 0 + description: Number of results (URL by profile) for this vantage point. + counts: + $ref: '#/components/schemas/StateCounts' + unknown_reason: + oneOf: + - $ref: '#/components/schemas/UnknownReason' + - type: 'null' + WarmRun: + type: object + required: [id, site_id, status, trigger, options, outcome, vantage_points, created_at] + properties: + id: + type: string + site_id: + type: string + status: + $ref: '#/components/schemas/RunStatus' + trigger: + $ref: '#/components/schemas/Trigger' + deployment: + oneOf: + - $ref: '#/components/schemas/Deployment' + - type: 'null' + source: + $ref: '#/components/schemas/RunSource' + options: + $ref: '#/components/schemas/EffectiveRunOptions' + outcome: + oneOf: + - $ref: '#/components/schemas/Outcome' + - type: 'null' + description: Null until the run is `completed`. + vantage_points: + type: array + items: + $ref: '#/components/schemas/VantagePointSummary' + created_at: + type: string + format: date-time + started_at: + type: [string, 'null'] + format: date-time + finished_at: + type: [string, 'null'] + format: date-time + error_code: + type: [string, 'null'] + description: A short code when `status` is `failed`; null otherwise. + WarmRunList: + type: object + required: [data, page] + properties: + data: + type: array + items: + $ref: '#/components/schemas/WarmRun' + page: + $ref: '#/components/schemas/PageInfo' + VantagePointRef: + type: object + required: [id, region] + properties: + id: + type: string + region: + type: string + Result: + type: object + description: | + What the engine observed for one URL and one profile at one vantage point. Fields marked + nullable depend on engine releases that may not be out yet. + required: [url, final_state] + properties: + url: + type: string + format: uri + profile: + type: [string, 'null'] + description: Request profile name, for example `desktop` or `mobile`. + vantage_point: + oneOf: + - $ref: '#/components/schemas/VantagePointRef' + - type: 'null' + initial_state: + description: Omitted when the engine has not reported the first observation separately. + $ref: '#/components/schemas/CacheState' + final_state: + $ref: '#/components/schemas/CacheState' + unknown_reason: + oneOf: + - $ref: '#/components/schemas/UnknownReason' + - type: 'null' + http_status: + type: [integer, 'null'] + cdn: + type: [string, 'null'] + description: Detected CDN, for example `cloudflare`. + cdn_cache_header: + type: [string, 'null'] + description: Raw cache status header value as observed (for example `EXPIRED`), never normalized. + pop: + type: [string, 'null'] + description: Point of presence when the CDN exposes it. + time_to_hit_ms: + type: [integer, 'null'] + minimum: 0 + ttfb_ms: + type: [integer, 'null'] + minimum: 0 + cache_control: + type: [string, 'null'] + age_seconds: + type: [integer, 'null'] + minimum: 0 + vary: + type: [string, 'null'] + indexability: + oneOf: + - $ref: '#/components/schemas/Indexability' + - type: 'null' + route_group: + type: [string, 'null'] + description: Route template the URL belongs to (for example `/blog/*`). + cause: + type: array + description: Engine-provided causes for a non-`hit` state, citing observed headers. `Set-Cookie` values are redacted. + items: + type: string + fix: + type: array + description: Engine-provided suggested fixes. + items: + type: string + error: + type: [string, 'null'] + ResultList: + type: object + required: [data, page] + properties: + data: + type: array + items: + $ref: '#/components/schemas/Result' + page: + $ref: '#/components/schemas/PageInfo' + ReportCell: + type: object + description: Results of one route group at one vantage point. No percentage is given on purpose. + required: [route_group, vantage_point, n, counts] + properties: + route_group: + type: string + vantage_point: + $ref: '#/components/schemas/VantagePointRef' + n: + type: integer + minimum: 0 + description: Number of results (URL by profile) in the cell. + counts: + $ref: '#/components/schemas/StateCounts' + unknown_reason: + oneOf: + - $ref: '#/components/schemas/UnknownReason' + - type: 'null' + CacheStatusReportSummary: + type: object + required: [id, site_id, warm_run_id, created_at] + properties: + id: + type: string + site_id: + type: string + warm_run_id: + type: string + created_at: + type: string + format: date-time + CacheStatusReport: + allOf: + - $ref: '#/components/schemas/CacheStatusReportSummary' + - type: object + required: [cells] + properties: + cells: + type: array + items: + $ref: '#/components/schemas/ReportCell' + CacheStatusReportList: + type: object + required: [data, page] + properties: + data: + type: array + items: + $ref: '#/components/schemas/CacheStatusReportSummary' + page: + $ref: '#/components/schemas/PageInfo' + Cadence: + type: object + description: Run every N hours or days. Allowed values depend on the organization's limits. + required: [unit, every] + properties: + unit: + type: string + enum: [hour, day] + every: + type: integer + minimum: 1 + Schedule: + type: object + required: [id, site_id, enabled, cadence, source, options, created_at] + properties: + id: + type: string + site_id: + type: string + enabled: + type: boolean + cadence: + $ref: '#/components/schemas/Cadence' + source: + $ref: '#/components/schemas/RunSource' + options: + $ref: '#/components/schemas/RunOptions' + next_run_at: + type: [string, 'null'] + format: date-time + last_run_id: + type: [string, 'null'] + created_at: + type: string + format: date-time + ScheduleCreate: + type: object + required: [cadence, source] + properties: + enabled: + type: boolean + cadence: + $ref: '#/components/schemas/Cadence' + source: + $ref: '#/components/schemas/RunSource' + options: + $ref: '#/components/schemas/RunOptions' + ScheduleUpdate: + type: object + properties: + enabled: + type: boolean + cadence: + $ref: '#/components/schemas/Cadence' + source: + $ref: '#/components/schemas/RunSource' + options: + $ref: '#/components/schemas/RunOptions' + ScheduleList: + type: object + required: [data, page] + properties: + data: + type: array + items: + $ref: '#/components/schemas/Schedule' + page: + $ref: '#/components/schemas/PageInfo' + EventType: + type: string + description: Events a webhook endpoint can subscribe to. + enum: + - warm_run.completed + - warm_run.canceled + - site.verification_changed + Webhook: + type: object + required: [id, url, events, enabled, created_at] + properties: + id: + type: string + url: + type: string + format: uri + description: HTTPS endpoint that receives deliveries. + description: + type: [string, 'null'] + events: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/EventType' + enabled: + type: boolean + created_at: + type: string + format: date-time + WebhookWithSecret: + allOf: + - $ref: '#/components/schemas/Webhook' + - type: object + required: [secret] + properties: + secret: + type: string + description: HMAC signing secret. Shown only in this response. + WebhookCreate: + type: object + required: [url, events] + properties: + url: + type: string + format: uri + description: + type: string + events: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/EventType' + enabled: + type: boolean + WebhookUpdate: + type: object + properties: + url: + type: string + format: uri + description: + type: string + events: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/EventType' + enabled: + type: boolean + WebhookList: + type: object + required: [data, page] + properties: + data: + type: array + items: + $ref: '#/components/schemas/Webhook' + page: + $ref: '#/components/schemas/PageInfo' + EventEnvelope: + type: object + required: [id, type, created_at] + properties: + id: + type: string + description: Unique per event. A redelivery of the same event keeps the id; use it to deduplicate. + type: + $ref: '#/components/schemas/EventType' + created_at: + type: string + format: date-time + WarmRunCompletedEvent: + allOf: + - $ref: '#/components/schemas/EventEnvelope' + - type: object + required: [data] + properties: + type: + const: warm_run.completed + data: + $ref: '#/components/schemas/WarmRun' + WarmRunCanceledEvent: + allOf: + - $ref: '#/components/schemas/EventEnvelope' + - type: object + required: [data] + properties: + type: + const: warm_run.canceled + data: + $ref: '#/components/schemas/WarmRun' + SiteVerificationChangedEvent: + allOf: + - $ref: '#/components/schemas/EventEnvelope' + - type: object + required: [data] + properties: + type: + const: site.verification_changed + data: + $ref: '#/components/schemas/Site' + ApiKey: + type: object + required: [id, name, prefix, scopes, created_at] + properties: + id: + type: string + name: + type: string + prefix: + type: string + description: First characters of the key, to recognize it. Not a secret. + scopes: + type: array + items: + $ref: '#/components/schemas/Scope' + site_ids: + type: [array, 'null'] + description: | + If set, the key can only act on these sites. A site-restricted key cannot hold + `webhooks:*` or `api-keys:*`. Null means all sites of the organization. + + items: + type: string + expires_at: + type: [string, 'null'] + format: date-time + last_used_at: + type: [string, 'null'] + format: date-time + created_at: + type: string + format: date-time + ApiKeyWithSecret: + allOf: + - $ref: '#/components/schemas/ApiKey' + - type: object + required: [secret] + properties: + secret: + type: string + description: The API key. Shown only in this response. + ApiKeyCreate: + type: object + required: [name, scopes] + properties: + name: + type: string + scopes: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Scope' + site_ids: + type: [array, 'null'] + items: + type: string + expires_at: + type: [string, 'null'] + format: date-time + ApiKeyList: + type: object + required: [data, page] + properties: + data: + type: array + items: + $ref: '#/components/schemas/ApiKey' + page: + $ref: '#/components/schemas/PageInfo' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f2dfd61 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["hatchling==1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "sitewarm" +version = "0.1.0" +description = "Python client for the sitewarm API (pre-release)" +readme = "README.md" +license = "Apache-2.0" +license-files = ["LICENSE"] +requires-python = ">=3.10" +dependencies = ["httpx>=0.27,<1", "pydantic>=2.6,<3"] +classifiers = [ + # Blocks accidental uploads to PyPI until a release is decided. + "Private :: Do Not Upload", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://github.com/sitewarm/sdk-python" +Issues = "https://github.com/sitewarm/sdk-python/issues" + +[dependency-groups] +dev = [ + "datamodel-code-generator==0.86.0", + "pytest>=8", + "pytest-asyncio>=0.24", + "pyyaml>=6", + "mypy>=1.10", + "types-PyYAML", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/sitewarm"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.mypy] +strict = true +files = ["src/sitewarm/_client.py", "src/sitewarm/_errors.py", "src/sitewarm/_retry.py"] diff --git a/scripts/generate.py b/scripts/generate.py new file mode 100644 index 0000000..5227523 --- /dev/null +++ b/scripts/generate.py @@ -0,0 +1,61 @@ +"""Regenerates src/sitewarm/_generated/* from the vendored spec (openapi/openapi.yaml). + +- models.py : pydantic v2 models from datamodel-code-generator +- operations.py : operationId -> method/path/idempotency table used by the runtime client + +CI runs this and fails on drift (git diff of src/sitewarm/_generated). +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +SPEC = ROOT / "openapi" / "openapi.yaml" +OUT = ROOT / "src" / "sitewarm" / "_generated" +BANNER = "# GENERATED by scripts/generate.py from openapi/openapi.yaml. Do not edit.\n" + +OUT.mkdir(parents=True, exist_ok=True) +(OUT / "__init__.py").write_text(BANNER) + +subprocess.run( + [ + sys.executable, "-m", "datamodel_code_generator", + "--input", str(SPEC), + "--input-file-type", "openapi", + "--output", str(OUT / "models.py"), + "--output-model-type", "pydantic_v2.BaseModel", + "--target-python-version", "3.10", + "--use-annotated", + "--use-standard-collections", + "--use-union-operator", + "--disable-timestamp", + "--custom-file-header", BANNER.rstrip("\n"), + ], + check=True, +) + +spec = yaml.safe_load(SPEC.read_text()) +rows = [] +for path, item in spec["paths"].items(): + for method in ("get", "put", "post", "patch", "delete"): + op = item.get(method) + if not op: + continue + refs = [p.get("$ref", "") for p in [*item.get("parameters", []), *op.get("parameters", [])]] + idem = next((r for r in refs if r.endswith("/IdempotencyKey") or r.endswith("/IdempotencyKeyRequired")), None) + kind = "none" if not idem else ("required" if idem.endswith("Required") else "optional") + rows.append(f' "{op["operationId"]}": Operation("{method.upper()}", "{path}", "{kind}"),') + +(OUT / "operations.py").write_text( + BANNER + + "from __future__ import annotations\n\nfrom typing import NamedTuple\n\n\n" + + "class Operation(NamedTuple):\n method: str\n path: str\n idempotency: str # none | optional | required\n\n\n" + + "OPERATIONS: dict[str, Operation] = {\n" + + "\n".join(rows) + + "\n}\n" +) +print(f"generated {len(rows)} operations") diff --git a/src/sitewarm/__init__.py b/src/sitewarm/__init__.py new file mode 100644 index 0000000..9b520e6 --- /dev/null +++ b/src/sitewarm/__init__.py @@ -0,0 +1,28 @@ +"""Python client for the sitewarm API (pre-release). + +Models for typing and validation live in ``sitewarm.models`` (generated from the OpenAPI spec). +""" +from ._client import ApiResponse, AsyncSiteWarm, SiteWarm +from ._errors import ( + SiteWarmApiError, + SiteWarmAuthenticationError, + SiteWarmConflictError, + SiteWarmConnectionError, + SiteWarmError, + SiteWarmNotFoundError, + SiteWarmPermissionError, + SiteWarmRateLimitError, + SiteWarmServerError, + SiteWarmTimeoutError, + SiteWarmValidationError, + SiteWarmWaitTimeoutError, +) + +__version__ = "0.1.0" + +__all__ = [ + "ApiResponse", "AsyncSiteWarm", "SiteWarm", "SiteWarmApiError", "SiteWarmAuthenticationError", + "SiteWarmConflictError", "SiteWarmConnectionError", "SiteWarmError", "SiteWarmNotFoundError", + "SiteWarmPermissionError", "SiteWarmRateLimitError", "SiteWarmServerError", "SiteWarmTimeoutError", + "SiteWarmValidationError", "SiteWarmWaitTimeoutError", +] diff --git a/src/sitewarm/_client.py b/src/sitewarm/_client.py new file mode 100644 index 0000000..cbef1ec --- /dev/null +++ b/src/sitewarm/_client.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import asyncio +import json +import re +import time +import uuid +from dataclasses import dataclass +from typing import Any, AsyncIterator, Iterator, Mapping, Optional, Union +from urllib.parse import quote, urlencode, urlsplit + +import httpx + +from ._errors import ( + SiteWarmConnectionError, + SiteWarmError, + SiteWarmTimeoutError, + SiteWarmWaitTimeoutError, + api_error_for, +) +from ._generated.operations import OPERATIONS +from ._retry import backoff_delay, parse_retry_after + +_LOOPBACK = {"localhost", "127.0.0.1", "::1"} +_TERMINAL = {"completed", "canceled", "failed"} + + +@dataclass(frozen=True) +class ApiResponse: + data: Any + status: int + headers: Mapping[str, str] + + +@dataclass +class _Prepared: + method: str + url: str + headers: dict[str, str] + content: Optional[bytes] + # 429 means "not processed". 5xx and network errors may have been, so they are only retried when + # repeating is safe: not a POST, or a POST carrying an Idempotency-Key. + retry_ambiguous: bool + + +class _Core: + def __init__( + self, + api_key: str, + base_url: str, + *, + max_retries: int = 3, + retry_base_delay: float = 0.5, + max_retry_delay: float = 30.0, + timeout: float = 30.0, + user_agent: Optional[str] = None, + ) -> None: + # Messages below never echo the key. + if not isinstance(api_key, str) or not re.fullmatch(r"[\x21-\x7e]+", api_key): + raise SiteWarmError("api_key must be a non-empty string without whitespace or control characters") + self._api_key = api_key + self._base_url = _normalize_base_url(base_url) + self._max_retries = max_retries + self._retry_base = retry_base_delay + self._retry_max = max_retry_delay + self._timeout = timeout + self._user_agent = user_agent or "sitewarm-python" + + def __repr__(self) -> str: # the key is never part of repr or str + return f"{type(self).__name__}(base_url={self._base_url!r})" + + def _prepare( + self, + operation_id: str, + path: Optional[Mapping[str, Any]], + query: Optional[Mapping[str, Any]], + body: Any, + idempotency_key: Optional[str], + ) -> _Prepared: + try: + op = OPERATIONS[operation_id] + except KeyError: + raise SiteWarmError(f"unknown operation {operation_id!r}") from None + url = self._base_url + _expand_path(op.path, path) + _query_string(query) + headers = { + "Authorization": f"Bearer {self._api_key}", + "Accept": "application/json, application/problem+json", + "User-Agent": self._user_agent, + } + content: Optional[bytes] = None + if body is not None: + if hasattr(body, "model_dump"): + body = body.model_dump(mode="json", exclude_none=True) + content = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + sends_key = op.idempotency != "none" and op.method == "POST" + if sends_key: + # One key per call, reused by every retry, so a retried POST never starts a second run. + headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4()) + return _Prepared(op.method, url, headers, content, op.method != "POST" or sends_key) + + def _decide(self, resp: httpx.Response, attempt: int, p: _Prepared) -> Union[ApiResponse, float]: + """Returns the response to hand back, or the seconds to wait before retrying. Raises on errors.""" + if resp.is_success: + return ApiResponse(_parse_body(resp), resp.status_code, resp.headers) + retry_after = parse_retry_after(resp.headers.get("Retry-After")) + status = resp.status_code + retryable = status == 429 or (status >= 500 and status != 501 and p.retry_ambiguous) + if retryable and attempt < self._max_retries: + # A server-provided wait longer than we are willing to sleep is surfaced instead of ignored. + wait = retry_after if retry_after is not None else backoff_delay(attempt, self._retry_base, self._retry_max) + if wait <= self._retry_max: + return float(wait) + raise api_error_for(status, _parse_problem(resp), retry_after, _rate_limit(resp.headers)) + + def _transport_error(self, err: Exception, attempt: int, p: _Prepared) -> float: + if attempt < self._max_retries and p.retry_ambiguous: + return backoff_delay(attempt, self._retry_base, self._retry_max) + if isinstance(err, httpx.TimeoutException): + raise SiteWarmTimeoutError(f"request timed out after {self._timeout} s") from err + raise SiteWarmConnectionError(f"request failed: {type(err).__name__}") from err + + +class SiteWarm(_Core): + """Synchronous client. ``call()`` reaches every operation of the spec by its ``operationId``. + + >>> sw = SiteWarm(api_key=key, base_url="https:///v1") # doctest: +SKIP + >>> run = sw.warm_runs.create(site_id, {"source": {"type": "sitemap", "sitemap_id": sm}}) # doctest: +SKIP + >>> done = sw.warm_runs.wait(run["id"]) # doctest: +SKIP + """ + + def __init__(self, api_key: str, base_url: str, *, http_client: Optional[httpx.Client] = None, **options: Any) -> None: + super().__init__(api_key, base_url, **options) + self._http = http_client or httpx.Client(follow_redirects=False) + self._owns_http = http_client is None + self.sites = _Sites(self) + self.sitemaps = _Sitemaps(self) + self.warm_runs = _WarmRuns(self) + self.cache_status_reports = _CacheStatusReports(self) + + def close(self) -> None: + if self._owns_http: + self._http.close() + + def __enter__(self) -> "SiteWarm": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def call(self, operation_id: str, *, path: Optional[Mapping[str, Any]] = None, query: Optional[Mapping[str, Any]] = None, + body: Any = None, idempotency_key: Optional[str] = None) -> Any: + return self.call_raw(operation_id, path=path, query=query, body=body, idempotency_key=idempotency_key).data + + def call_raw(self, operation_id: str, *, path: Optional[Mapping[str, Any]] = None, query: Optional[Mapping[str, Any]] = None, + body: Any = None, idempotency_key: Optional[str] = None) -> ApiResponse: + p = self._prepare(operation_id, path, query, body, idempotency_key) + attempt = 0 + while True: + try: + resp = self._http.request(p.method, p.url, headers=p.headers, content=p.content, timeout=self._timeout) + except httpx.HTTPError as err: + time.sleep(self._transport_error(err, attempt, p)) + attempt += 1 + continue + out = self._decide(resp, attempt, p) + if isinstance(out, ApiResponse): + return out + time.sleep(out) + attempt += 1 + + def pages(self, operation_id: str, *, max_pages: int = 10_000, **args: Any) -> Iterator[Any]: + """Yields the pages (``{"data": [...], "page": {...}}``) of a list operation.""" + query = dict(args.pop("query", None) or {}) + cursor = query.get("cursor") + for _ in range(max_pages): + if cursor: + query["cursor"] = cursor + page = self.call(operation_id, query=query, **args) + yield page + nxt = page["page"].get("next_cursor") + if not page["page"].get("has_more") or not nxt: + return + if nxt == cursor: + raise SiteWarmError("pagination cursor did not advance") + cursor = nxt + + def paginate(self, operation_id: str, **args: Any) -> Iterator[Any]: + """Yields every item of a list operation across pages.""" + for page in self.pages(operation_id, **args): + yield from page["data"] + + +class AsyncSiteWarm(_Core): + """Asynchronous twin of :class:`SiteWarm` (same arguments, ``await`` on calls).""" + + def __init__(self, api_key: str, base_url: str, *, http_client: Optional[httpx.AsyncClient] = None, **options: Any) -> None: + super().__init__(api_key, base_url, **options) + self._http = http_client or httpx.AsyncClient(follow_redirects=False) + self._owns_http = http_client is None + + async def aclose(self) -> None: + if self._owns_http: + await self._http.aclose() + + async def __aenter__(self) -> "AsyncSiteWarm": + return self + + async def __aexit__(self, *exc: object) -> None: + await self.aclose() + + async def call(self, operation_id: str, *, path: Optional[Mapping[str, Any]] = None, query: Optional[Mapping[str, Any]] = None, + body: Any = None, idempotency_key: Optional[str] = None) -> Any: + return (await self.call_raw(operation_id, path=path, query=query, body=body, idempotency_key=idempotency_key)).data + + async def call_raw(self, operation_id: str, *, path: Optional[Mapping[str, Any]] = None, query: Optional[Mapping[str, Any]] = None, + body: Any = None, idempotency_key: Optional[str] = None) -> ApiResponse: + p = self._prepare(operation_id, path, query, body, idempotency_key) + attempt = 0 + while True: + try: + resp = await self._http.request(p.method, p.url, headers=p.headers, content=p.content, timeout=self._timeout) + except httpx.HTTPError as err: + await asyncio.sleep(self._transport_error(err, attempt, p)) + attempt += 1 + continue + out = self._decide(resp, attempt, p) + if isinstance(out, ApiResponse): + return out + await asyncio.sleep(out) + attempt += 1 + + async def pages(self, operation_id: str, *, max_pages: int = 10_000, **args: Any) -> AsyncIterator[Any]: + query = dict(args.pop("query", None) or {}) + cursor = query.get("cursor") + for _ in range(max_pages): + if cursor: + query["cursor"] = cursor + page = await self.call(operation_id, query=query, **args) + yield page + nxt = page["page"].get("next_cursor") + if not page["page"].get("has_more") or not nxt: + return + if nxt == cursor: + raise SiteWarmError("pagination cursor did not advance") + cursor = nxt + + async def paginate(self, operation_id: str, **args: Any) -> AsyncIterator[Any]: + async for page in self.pages(operation_id, **args): + for item in page["data"]: + yield item + + +class _Sites: + def __init__(self, c: SiteWarm) -> None: + self._c = c + + def list(self, **query: Any) -> Any: + return self._c.call("listSites", query=query) + + def list_all(self, **query: Any) -> Iterator[Any]: + return self._c.paginate("listSites", query=query) + + def get(self, site_id: str) -> Any: + return self._c.call("getSite", path={"siteId": site_id}) + + def create(self, body: Any, *, idempotency_key: Optional[str] = None) -> Any: + return self._c.call("createSite", body=body, idempotency_key=idempotency_key) + + def update(self, site_id: str, body: Any) -> Any: + return self._c.call("updateSite", path={"siteId": site_id}, body=body) + + def delete(self, site_id: str) -> Any: + return self._c.call("deleteSite", path={"siteId": site_id}) + + +class _Sitemaps: + def __init__(self, c: SiteWarm) -> None: + self._c = c + + def list(self, site_id: str, **query: Any) -> Any: + return self._c.call("listSitemaps", path={"siteId": site_id}, query=query) + + def get(self, site_id: str, sitemap_id: str) -> Any: + return self._c.call("getSitemap", path={"siteId": site_id, "sitemapId": sitemap_id}) + + def create(self, site_id: str, body: Any, *, idempotency_key: Optional[str] = None) -> Any: + return self._c.call("createSitemap", path={"siteId": site_id}, body=body, idempotency_key=idempotency_key) + + def delete(self, site_id: str, sitemap_id: str) -> Any: + return self._c.call("deleteSitemap", path={"siteId": site_id, "sitemapId": sitemap_id}) + + +class _WarmRuns: + def __init__(self, c: SiteWarm) -> None: + self._c = c + + def create(self, site_id: str, body: Any, *, idempotency_key: Optional[str] = None) -> Any: + """Starts a run (202). Sends ``Idempotency-Key`` (generated if you do not pass one).""" + return self._c.call("createWarmRun", path={"siteId": site_id}, body=body, idempotency_key=idempotency_key) + + def get(self, run_id: str) -> Any: + return self._c.call("getWarmRun", path={"runId": run_id}) + + def cancel(self, run_id: str) -> Any: + return self._c.call("cancelWarmRun", path={"runId": run_id}) + + def list_for_site(self, site_id: str, **query: Any) -> Any: + return self._c.call("listWarmRuns", path={"siteId": site_id}, query=query) + + def results(self, run_id: str, **query: Any) -> Any: + return self._c.call("listWarmRunResults", path={"runId": run_id}, query=query) + + def list_all_results(self, run_id: str, **query: Any) -> Iterator[Any]: + return self._c.paginate("listWarmRunResults", path={"runId": run_id}, query=query) + + def wait(self, run_id: str, *, timeout: float = 900.0, poll_interval: float = 5.0, + min_poll_interval: float = 1.0, max_poll_interval: float = 60.0) -> Any: + """Polls until the run is ``completed``, ``canceled`` or ``failed``, honoring ``Retry-After``. + + Raises :class:`SiteWarmWaitTimeoutError` on timeout; the run is NOT canceled. + """ + deadline = time.monotonic() + timeout + while True: + r = self._c.call_raw("getWarmRun", path={"runId": run_id}) + run = r.data + if run["status"] in _TERMINAL: + return run + remaining = deadline - time.monotonic() + if remaining <= 0: + raise SiteWarmWaitTimeoutError(run_id, run["status"], timeout) + hinted = parse_retry_after(r.headers.get("Retry-After")) + delay = min(max_poll_interval, max(min_poll_interval, float(hinted) if hinted is not None else poll_interval)) + if delay >= remaining: + time.sleep(remaining) + final = self._c.call("getWarmRun", path={"runId": run_id}) + if final["status"] in _TERMINAL: + return final + raise SiteWarmWaitTimeoutError(run_id, final["status"], timeout) + time.sleep(delay) + + +class _CacheStatusReports: + def __init__(self, c: SiteWarm) -> None: + self._c = c + + def list_for_site(self, site_id: str, **query: Any) -> Any: + return self._c.call("listCacheStatusReports", path={"siteId": site_id}, query=query) + + def get(self, report_id: str) -> Any: + return self._c.call("getCacheStatusReport", path={"reportId": report_id}) + + +def _normalize_base_url(raw: str) -> str: + u = urlsplit(raw) + if not u.scheme or not u.hostname: + raise SiteWarmError("base_url must be an absolute URL such as https:///v1") + if u.scheme != "https" and not (u.scheme == "http" and u.hostname in _LOOPBACK): + raise SiteWarmError("base_url must use https (http is only allowed for localhost)") + if u.username or u.password: + raise SiteWarmError("base_url must not contain credentials") + if u.query or u.fragment: + raise SiteWarmError("base_url must not contain a query or fragment") + return f"{u.scheme}://{u.netloc}{u.path.rstrip('/')}" + + +def _expand_path(template: str, values: Optional[Mapping[str, Any]]) -> str: + def sub(m: "re.Match[str]") -> str: + v = (values or {}).get(m.group(1)) + if v is None or v == "": + raise SiteWarmError(f'missing path parameter "{m.group(1)}"') + return quote(str(v), safe="") + + return re.sub(r"\{(\w+)\}", sub, template) + + +def _query_string(query: Optional[Mapping[str, Any]]) -> str: + if not query: + return "" + pairs: list[tuple[str, str]] = [(k, str(v).lower() if isinstance(v, bool) else str(v)) for k, v in query.items() if v is not None] + return "?" + urlencode(pairs) if pairs else "" + + +def _parse_body(resp: httpx.Response) -> Any: + if resp.status_code == 204 or not resp.content: + return None + try: + return resp.json() + except ValueError: + raise SiteWarmError(f"invalid JSON in {resp.status_code} response") from None + + +def _parse_problem(resp: httpx.Response) -> dict[str, Any]: + fallback: dict[str, Any] = {"type": "about:blank", "title": resp.reason_phrase or f"HTTP {resp.status_code}", "status": resp.status_code} + try: + j = resp.json() + except ValueError: + return fallback + if isinstance(j, dict): + return {**j, "type": j.get("type") if isinstance(j.get("type"), str) else fallback["type"], + "title": j.get("title") if isinstance(j.get("title"), str) else fallback["title"], + "status": j.get("status") if isinstance(j.get("status"), int) else resp.status_code} + return fallback + + +def _rate_limit(h: Mapping[str, str]) -> dict[str, int]: + out: dict[str, int] = {} + for header, key in (("RateLimit-Limit", "limit"), ("RateLimit-Remaining", "remaining"), ("RateLimit-Reset", "reset")): + v = h.get(header) + if v is not None and re.fullmatch(r"\d+", v.strip()): + out[key] = int(v) + return out diff --git a/src/sitewarm/_errors.py b/src/sitewarm/_errors.py new file mode 100644 index 0000000..77a95d0 --- /dev/null +++ b/src/sitewarm/_errors.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import re +from typing import Any, Optional + + +class SiteWarmError(Exception): + """Base class of every error raised by this SDK. Never contains the API key.""" + + +class SiteWarmConnectionError(SiteWarmError): + """The request never produced an HTTP response (DNS, TLS, reset, ...).""" + + +class SiteWarmTimeoutError(SiteWarmConnectionError): + """The per-request timeout elapsed.""" + + +class SiteWarmApiError(SiteWarmError): + """The API answered with a non-2xx status. + + ``problem`` is the RFC 9457 body, ``code`` the problem type slug when present, + ``retry_after_seconds`` the parsed ``Retry-After`` and ``rate_limit`` the RateLimit-* headers. + """ + + def __init__( + self, + status: int, + problem: dict[str, Any], + retry_after_seconds: Optional[int] = None, + rate_limit: Optional[dict[str, int]] = None, + ) -> None: + title, detail = problem.get("title"), problem.get("detail") + super().__init__( + f"sitewarm API error {status}" + (f": {title}" if title else "") + (f" ({detail})" if detail else "") + ) + self.status = status + self.problem = problem + self.code = _problem_code(problem) + self.retry_after_seconds = retry_after_seconds + self.rate_limit = rate_limit or {} + + +class SiteWarmAuthenticationError(SiteWarmApiError): ... +class SiteWarmPermissionError(SiteWarmApiError): ... +class SiteWarmNotFoundError(SiteWarmApiError): ... +class SiteWarmConflictError(SiteWarmApiError): ... +class SiteWarmValidationError(SiteWarmApiError): + """400 and 422: inspect ``problem["errors"]`` for field-level details.""" +class SiteWarmRateLimitError(SiteWarmApiError): ... +class SiteWarmServerError(SiteWarmApiError): ... + + +class SiteWarmWaitTimeoutError(SiteWarmError): + """``wait_for_run`` gave up before the run finished. The run keeps going server side.""" + + def __init__(self, run_id: str, last_status: str, timeout: float) -> None: + super().__init__(f"warm run {run_id} did not finish within {timeout} s (last status: {last_status})") + self.run_id = run_id + self.last_status = last_status + + +def _problem_code(problem: dict[str, Any]) -> Optional[str]: + code = problem.get("code") + if isinstance(code, str): + return code + t = problem.get("type") + if isinstance(t, str): + slug = [p for p in t.split("/") if p][-1:] or [""] + if re.fullmatch(r"[a-z0-9-]+", slug[0]): + return slug[0] + return None + + +def api_error_for( + status: int, + problem: dict[str, Any], + retry_after_seconds: Optional[int] = None, + rate_limit: Optional[dict[str, int]] = None, +) -> SiteWarmApiError: + cls: type[SiteWarmApiError] + if status == 401: + cls = SiteWarmAuthenticationError + elif status == 403: + cls = SiteWarmPermissionError + elif status == 404: + cls = SiteWarmNotFoundError + elif status == 409: + cls = SiteWarmConflictError + elif status in (400, 422): + cls = SiteWarmValidationError + elif status == 429: + cls = SiteWarmRateLimitError + elif status >= 500: + cls = SiteWarmServerError + else: + cls = SiteWarmApiError + return cls(status, problem, retry_after_seconds, rate_limit) diff --git a/src/sitewarm/_generated/__init__.py b/src/sitewarm/_generated/__init__.py new file mode 100644 index 0000000..964ee50 --- /dev/null +++ b/src/sitewarm/_generated/__init__.py @@ -0,0 +1 @@ +# GENERATED by scripts/generate.py from openapi/openapi.yaml. Do not edit. diff --git a/src/sitewarm/_generated/models.py b/src/sitewarm/_generated/models.py new file mode 100644 index 0000000..89b84bd --- /dev/null +++ b/src/sitewarm/_generated/models.py @@ -0,0 +1,694 @@ +# GENERATED by scripts/generate.py from openapi/openapi.yaml. Do not edit. + +from __future__ import annotations + +from enum import Enum +from typing import Annotated, Literal + +from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, Field, RootModel +from typing_extensions import TypeAliasType + + +class Error(BaseModel): + pointer: Annotated[str, Field(description='JSON Pointer to the offending member.')] + detail: str + + +class ProblemType(Enum): + invalid_request = 'invalid-request' + validation_failed = 'validation-failed' + idempotency_key_missing = 'idempotency-key-missing' + idempotency_key_reused = 'idempotency-key-reused' + unauthorized = 'unauthorized' + insufficient_scope = 'insufficient-scope' + scope_not_allowed = 'scope-not-allowed' + not_entitled = 'not-entitled' + not_found = 'not-found' + conflict = 'conflict' + site_not_verified = 'site-not-verified' + site_paused = 'site-paused' + quota_exceeded = 'quota-exceeded' + rate_limited = 'rate-limited' + internal_error = 'internal-error' + + +class PageInfo(BaseModel): + has_more: bool + next_cursor: Annotated[ + str | None, + Field(description='Cursor for the next page; null on the last page.'), + ] + + +class Scope(Enum): + sites_read = 'sites:read' + sites_write = 'sites:write' + warm_runs_read = 'warm-runs:read' + warm_runs_write = 'warm-runs:write' + cache_reports_read = 'cache-reports:read' + schedules_read = 'schedules:read' + schedules_write = 'schedules:write' + webhooks_read = 'webhooks:read' + webhooks_write = 'webhooks:write' + api_keys_read = 'api-keys:read' + api_keys_write = 'api-keys:write' + + +class CacheState(Enum): + hit = 'hit' + stale = 'stale' + miss = 'miss' + bypass = 'bypass' + variant_unverifiable = 'variant-unverifiable' + challenged = 'challenged' + client_error = 'client-error' + redirect_error = 'redirect-error' + error = 'error' + incomplete = 'incomplete' + unknown = 'unknown' + skipped_robots = 'skipped-robots' + stale_revision = 'stale-revision' + expected = 'expected' + unexpected = 'unexpected' + + +class FailOnToken1(Enum): + noindex = 'noindex' + indexability_inconclusive = 'indexability-inconclusive' + + +class FailOnToken(RootModel[CacheState | FailOnToken1]): + root: Annotated[ + CacheState | FailOnToken1, + Field( + description="A value accepted by the engine's `--fail-on`: any cache state, or an indexability token.\n`noindex` also fails on `indexability-error`.\n" + ), + ] + + +class UnknownReason(Enum): + cdn_hidden = 'cdn-hidden' + region_unavailable = 'region-unavailable' + + +class Finding(Enum): + noindex = 'noindex' + indexability_error = 'indexability-error' + indexability_inconclusive = 'indexability-inconclusive' + NoneType_None = None + + +class Indexability(BaseModel): + finding: Annotated[ + Finding | None, + Field(description="The engine's finding; null when none was reported."), + ] = None + meta_robots: str | None = None + x_robots_tag: str | None = None + canonical: str | None = None + + +class RunStatus(Enum): + queued = 'queued' + running = 'running' + completed = 'completed' + canceled = 'canceled' + failed = 'failed' + + +class TriggerType(Enum): + api = 'api' + schedule = 'schedule' + panel = 'panel' + + +class Trigger(BaseModel): + type: TriggerType + schedule_id: str | None = None + api_key_id: str | None = None + + +class Deployment(BaseModel): + id: str | None = None + environment: str | None = None + ref: Annotated[ + str | None, Field(description='Commit SHA, tag or other revision label.') + ] = None + + +class Status(Enum): + unverified = 'unverified' + verified = 'verified' + + +class Verification(BaseModel): + status: Annotated[ + Status, + Field( + description='`verified` only while the proof is valid. A pending, expired or lapsed proof is\n`unverified`.\n' + ), + ] + verified_at: AwareDatetime | None = None + + +class SiteSettings(BaseModel): + device_variants: Annotated[ + bool | None, + Field( + description='The operator declares that the CDN segments the cache by device, so affected URLs are\nreported `variant-unverifiable`.\n' + ), + ] = None + + +class SiteCreate(BaseModel): + hostname: str + settings: SiteSettings | None = None + + +class SiteUpdate(BaseModel): + settings: SiteSettings | None = None + + +class Sitemap(BaseModel): + id: str + site_id: str + url: AnyUrl + created_at: AwareDatetime + + +class SitemapCreate(BaseModel): + url: AnyUrl + + +class SitemapList(BaseModel): + data: list[Sitemap] + page: PageInfo + + +class UrlSet(BaseModel): + id: str + site_id: str + name: str + url_count: Annotated[int, Field(ge=0)] + urls: Annotated[ + list[AnyUrl] | None, + Field( + description='The URLs in warming priority order. Returned by `getUrlSet`, omitted in lists.' + ), + ] = None + created_at: AwareDatetime + + +class UrlSetWrite(BaseModel): + name: str + urls: Annotated[ + list[AnyUrl], + Field(description="Absolute URLs on the site's hostname, in priority order."), + ] + + +class UrlSetList(BaseModel): + data: list[UrlSet] + page: PageInfo + + +class RunSourceSitemap(BaseModel): + type: Literal['sitemap'] + sitemap_id: str + + +class RunSourceUrlSet(BaseModel): + type: Literal['url_set'] + url_set_id: str + + +class RunSource(RootModel[RunSourceSitemap | RunSourceUrlSet]): + root: Annotated[ + RunSourceSitemap | RunSourceUrlSet, + Field( + description='Exactly one source of URLs per run, as in the engine.', + discriminator='type', + ), + ] + + +class RunOptions(BaseModel): + top: Annotated[ + int | None, + Field( + description='Use only the first N URLs of the source, in its order.', ge=1 + ), + ] = None + fail_on: Annotated[ + list[FailOnToken] | None, + Field( + description='Cache states and indexability tokens that make the run fail. Omitted means the engine\ndefault. An empty array means nothing fails the run (the "no verification" rule still\napplies).\n' + ), + ] = None + expect_revision_header: Annotated[ + str | None, + Field( + description='`NAME=VALUE`. Each verified response must carry this revision, otherwise `stale-revision`.', + examples=['X-Build-Id=0123abcd'], + ), + ] = None + wait_for_header: Annotated[ + str | None, + Field( + description='`NAME=VALUE`. Wait until the site serves this header before warming.' + ), + ] = None + wait_for_url: Annotated[ + AnyUrl | None, + Field( + description='Wait until this URL is live (use with `expect_text`) before warming.' + ), + ] = None + expect_text: str | None = None + settle: Annotated[ + str | None, Field(description='Fixed delay before warming, such as `30s`.') + ] = None + max_wait: Annotated[ + str | None, + Field(description='How long to retry a URL until it is a `hit`, such as `5m`.'), + ] = None + run_timeout: Annotated[ + str | None, + Field( + description='Cap for the whole run, such as `15m`. URLs left over become `incomplete`.' + ), + ] = None + vantage_point_ids: Annotated[ + list[str] | None, + Field( + description='Restrict the run to these vantage points. Omitted means all the organization may use.\n' + ), + ] = None + + +class EffectiveRunOptions(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + top: Annotated[int | None, Field(ge=1)] = None + fail_on: Annotated[ + list[FailOnToken] | None, + Field( + description='The list in effect, or null while the engine default is not yet reported.' + ), + ] + + +class WarmRunCreate(BaseModel): + source: RunSource | None = None + deployment: Deployment | None = None + options: RunOptions | None = None + + +class Reason(Enum): + fail_on_matched = 'fail_on_matched' + no_verifications = 'no_verifications' + NoneType_None = None + + +class Outcome(BaseModel): + failed: bool + failing_states: Annotated[ + list[FailOnToken], + Field( + description='Tokens from the effective `fail_on` that matched at least one result.' + ), + ] + reason: Annotated[ + Reason | None, + Field( + description='`no_verifications`: no URL could be verified (for example all `skipped-robots`), which\nfails the run even if `skipped-robots` is not in `fail_on`. Null when the run passed.\n' + ), + ] + + +StateCountsAdditionalProperty = TypeAliasType( + "StateCountsAdditionalProperty", Annotated[int, Field(ge=0)] +) + + +class StateCounts(RootModel[dict[CacheState, StateCountsAdditionalProperty]]): + root: Annotated[ + dict[CacheState, StateCountsAdditionalProperty], + Field( + description='Number of results per cache state. States with no results may be omitted.' + ), + ] + + +class VantagePointSummary(BaseModel): + vantage_point_id: str + region: str + status: RunStatus + n: Annotated[ + int, + Field( + description='Number of results (URL by profile) for this vantage point.', + ge=0, + ), + ] + counts: StateCounts + unknown_reason: UnknownReason | None = None + + +class WarmRun(BaseModel): + id: str + site_id: str + status: RunStatus + trigger: Trigger + deployment: Deployment | None = None + source: RunSource | None = None + options: EffectiveRunOptions + outcome: Annotated[ + Outcome | None, Field(description='Null until the run is `completed`.') + ] + vantage_points: list[VantagePointSummary] + created_at: AwareDatetime + started_at: AwareDatetime | None = None + finished_at: AwareDatetime | None = None + error_code: Annotated[ + str | None, + Field(description='A short code when `status` is `failed`; null otherwise.'), + ] = None + + +class WarmRunList(BaseModel): + data: list[WarmRun] + page: PageInfo + + +class VantagePointRef(BaseModel): + id: str + region: str + + +class Result(BaseModel): + url: AnyUrl + profile: Annotated[ + str | None, + Field(description='Request profile name, for example `desktop` or `mobile`.'), + ] = None + vantage_point: VantagePointRef | None = None + initial_state: Annotated[ + CacheState | None, + Field( + description='Omitted when the engine has not reported the first observation separately.' + ), + ] = None + final_state: CacheState + unknown_reason: UnknownReason | None = None + http_status: int | None = None + cdn: Annotated[ + str | None, Field(description='Detected CDN, for example `cloudflare`.') + ] = None + cdn_cache_header: Annotated[ + str | None, + Field( + description='Raw cache status header value as observed (for example `EXPIRED`), never normalized.' + ), + ] = None + pop: Annotated[ + str | None, Field(description='Point of presence when the CDN exposes it.') + ] = None + time_to_hit_ms: Annotated[int | None, Field(ge=0)] = None + ttfb_ms: Annotated[int | None, Field(ge=0)] = None + cache_control: str | None = None + age_seconds: Annotated[int | None, Field(ge=0)] = None + vary: str | None = None + indexability: Indexability | None = None + route_group: Annotated[ + str | None, + Field(description='Route template the URL belongs to (for example `/blog/*`).'), + ] = None + cause: Annotated[ + list[str] | None, + Field( + description='Engine-provided causes for a non-`hit` state, citing observed headers. `Set-Cookie` values are redacted.' + ), + ] = None + fix: Annotated[ + list[str] | None, Field(description='Engine-provided suggested fixes.') + ] = None + error: str | None = None + + +class ResultList(BaseModel): + data: list[Result] + page: PageInfo + + +class ReportCell(BaseModel): + route_group: str + vantage_point: VantagePointRef + n: Annotated[ + int, Field(description='Number of results (URL by profile) in the cell.', ge=0) + ] + counts: StateCounts + unknown_reason: UnknownReason | None = None + + +class CacheStatusReportSummary(BaseModel): + id: str + site_id: str + warm_run_id: str + created_at: AwareDatetime + + +class CacheStatusReport(CacheStatusReportSummary): + cells: list[ReportCell] + + +class CacheStatusReportList(BaseModel): + data: list[CacheStatusReportSummary] + page: PageInfo + + +class Unit(Enum): + hour = 'hour' + day = 'day' + + +class Cadence(BaseModel): + unit: Unit + every: Annotated[int, Field(ge=1)] + + +class Schedule(BaseModel): + id: str + site_id: str + enabled: bool + cadence: Cadence + source: RunSource + options: RunOptions + next_run_at: AwareDatetime | None = None + last_run_id: str | None = None + created_at: AwareDatetime + + +class ScheduleCreate(BaseModel): + enabled: bool | None = None + cadence: Cadence + source: RunSource + options: RunOptions | None = None + + +class ScheduleUpdate(BaseModel): + enabled: bool | None = None + cadence: Cadence | None = None + source: RunSource | None = None + options: RunOptions | None = None + + +class ScheduleList(BaseModel): + data: list[Schedule] + page: PageInfo + + +class EventType(Enum): + warm_run_completed = 'warm_run.completed' + warm_run_canceled = 'warm_run.canceled' + site_verification_changed = 'site.verification_changed' + + +class Webhook(BaseModel): + id: str + url: Annotated[ + AnyUrl, Field(description='HTTPS endpoint that receives deliveries.') + ] + description: str | None = None + events: Annotated[list[EventType], Field(min_length=1)] + enabled: bool + created_at: AwareDatetime + + +class WebhookWithSecret(Webhook): + secret: Annotated[ + str, Field(description='HMAC signing secret. Shown only in this response.') + ] + + +class WebhookCreate(BaseModel): + url: AnyUrl + description: str | None = None + events: Annotated[list[EventType], Field(min_length=1)] + enabled: bool | None = None + + +class WebhookUpdate(BaseModel): + url: AnyUrl | None = None + description: str | None = None + events: Annotated[list[EventType] | None, Field(min_length=1)] = None + enabled: bool | None = None + + +class WebhookList(BaseModel): + data: list[Webhook] + page: PageInfo + + +class EventEnvelope(BaseModel): + id: Annotated[ + str, + Field( + description='Unique per event. A redelivery of the same event keeps the id; use it to deduplicate.' + ), + ] + type: EventType + created_at: AwareDatetime + + +class Type(Enum): + warm_run_completed = 'warm_run.completed' + warm_run_canceled = 'warm_run.canceled' + site_verification_changed = 'site.verification_changed' + + +class WarmRunCompletedEvent(EventEnvelope): + type: Annotated[ + Literal['warm_run.completed'], + Field(description='Events a webhook endpoint can subscribe to.'), + ] + data: WarmRun + + +class WarmRunCanceledEvent(EventEnvelope): + type: Annotated[ + Literal['warm_run.canceled'], + Field(description='Events a webhook endpoint can subscribe to.'), + ] + data: WarmRun + + +class ApiKey(BaseModel): + id: str + name: str + prefix: Annotated[ + str, + Field( + description='First characters of the key, to recognize it. Not a secret.' + ), + ] + scopes: list[Scope] + site_ids: Annotated[ + list[str] | None, + Field( + description='If set, the key can only act on these sites. A site-restricted key cannot hold\n`webhooks:*` or `api-keys:*`. Null means all sites of the organization.\n' + ), + ] = None + expires_at: AwareDatetime | None = None + last_used_at: AwareDatetime | None = None + created_at: AwareDatetime + + +class ApiKeyWithSecret(ApiKey): + secret: Annotated[ + str, Field(description='The API key. Shown only in this response.') + ] + + +class ApiKeyCreate(BaseModel): + name: str + scopes: Annotated[list[Scope], Field(min_length=1)] + site_ids: list[str] | None = None + expires_at: AwareDatetime | None = None + + +class ApiKeyList(BaseModel): + data: list[ApiKey] + page: PageInfo + + +class Problem(BaseModel): + model_config = ConfigDict( + extra='allow', + ) + type: Annotated[ + str, + Field( + description='Identifies the problem type; one of `ProblemType`, as a URI reference such as\n`/problems/site-not-verified`.\n', + examples=['/problems/site-not-verified'], + ), + ] + code: ProblemType | None = None + title: str + status: int + detail: str | None = None + instance: str | None = None + errors: Annotated[ + list[Error] | None, + Field( + description='Field errors, present for `validation-failed` and `invalid-request`.' + ), + ] = None + limit: Annotated[ + str | None, + Field( + description='For `quota-exceeded`, the name of the limit that was reached (for example `sites`).' + ), + ] = None + plan: Annotated[ + str | None, + Field( + description="For `quota-exceeded`, the organization's plan as an opaque label." + ), + ] = None + + +class Site(BaseModel): + id: str + hostname: Annotated[ + str, Field(description="The site's hostname; apex and `www` are the same site.") + ] + verification: Annotated[ + Verification, + Field(description='Read-only. Verification is done outside this API.'), + ] + paused: Annotated[ + bool | None, + Field( + description='Read-only here. A paused site gets no new jobs. Omitted until sites can be paused.\n' + ), + ] = None + settings: Annotated[ + SiteSettings | None, + Field(description='Omitted until site settings are stored.'), + ] = None + created_at: AwareDatetime + + +class SiteList(BaseModel): + data: list[Site] + page: PageInfo + + +class SiteVerificationChangedEvent(EventEnvelope): + type: Annotated[ + Literal['site.verification_changed'], + Field(description='Events a webhook endpoint can subscribe to.'), + ] + data: Site diff --git a/src/sitewarm/_generated/operations.py b/src/sitewarm/_generated/operations.py new file mode 100644 index 0000000..c8bdfb2 --- /dev/null +++ b/src/sitewarm/_generated/operations.py @@ -0,0 +1,50 @@ +# GENERATED by scripts/generate.py from openapi/openapi.yaml. Do not edit. +from __future__ import annotations + +from typing import NamedTuple + + +class Operation(NamedTuple): + method: str + path: str + idempotency: str # none | optional | required + + +OPERATIONS: dict[str, Operation] = { + "listSites": Operation("GET", "/sites", "none"), + "createSite": Operation("POST", "/sites", "optional"), + "getSite": Operation("GET", "/sites/{siteId}", "none"), + "updateSite": Operation("PATCH", "/sites/{siteId}", "none"), + "deleteSite": Operation("DELETE", "/sites/{siteId}", "none"), + "listSitemaps": Operation("GET", "/sites/{siteId}/sitemaps", "none"), + "createSitemap": Operation("POST", "/sites/{siteId}/sitemaps", "optional"), + "getSitemap": Operation("GET", "/sites/{siteId}/sitemaps/{sitemapId}", "none"), + "deleteSitemap": Operation("DELETE", "/sites/{siteId}/sitemaps/{sitemapId}", "none"), + "listUrlSets": Operation("GET", "/sites/{siteId}/url-sets", "none"), + "createUrlSet": Operation("POST", "/sites/{siteId}/url-sets", "optional"), + "getUrlSet": Operation("GET", "/sites/{siteId}/url-sets/{urlSetId}", "none"), + "replaceUrlSet": Operation("PUT", "/sites/{siteId}/url-sets/{urlSetId}", "none"), + "deleteUrlSet": Operation("DELETE", "/sites/{siteId}/url-sets/{urlSetId}", "none"), + "listWarmRuns": Operation("GET", "/sites/{siteId}/warm-runs", "none"), + "createWarmRun": Operation("POST", "/sites/{siteId}/warm-runs", "required"), + "getWarmRun": Operation("GET", "/warm-runs/{runId}", "none"), + "cancelWarmRun": Operation("POST", "/warm-runs/{runId}/cancel", "optional"), + "listWarmRunResults": Operation("GET", "/warm-runs/{runId}/results", "none"), + "listCacheStatusReports": Operation("GET", "/sites/{siteId}/cache-status-reports", "none"), + "getCacheStatusReport": Operation("GET", "/cache-status-reports/{reportId}", "none"), + "listSchedules": Operation("GET", "/sites/{siteId}/schedules", "none"), + "createSchedule": Operation("POST", "/sites/{siteId}/schedules", "optional"), + "getSchedule": Operation("GET", "/schedules/{scheduleId}", "none"), + "updateSchedule": Operation("PATCH", "/schedules/{scheduleId}", "none"), + "deleteSchedule": Operation("DELETE", "/schedules/{scheduleId}", "none"), + "listWebhooks": Operation("GET", "/webhooks", "none"), + "createWebhook": Operation("POST", "/webhooks", "optional"), + "getWebhook": Operation("GET", "/webhooks/{webhookId}", "none"), + "updateWebhook": Operation("PATCH", "/webhooks/{webhookId}", "none"), + "deleteWebhook": Operation("DELETE", "/webhooks/{webhookId}", "none"), + "rotateWebhookSecret": Operation("POST", "/webhooks/{webhookId}/rotate-secret", "optional"), + "listApiKeys": Operation("GET", "/api-keys", "none"), + "createApiKey": Operation("POST", "/api-keys", "optional"), + "getApiKey": Operation("GET", "/api-keys/{apiKeyId}", "none"), + "revokeApiKey": Operation("DELETE", "/api-keys/{apiKeyId}", "none"), +} diff --git a/src/sitewarm/_retry.py b/src/sitewarm/_retry.py new file mode 100644 index 0000000..4f4c7ed --- /dev/null +++ b/src/sitewarm/_retry.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import math +import random +import re +import time +from email.utils import parsedate_to_datetime +from typing import Callable, Optional + + +def parse_retry_after(value: Optional[str], now: Optional[float] = None) -> Optional[int]: + """Parses ``Retry-After``: delta-seconds or an HTTP-date. Returns seconds (>= 0) or None.""" + if value is None: + return None + v = value.strip() + if not v: + return None + if re.fullmatch(r"\d+", v): + return int(v) + try: + at = parsedate_to_datetime(v).timestamp() + except (TypeError, ValueError): + return None + return max(0, math.ceil(at - (time.time() if now is None else now))) + + +def backoff_delay(attempt: int, base: float, maximum: float, rand: Callable[[], float] = random.random) -> float: + """Exponential backoff with "equal jitter": half fixed, half random. Seconds.""" + ceiling = min(maximum, base * 2**attempt) + return float(ceiling / 2 + rand() * (ceiling / 2)) diff --git a/src/sitewarm/models.py b/src/sitewarm/models.py new file mode 100644 index 0000000..5d2dcf9 --- /dev/null +++ b/src/sitewarm/models.py @@ -0,0 +1,2 @@ +"""Pydantic models generated from the OpenAPI spec, re-exported for convenience.""" +from ._generated.models import * # noqa: F401,F403 diff --git a/src/sitewarm/py.typed b/src/sitewarm/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..a6bf8d3 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +from typing import Optional + +import httpx +import pytest + +from sitewarm import ( + AsyncSiteWarm, + SiteWarm, + SiteWarmAuthenticationError, + SiteWarmConnectionError, + SiteWarmError, + SiteWarmNotFoundError, + SiteWarmRateLimitError, + SiteWarmServerError, + SiteWarmValidationError, + SiteWarmWaitTimeoutError, +) +from sitewarm._retry import backoff_delay, parse_retry_after + +KEY = "swk_secret_key" +BASE = "https://api.example.test/v1" + + +def make(handler, **kw) -> SiteWarm: + kw.setdefault("retry_base_delay", 0.0) + return SiteWarm(KEY, BASE, http_client=httpx.Client(transport=httpx.MockTransport(handler)), **kw) + + +def test_request_shape_and_auth_header(): + seen: list[httpx.Request] = [] + + def h(req): + seen.append(req) + return httpx.Response(200, json={"id": "s1"}) + + assert make(h).sites.get("a/b") == {"id": "s1"} + r = seen[0] + assert r.method == "GET" and r.url.raw_path == b"/v1/sites/a%2Fb" + assert r.headers["authorization"] == f"Bearer {KEY}" + + +def test_query_string_skips_none_and_lowercases_bools(): + seen = [] + + def h(req): + seen.append(req) + return httpx.Response(200, json={"data": [], "page": {"has_more": False, "next_cursor": None}}) + + make(h).sites.list(limit=5, cursor=None, verified=True) + assert dict(seen[0].url.params) == {"limit": "5", "verified": "true"} + + +@pytest.mark.parametrize( + "status,cls", + [(401, SiteWarmAuthenticationError), (404, SiteWarmNotFoundError), (422, SiteWarmValidationError), + (429, SiteWarmRateLimitError), (500, SiteWarmServerError)], +) +def test_problem_to_typed_error(status, cls): + def h(req): + return httpx.Response( + status, + json={"type": "https://x.test/problems/some-slug", "title": "T", "detail": "D", "code": "some-code"}, + headers={"content-type": "application/problem+json", "Retry-After": "0", "RateLimit-Remaining": "0"}, + ) + + with pytest.raises(cls) as e: + make(h, max_retries=0).sites.get("s1") + assert e.value.status == status and e.value.code == "some-code" and e.value.rate_limit == {"remaining": 0} + assert KEY not in str(e.value) and KEY not in repr(e.value) + + +def test_retries_429_then_succeeds_and_honors_retry_after(): + n = {"c": 0} + + def h(req): + n["c"] += 1 + return httpx.Response(429, headers={"Retry-After": "0"}) if n["c"] < 3 else httpx.Response(200, json={}) + + make(h).sites.get("s1") + assert n["c"] == 3 + + +def test_retry_after_longer_than_cap_is_surfaced(): + n = {"c": 0} + + def h(req): + n["c"] += 1 + return httpx.Response(429, headers={"Retry-After": "120"}) + + with pytest.raises(SiteWarmRateLimitError) as e: + make(h, max_retry_delay=5).sites.get("s1") + assert n["c"] == 1 and e.value.retry_after_seconds == 120 + + +def test_post_without_key_is_not_retried_on_5xx_but_keyed_post_is(monkeypatch): + from sitewarm._generated.operations import OPERATIONS, Operation + + # Every POST of the current spec takes a key, so exercise the no-key path with a synthetic operation. + monkeypatch.setitem(OPERATIONS, "fakePost", Operation("POST", "/fake", "none")) + calls = [] + + def h(req): + calls.append(req) + return httpx.Response(503) + + with pytest.raises(SiteWarmServerError): + make(h, max_retries=2).call("fakePost") + assert len(calls) == 1 and "idempotency-key" not in calls[0].headers + + calls.clear() + with pytest.raises(SiteWarmServerError): + make(h, max_retries=2).warm_runs.create("s1", {"source": {"type": "sitemap"}}) + assert len(calls) == 3 + keys = {c.headers["idempotency-key"] for c in calls} + assert len(keys) == 1 # one key for the call, reused by every retry + + +def test_given_idempotency_key_is_sent(): + seen = [] + + def h(req): + seen.append(req) + return httpx.Response(202, json={"id": "run1"}) + + make(h).warm_runs.create("s1", {"x": 1}, idempotency_key="mine") + assert seen[0].headers["idempotency-key"] == "mine" + + +def test_network_error_is_wrapped_without_secrets(): + def h(req): + raise httpx.ConnectError(f"boom {KEY}") + + with pytest.raises(SiteWarmConnectionError) as e: + make(h, max_retries=1).sites.get("s1") + assert KEY not in str(e.value) + + +def test_paginate_follows_cursor_and_detects_stall(): + pages = { + None: {"data": [1, 2], "page": {"has_more": True, "next_cursor": "c1"}}, + "c1": {"data": [3], "page": {"has_more": False, "next_cursor": None}}, + } + + def h(req): + return httpx.Response(200, json=pages[req.url.params.get("cursor")]) + + assert list(make(h).sites.list_all()) == [1, 2, 3] + + def stuck(req): + return httpx.Response(200, json={"data": [1], "page": {"has_more": True, "next_cursor": "c1"}}) + + with pytest.raises(SiteWarmError, match="did not advance"): + list(make(stuck).sites.list_all()) + + +def test_wait_polls_until_terminal_and_times_out_without_canceling(): + seq = iter(["queued", "running", "completed"]) + + def h(req): + return httpx.Response(200, json={"status": next(seq)}, headers={"Retry-After": "0"}) + + assert make(h).warm_runs.wait("r1", min_poll_interval=0)["status"] == "completed" + + methods = [] + + def never(req): + methods.append(req.method) + return httpx.Response(200, json={"status": "running"}) + + with pytest.raises(SiteWarmWaitTimeoutError): + make(never).warm_runs.wait("r1", timeout=0.05, poll_interval=0.01, min_poll_interval=0.01) + assert set(methods) == {"GET"} + + +def test_pydantic_body_is_serialized(): + from pydantic import BaseModel + + class B(BaseModel): + a: int + b: Optional[int] = None + + seen = [] + + def h(req): + seen.append(req.content) + return httpx.Response(200, json={}) + + make(h).sites.create(B(a=1)) + assert seen[0] == b'{"a": 1}' + + +@pytest.mark.parametrize( + "url", ["http://api.example.test/v1", "https://u:p@api.example.test", "https://x.test/v1?a=1", "api.example.test", ""], +) +def test_base_url_validation(url): + with pytest.raises(SiteWarmError): + SiteWarm(KEY, url) + + +def test_localhost_http_allowed_and_key_validation(): + SiteWarm(KEY, "http://localhost:8080/v1") + for bad in ("", "has space", "tab\t"): + with pytest.raises(SiteWarmError) as e: + SiteWarm(bad, BASE) + assert bad not in str(e.value) or bad == "" + + +def test_repr_hides_key(): + assert KEY not in repr(SiteWarm(KEY, BASE)) + + +def test_unknown_operation_and_missing_path_param(): + c = make(lambda r: httpx.Response(200, json={})) + with pytest.raises(SiteWarmError, match="unknown operation"): + c.call("nope") + with pytest.raises(SiteWarmError, match="missing path parameter"): + c.call("getSite") + + +def test_retry_helpers(): + assert parse_retry_after("7") == 7 + assert parse_retry_after("garbage") is None + assert parse_retry_after("Wed, 21 Oct 2015 07:28:10 GMT", now=1445412480 - 5 + 0) is not None + assert backoff_delay(10, 1.0, 4.0, rand=lambda: 0.0) == 2.0 + assert backoff_delay(10, 1.0, 4.0, rand=lambda: 1.0) == 4.0 + + +async def test_async_client_retries_and_paginates(): + n = {"c": 0} + + def h(req): + n["c"] += 1 + if n["c"] == 1: + return httpx.Response(429, headers={"Retry-After": "0"}) + return httpx.Response(200, json={"data": [1], "page": {"has_more": False, "next_cursor": None}}) + + async with AsyncSiteWarm(KEY, BASE, retry_base_delay=0.0, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(h))) as c: + items = [i async for i in c.paginate("listSites")] + assert items == [1] and n["c"] == 2 diff --git a/tests/test_spec.py b/tests/test_spec.py new file mode 100644 index 0000000..de6bb50 --- /dev/null +++ b/tests/test_spec.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from sitewarm._generated.operations import OPERATIONS + +SPEC = yaml.safe_load((Path(__file__).resolve().parent.parent / "openapi" / "openapi.yaml").read_text()) + + +def test_operation_table_matches_spec(): + expected = { + op["operationId"]: (m.upper(), p) + for p, item in SPEC["paths"].items() + for m, op in item.items() + if m in ("get", "put", "post", "patch", "delete") + } + assert {k: (v.method, v.path) for k, v in OPERATIONS.items()} == expected + assert len(OPERATIONS) == 36 + + +def test_create_warm_run_requires_idempotency_key(): + assert OPERATIONS["createWarmRun"].idempotency == "required" + assert OPERATIONS["getWarmRun"].idempotency == "none" + + +def test_models_import(): + import sitewarm.models as m + + assert hasattr(m, "WarmRunCreate") + + +def test_no_private_extensions_in_spec(): + assert "x-open-question" not in (Path(__file__).resolve().parent.parent / "openapi" / "openapi.yaml").read_text() From df1d10d5352393d9401514ebeb610cbe88638b31 Mon Sep 17 00:00:00 2001 From: Ernesto Vargas Date: Tue, 27 Oct 2026 02:56:21 -0600 Subject: [PATCH 2/3] Harden client: retry rules, body limit, pagination guards, key redaction Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Y4ijWAJZzDChcsSYmgTYcD --- README.md | 5 +- src/sitewarm/__init__.py | 3 +- src/sitewarm/_client.py | 135 +++++++++++++++++++++----- src/sitewarm/_errors.py | 8 ++ tests/test_hardening.py | 199 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 325 insertions(+), 25 deletions(-) create mode 100644 tests/test_hardening.py diff --git a/README.md b/README.md index 5289724..3591878 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,11 @@ A site must be verified before a run is accepted; verification happens outside t ## Behavior -- **Auth**: `Authorization: Bearer `. The key is not in `repr()`, error messages or logs (the SDK never logs). `api_key` must be printable ASCII without whitespace. `base_url` must be https (http only for localhost) and have no credentials; redirects are not followed, so the key never goes to another origin. +- **Auth**: `Authorization: Bearer `. The key is held in a redacting wrapper, so it is not in `repr()`, `str()`, `vars()`, pickles, error messages or logs (the SDK never logs). `api_key` must be printable ASCII without whitespace. `base_url` must be https (http only for localhost) and have no credentials. Redirects are never followed, even with your own `httpx` client (`follow_redirects=False` is set on every request), so the key never goes to another origin; a 3xx is raised as an API error. - **No default `base_url`**: pass the API host you were given. -- **Retries**: up to `max_retries` (3) on `429`, `5xx` (not `501`) and network errors, with exponential backoff and jitter capped at `max_retry_delay` (30 s). `Retry-After` is honored; if it exceeds the cap the error is raised instead of sleeping. `429` is always retried; `5xx` and network errors only when repeating is safe (not a POST, or a POST with an `Idempotency-Key`). +- **Retries**: up to `max_retries` (3; a negative value keeps the default) on `429`, `5xx` (not `501`) and network errors, with exponential backoff and jitter capped at `max_retry_delay` (30 s). `Retry-After` is honored; if it exceeds the cap the error is raised instead of sleeping. `429` is always retried; `5xx`, network errors and a response body that breaks off only when repeating is safe (`GET`, `HEAD`, `PUT`, `OPTIONS`, or a POST with an `Idempotency-Key`; never `DELETE`/`PATCH`). `timeout <= 0` keeps the default (30 s). - **Idempotency**: every POST that accepts `Idempotency-Key` sends one (a random UUID unless you pass `idempotency_key`), generated once per call and reused by all its retries, so a retried `warm_runs.create` never starts two runs. +- **Limits**: response bodies over 32 MiB raise `SiteWarmResponseTooLargeError` (not retried); `pages`/`paginate` raise on a repeated cursor or after 10,000 pages instead of stopping silently; a `limit` query value outside 1..100 raises before the request (0 or `None` = server default). - **Errors**: `SiteWarmApiError` (`status`, `problem` as RFC 9457, `code`, `retry_after_seconds`, `rate_limit`) with subclasses for 401, 403, 404, 409, 400/422, 429 and 5xx; `SiteWarmConnectionError` and `SiteWarmTimeoutError` for transport; `SiteWarmWaitTimeoutError` from `warm_runs.wait`, which does not cancel the run. ## Development diff --git a/src/sitewarm/__init__.py b/src/sitewarm/__init__.py index 9b520e6..9246fc1 100644 --- a/src/sitewarm/__init__.py +++ b/src/sitewarm/__init__.py @@ -12,6 +12,7 @@ SiteWarmNotFoundError, SiteWarmPermissionError, SiteWarmRateLimitError, + SiteWarmResponseTooLargeError, SiteWarmServerError, SiteWarmTimeoutError, SiteWarmValidationError, @@ -23,6 +24,6 @@ __all__ = [ "ApiResponse", "AsyncSiteWarm", "SiteWarm", "SiteWarmApiError", "SiteWarmAuthenticationError", "SiteWarmConflictError", "SiteWarmConnectionError", "SiteWarmError", "SiteWarmNotFoundError", - "SiteWarmPermissionError", "SiteWarmRateLimitError", "SiteWarmServerError", "SiteWarmTimeoutError", + "SiteWarmPermissionError", "SiteWarmRateLimitError", "SiteWarmResponseTooLargeError", "SiteWarmServerError", "SiteWarmTimeoutError", "SiteWarmValidationError", "SiteWarmWaitTimeoutError", ] diff --git a/src/sitewarm/_client.py b/src/sitewarm/_client.py index cbef1ec..f94efde 100644 --- a/src/sitewarm/_client.py +++ b/src/sitewarm/_client.py @@ -5,7 +5,7 @@ import re import time import uuid -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, AsyncIterator, Iterator, Mapping, Optional, Union from urllib.parse import quote, urlencode, urlsplit @@ -14,6 +14,7 @@ from ._errors import ( SiteWarmConnectionError, SiteWarmError, + SiteWarmResponseTooLargeError, SiteWarmTimeoutError, SiteWarmWaitTimeoutError, api_error_for, @@ -23,6 +24,35 @@ _LOOPBACK = {"localhost", "127.0.0.1", "::1"} _TERMINAL = {"completed", "canceled", "failed"} +_MAX_BODY = 32 << 20 # largest response body that is read (32 MiB) +_REPEATABLE = {"GET", "HEAD", "PUT", "OPTIONS"} +_MAX_PAGES = 10_000 + + +class _Secret: + """Holds the API key so that repr/str/vars/pickle of the client never reveal it.""" + + __slots__ = ("_v",) + + def __init__(self, v: str) -> None: + self._v = v + + def reveal(self) -> str: + return self._v + + def __repr__(self) -> str: + return "" + + __str__ = __repr__ + + def __reduce__(self) -> Any: + raise TypeError("the API key cannot be pickled") + + def __copy__(self) -> "_Secret": + return self + + def __deepcopy__(self, memo: Any) -> "_Secret": + return self @dataclass(frozen=True) @@ -36,10 +66,10 @@ class ApiResponse: class _Prepared: method: str url: str - headers: dict[str, str] - content: Optional[bytes] - # 429 means "not processed". 5xx and network errors may have been, so they are only retried when - # repeating is safe: not a POST, or a POST carrying an Idempotency-Key. + headers: dict[str, str] = field(repr=False) # carries Authorization + content: Optional[bytes] = field(repr=False) + # 429 means "not processed". 5xx, network errors and broken bodies may have been, so they are only retried + # when repeating is safe: GET, HEAD, PUT, OPTIONS, or a POST carrying an Idempotency-Key. Never DELETE/PATCH. retry_ambiguous: bool @@ -58,12 +88,13 @@ def __init__( # Messages below never echo the key. if not isinstance(api_key, str) or not re.fullmatch(r"[\x21-\x7e]+", api_key): raise SiteWarmError("api_key must be a non-empty string without whitespace or control characters") - self._api_key = api_key + self._api_key = _Secret(api_key) self._base_url = _normalize_base_url(base_url) - self._max_retries = max_retries + # A negative max_retries or a timeout <= 0 keeps the default. + self._max_retries = int(max_retries) if _is_num(max_retries) and max_retries >= 0 else 3 self._retry_base = retry_base_delay self._retry_max = max_retry_delay - self._timeout = timeout + self._timeout = float(timeout) if _is_num(timeout) and timeout > 0 else 30.0 self._user_agent = user_agent or "sitewarm-python" def __repr__(self) -> str: # the key is never part of repr or str @@ -81,9 +112,9 @@ def _prepare( op = OPERATIONS[operation_id] except KeyError: raise SiteWarmError(f"unknown operation {operation_id!r}") from None - url = self._base_url + _expand_path(op.path, path) + _query_string(query) + url = self._base_url + _expand_path(op.path, path) + _query_string(_checked_query(query)) headers = { - "Authorization": f"Bearer {self._api_key}", + "Authorization": f"Bearer {self._api_key.reveal()}", "Accept": "application/json, application/problem+json", "User-Agent": self._user_agent, } @@ -97,7 +128,7 @@ def _prepare( if sends_key: # One key per call, reused by every retry, so a retried POST never starts a second run. headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4()) - return _Prepared(op.method, url, headers, content, op.method != "POST" or sends_key) + return _Prepared(op.method, url, headers, content, op.method in _REPEATABLE or sends_key) def _decide(self, resp: httpx.Response, attempt: int, p: _Prepared) -> Union[ApiResponse, float]: """Returns the response to hand back, or the seconds to wait before retrying. Raises on errors.""" @@ -113,8 +144,9 @@ def _decide(self, resp: httpx.Response, attempt: int, p: _Prepared) -> Union[Api return float(wait) raise api_error_for(status, _parse_problem(resp), retry_after, _rate_limit(resp.headers)) - def _transport_error(self, err: Exception, attempt: int, p: _Prepared) -> float: - if attempt < self._max_retries and p.retry_ambiguous: + def _transport_error(self, err: Exception, attempt: int, p: _Prepared, status: Optional[int] = None) -> float: + # status is set when the failure happened while reading the body: after a 429 nothing was processed. + if attempt < self._max_retries and (p.retry_ambiguous or status == 429): return backoff_delay(attempt, self._retry_base, self._retry_max) if isinstance(err, httpx.TimeoutException): raise SiteWarmTimeoutError(f"request timed out after {self._timeout} s") from err @@ -157,10 +189,15 @@ def call_raw(self, operation_id: str, *, path: Optional[Mapping[str, Any]] = Non p = self._prepare(operation_id, path, query, body, idempotency_key) attempt = 0 while True: + status: list[int] = [] try: - resp = self._http.request(p.method, p.url, headers=p.headers, content=p.content, timeout=self._timeout) + # follow_redirects=False even for a caller-supplied client, so Authorization never leaves the origin. + with self._http.stream(p.method, p.url, headers=p.headers, content=p.content, timeout=self._timeout, + follow_redirects=False) as raw: + status.append(raw.status_code) + resp = _bounded(raw) except httpx.HTTPError as err: - time.sleep(self._transport_error(err, attempt, p)) + time.sleep(self._transport_error(err, attempt, p, status[0] if status else None)) attempt += 1 continue out = self._decide(resp, attempt, p) @@ -169,10 +206,14 @@ def call_raw(self, operation_id: str, *, path: Optional[Mapping[str, Any]] = Non time.sleep(out) attempt += 1 - def pages(self, operation_id: str, *, max_pages: int = 10_000, **args: Any) -> Iterator[Any]: - """Yields the pages (``{"data": [...], "page": {...}}``) of a list operation.""" + def pages(self, operation_id: str, *, max_pages: int = _MAX_PAGES, **args: Any) -> Iterator[Any]: + """Yields the pages (``{"data": [...], "page": {...}}``) of a list operation. + + Raises if a cursor repeats or if ``max_pages`` (10,000) pages did not reach the end. + """ query = dict(args.pop("query", None) or {}) cursor = query.get("cursor") + seen = {cursor} if cursor else set() for _ in range(max_pages): if cursor: query["cursor"] = cursor @@ -181,9 +222,11 @@ def pages(self, operation_id: str, *, max_pages: int = 10_000, **args: Any) -> I nxt = page["page"].get("next_cursor") if not page["page"].get("has_more") or not nxt: return - if nxt == cursor: + if nxt in seen: raise SiteWarmError("pagination cursor did not advance") + seen.add(nxt) cursor = nxt + raise SiteWarmError(f"pagination stopped after {max_pages} pages without reaching the end") def paginate(self, operation_id: str, **args: Any) -> Iterator[Any]: """Yields every item of a list operation across pages.""" @@ -218,10 +261,14 @@ async def call_raw(self, operation_id: str, *, path: Optional[Mapping[str, Any]] p = self._prepare(operation_id, path, query, body, idempotency_key) attempt = 0 while True: + status: list[int] = [] try: - resp = await self._http.request(p.method, p.url, headers=p.headers, content=p.content, timeout=self._timeout) + async with self._http.stream(p.method, p.url, headers=p.headers, content=p.content, + timeout=self._timeout, follow_redirects=False) as raw: + status.append(raw.status_code) + resp = await _abounded(raw) except httpx.HTTPError as err: - await asyncio.sleep(self._transport_error(err, attempt, p)) + await asyncio.sleep(self._transport_error(err, attempt, p, status[0] if status else None)) attempt += 1 continue out = self._decide(resp, attempt, p) @@ -230,9 +277,10 @@ async def call_raw(self, operation_id: str, *, path: Optional[Mapping[str, Any]] await asyncio.sleep(out) attempt += 1 - async def pages(self, operation_id: str, *, max_pages: int = 10_000, **args: Any) -> AsyncIterator[Any]: + async def pages(self, operation_id: str, *, max_pages: int = _MAX_PAGES, **args: Any) -> AsyncIterator[Any]: query = dict(args.pop("query", None) or {}) cursor = query.get("cursor") + seen = {cursor} if cursor else set() for _ in range(max_pages): if cursor: query["cursor"] = cursor @@ -241,9 +289,11 @@ async def pages(self, operation_id: str, *, max_pages: int = 10_000, **args: Any nxt = page["page"].get("next_cursor") if not page["page"].get("has_more") or not nxt: return - if nxt == cursor: + if nxt in seen: raise SiteWarmError("pagination cursor did not advance") + seen.add(nxt) cursor = nxt + raise SiteWarmError(f"pagination stopped after {max_pages} pages without reaching the end") async def paginate(self, operation_id: str, **args: Any) -> AsyncIterator[Any]: async for page in self.pages(operation_id, **args): @@ -381,6 +431,47 @@ def _query_string(query: Optional[Mapping[str, Any]]) -> str: return "?" + urlencode(pairs) if pairs else "" +def _is_num(v: Any) -> bool: + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def _checked_query(query: Optional[Mapping[str, Any]]) -> Optional[Mapping[str, Any]]: + """``limit`` must be 1..100 (0 or None = server default, not sent); invalid values never reach the server.""" + if not query or "limit" not in query: + return query + limit = query["limit"] + if limit is None or (isinstance(limit, int) and not isinstance(limit, bool) and limit == 0): + return {k: v for k, v in query.items() if k != "limit"} + if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 100: + raise SiteWarmError("limit must be between 1 and 100") + return query + + +def _rebuild(raw: httpx.Response, data: bytes) -> httpx.Response: + # iter_bytes() already decoded the body, so the copy must not claim an encoding or a length. + headers = [(k, v) for k, v in raw.headers.multi_items() if k.lower() not in ("content-encoding", "content-length")] + return httpx.Response(raw.status_code, headers=headers, content=data, request=raw.request) + + +def _bounded(raw: httpx.Response) -> httpx.Response: + """Reads at most _MAX_BODY + 1 bytes; a larger body raises SiteWarmResponseTooLargeError (not retried).""" + buf = bytearray() + for chunk in raw.iter_bytes(): + buf += chunk + if len(buf) > _MAX_BODY: + raise SiteWarmResponseTooLargeError(_MAX_BODY) + return _rebuild(raw, bytes(buf)) + + +async def _abounded(raw: httpx.Response) -> httpx.Response: + buf = bytearray() + async for chunk in raw.aiter_bytes(): + buf += chunk + if len(buf) > _MAX_BODY: + raise SiteWarmResponseTooLargeError(_MAX_BODY) + return _rebuild(raw, bytes(buf)) + + def _parse_body(resp: httpx.Response) -> Any: if resp.status_code == 204 or not resp.content: return None diff --git a/src/sitewarm/_errors.py b/src/sitewarm/_errors.py index 77a95d0..47bf02a 100644 --- a/src/sitewarm/_errors.py +++ b/src/sitewarm/_errors.py @@ -16,6 +16,14 @@ class SiteWarmTimeoutError(SiteWarmConnectionError): """The per-request timeout elapsed.""" +class SiteWarmResponseTooLargeError(SiteWarmError): + """The response body was larger than the SDK reads (32 MiB). Not retried.""" + + def __init__(self, limit: int) -> None: + super().__init__(f"response body is larger than {limit} bytes") + self.limit = limit + + class SiteWarmApiError(SiteWarmError): """The API answered with a non-2xx status. diff --git a/tests/test_hardening.py b/tests/test_hardening.py new file mode 100644 index 0000000..16f1298 --- /dev/null +++ b/tests/test_hardening.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import copy +import pickle + +import httpx +import pytest + +from sitewarm import ( + AsyncSiteWarm, + SiteWarm, + SiteWarmConnectionError, + SiteWarmError, + SiteWarmResponseTooLargeError, + SiteWarmServerError, +) + +KEY = "swk_secret_key" +BASE = "https://api.example.test/v1" + + +def make(handler, **kw) -> SiteWarm: + kw.setdefault("retry_base_delay", 0.0) + return SiteWarm(KEY, BASE, http_client=httpx.Client(transport=httpx.MockTransport(handler)), **kw) + + +class _Broken(httpx.SyncByteStream): + def __iter__(self): + yield b'{"id"' + raise httpx.ReadError("connection reset") + + +def test_non_positive_timeout_and_negative_retries_keep_defaults(): + for t in (0, -1, 0.0): + assert SiteWarm(KEY, BASE, timeout=t)._timeout == 30.0 + assert SiteWarm(KEY, BASE, timeout=2)._timeout == 2.0 + assert SiteWarm(KEY, BASE, max_retries=-1)._max_retries == 3 + assert SiteWarm(KEY, BASE, max_retries=0)._max_retries == 0 + + +def test_body_read_error_is_retried_when_repeatable(): + n = {"c": 0} + + def h(req): + n["c"] += 1 + if n["c"] == 1: + return httpx.Response(200, stream=_Broken()) + return httpx.Response(200, json={"id": "s1"}) + + assert make(h).sites.get("s1") == {"id": "s1"} and n["c"] == 2 + + +def test_body_read_error_on_unkeyed_post_is_not_retried(monkeypatch): + from sitewarm._generated.operations import OPERATIONS, Operation + + monkeypatch.setitem(OPERATIONS, "fakePost", Operation("POST", "/fake", "none")) + n = {"c": 0} + + def h(req): + n["c"] += 1 + return httpx.Response(200, stream=_Broken()) + + with pytest.raises(SiteWarmConnectionError): + make(h, max_retries=2).call("fakePost") + assert n["c"] == 1 + + +def test_oversized_response_is_typed_and_not_retried(): + n = {"c": 0} + + def h(req): + n["c"] += 1 + return httpx.Response(200, content=b"x" * ((32 << 20) + 1)) + + with pytest.raises(SiteWarmResponseTooLargeError): + make(h).sites.get("s1") + assert n["c"] == 1 + + +@pytest.mark.parametrize("method,want", [("GET", 3), ("HEAD", 3), ("PUT", 3), ("OPTIONS", 3), ("DELETE", 1), ("PATCH", 1)]) +def test_only_repeatable_methods_retry_5xx(monkeypatch, method, want): + from sitewarm._generated.operations import OPERATIONS, Operation + + monkeypatch.setitem(OPERATIONS, "fakeOp", Operation(method, "/fake", "none")) + n = {"c": 0} + + def h(req): + n["c"] += 1 + return httpx.Response(503) + + with pytest.raises(SiteWarmServerError): + make(h, max_retries=2).call("fakeOp") + assert n["c"] == want + + +def test_429_is_retried_for_every_method(monkeypatch): + from sitewarm._generated.operations import OPERATIONS, Operation + + monkeypatch.setitem(OPERATIONS, "fakeOp", Operation("DELETE", "/fake", "none")) + n = {"c": 0} + + def h(req): + n["c"] += 1 + return httpx.Response(429, headers={"Retry-After": "0"}) if n["c"] < 3 else httpx.Response(200, json={}) + + make(h).call("fakeOp") + assert n["c"] == 3 + + +def test_pagination_cursor_cycle_and_page_cap(): + def cyc(req): + nxt = "a" if req.url.params.get("cursor") == "b" else "b" + return httpx.Response(200, json={"data": [1], "page": {"has_more": True, "next_cursor": nxt}}) + + with pytest.raises(SiteWarmError, match="did not advance"): + list(make(cyc).sites.list_all()) + + n = {"c": 0} + + def endless(req): + n["c"] += 1 + return httpx.Response(200, json={"data": [], "page": {"has_more": True, "next_cursor": f"c{n['c']}"}}) + + with pytest.raises(SiteWarmError, match="stopped after 5 pages"): + list(make(endless).paginate("listSites", max_pages=5)) + assert n["c"] == 5 + + +def test_list_limit_is_validated_before_sending(): + seen = [] + + def h(req): + seen.append(req) + return httpx.Response(200, json={"data": [], "page": {"has_more": False, "next_cursor": None}}) + + c = make(h) + for bad in (-1, 101, "5", True, 1.5): + with pytest.raises(SiteWarmError, match="limit"): + c.sites.list(limit=bad) + assert seen == [] + for ok in (1, 100): + c.sites.list(limit=ok) + c.sites.list(limit=0) + c.sites.list(limit=None) + assert [r.url.params.get("limit") for r in seen] == ["1", "100", None, None] + + +def test_key_not_in_repr_vars_or_pickle(): + for c in (SiteWarm(KEY, BASE), AsyncSiteWarm(KEY, BASE)): + for d in (repr(c), str(c), repr(vars(c)), repr(c.__dict__), repr(copy.deepcopy(c._api_key))): + assert KEY not in d + with pytest.raises(TypeError): + pickle.dumps(c._api_key) + assert KEY not in repr(SiteWarm(KEY, BASE)._prepare("getSite", {"siteId": "x"}, None, None, None)) + + +def test_supplied_client_that_follows_redirects_never_forwards_authorization(): + hosts = [] + + def h(req): + hosts.append(req.url.host) + if req.url.host == "api.example.test": + return httpx.Response(302, headers={"location": "https://other.test/x"}) + return httpx.Response(200, json={}) + + mine = httpx.Client(transport=httpx.MockTransport(h), follow_redirects=True) + with pytest.raises(SiteWarmError) as e: + SiteWarm(KEY, BASE, http_client=mine, max_retries=0).sites.get("s1") + assert getattr(e.value, "status", None) == 302 and hosts == ["api.example.test"] + + +async def test_async_body_error_retry_oversize_and_redirect(): + class ABroken(httpx.AsyncByteStream): + async def __aiter__(self): + yield b'{"id"' + raise httpx.ReadError("reset") + + n = {"c": 0} + hosts = [] + + def h(req): + hosts.append(req.url.host) + if req.url.path.endswith("/redir"): + return httpx.Response(302, headers={"location": "https://other.test/x"}) + if req.url.path.endswith("/big"): + return httpx.Response(200, content=b"x" * ((32 << 20) + 1)) + n["c"] += 1 + if n["c"] == 1: + return httpx.Response(200, stream=ABroken()) + return httpx.Response(200, json={"id": "ok"}) + + http = httpx.AsyncClient(transport=httpx.MockTransport(h), follow_redirects=True) + async with AsyncSiteWarm(KEY, BASE, retry_base_delay=0.0, http_client=http) as c: + assert await c.call("getSite", path={"siteId": "a"}) == {"id": "ok"} and n["c"] == 2 + with pytest.raises(SiteWarmResponseTooLargeError): + await c.call("getSite", path={"siteId": "big"}) + with pytest.raises(SiteWarmError): + await c.call("getSite", path={"siteId": "redir"}) + assert "other.test" not in hosts From 095ffe290c7a62425b35459a6cc779b1f008f220 Mon Sep 17 00:00:00 2001 From: Ernesto Vargas Date: Tue, 27 Oct 2026 03:13:55 -0600 Subject: [PATCH 3/3] Port review findings: 409 conflict retry, network error classes, Retry-After floor, decode error, User-Agent - Retry a 409 conflict on keyed POSTs only; never idempotency-key-reused. - Do not retry certificate, unknown-host or malformed-request errors. - Retry-After floor at the retry base, with jitter; longer than the cap is surfaced. - Typed decode error (with status) for a 2xx body that is not JSON. - User-Agent carries version and URL; an empty custom one keeps the default. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Y4ijWAJZzDChcsSYmgTYcD --- README.md | 3 +- src/sitewarm/__init__.py | 6 +- src/sitewarm/_client.py | 55 ++++++++++++++--- src/sitewarm/_errors.py | 8 +++ src/sitewarm/_version.py | 1 + tests/test_lows.py | 126 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 src/sitewarm/_version.py create mode 100644 tests/test_lows.py diff --git a/README.md b/README.md index 3591878..969fa9c 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,10 @@ A site must be verified before a run is accepted; verification happens outside t - **Auth**: `Authorization: Bearer `. The key is held in a redacting wrapper, so it is not in `repr()`, `str()`, `vars()`, pickles, error messages or logs (the SDK never logs). `api_key` must be printable ASCII without whitespace. `base_url` must be https (http only for localhost) and have no credentials. Redirects are never followed, even with your own `httpx` client (`follow_redirects=False` is set on every request), so the key never goes to another origin; a 3xx is raised as an API error. - **No default `base_url`**: pass the API host you were given. - **Retries**: up to `max_retries` (3; a negative value keeps the default) on `429`, `5xx` (not `501`) and network errors, with exponential backoff and jitter capped at `max_retry_delay` (30 s). `Retry-After` is honored; if it exceeds the cap the error is raised instead of sleeping. `429` is always retried; `5xx`, network errors and a response body that breaks off only when repeating is safe (`GET`, `HEAD`, `PUT`, `OPTIONS`, or a POST with an `Idempotency-Key`; never `DELETE`/`PATCH`). `timeout <= 0` keeps the default (30 s). +- **More retry rules**: a `409` `conflict` on a keyed POST is retried (the first attempt may still be running); `idempotency-key-reused` never. Certificate/TLS verification failures, unknown hosts and malformed requests are not retried. `Retry-After` has a floor of the retry base delay and gets up to +25% jitter; a `Retry-After` above the cap is raised, not slept. - **Idempotency**: every POST that accepts `Idempotency-Key` sends one (a random UUID unless you pass `idempotency_key`), generated once per call and reused by all its retries, so a retried `warm_runs.create` never starts two runs. - **Limits**: response bodies over 32 MiB raise `SiteWarmResponseTooLargeError` (not retried); `pages`/`paginate` raise on a repeated cursor or after 10,000 pages instead of stopping silently; a `limit` query value outside 1..100 raises before the request (0 or `None` = server default). -- **Errors**: `SiteWarmApiError` (`status`, `problem` as RFC 9457, `code`, `retry_after_seconds`, `rate_limit`) with subclasses for 401, 403, 404, 409, 400/422, 429 and 5xx; `SiteWarmConnectionError` and `SiteWarmTimeoutError` for transport; `SiteWarmWaitTimeoutError` from `warm_runs.wait`, which does not cancel the run. +- **Errors**: `SiteWarmApiError` (`status`, `problem` as RFC 9457, `code`, `retry_after_seconds`, `rate_limit`) with subclasses for 401, 403, 404, 409, 400/422, 429 and 5xx; `SiteWarmConnectionError` and `SiteWarmTimeoutError` for transport; `SiteWarmWaitTimeoutError` from `warm_runs.wait`, which does not cancel the run; `SiteWarmDecodeError` (`status`) for a 2xx body that is not JSON (the body is never included). The `User-Agent` is `sitewarm-python/ (+https://github.com/sitewarm/sdk-python)`; an empty `user_agent` keeps it. ## Development diff --git a/src/sitewarm/__init__.py b/src/sitewarm/__init__.py index 9246fc1..a929222 100644 --- a/src/sitewarm/__init__.py +++ b/src/sitewarm/__init__.py @@ -2,12 +2,14 @@ Models for typing and validation live in ``sitewarm.models`` (generated from the OpenAPI spec). """ +from ._version import __version__ from ._client import ApiResponse, AsyncSiteWarm, SiteWarm from ._errors import ( SiteWarmApiError, SiteWarmAuthenticationError, SiteWarmConflictError, SiteWarmConnectionError, + SiteWarmDecodeError, SiteWarmError, SiteWarmNotFoundError, SiteWarmPermissionError, @@ -19,11 +21,11 @@ SiteWarmWaitTimeoutError, ) -__version__ = "0.1.0" __all__ = [ + "__version__", "ApiResponse", "AsyncSiteWarm", "SiteWarm", "SiteWarmApiError", "SiteWarmAuthenticationError", - "SiteWarmConflictError", "SiteWarmConnectionError", "SiteWarmError", "SiteWarmNotFoundError", + "SiteWarmConflictError", "SiteWarmConnectionError", "SiteWarmDecodeError", "SiteWarmError", "SiteWarmNotFoundError", "SiteWarmPermissionError", "SiteWarmRateLimitError", "SiteWarmResponseTooLargeError", "SiteWarmServerError", "SiteWarmTimeoutError", "SiteWarmValidationError", "SiteWarmWaitTimeoutError", ] diff --git a/src/sitewarm/_client.py b/src/sitewarm/_client.py index f94efde..4c36498 100644 --- a/src/sitewarm/_client.py +++ b/src/sitewarm/_client.py @@ -2,7 +2,10 @@ import asyncio import json +import random import re +import socket +import ssl import time import uuid from dataclasses import dataclass, field @@ -13,6 +16,7 @@ from ._errors import ( SiteWarmConnectionError, + SiteWarmDecodeError, SiteWarmError, SiteWarmResponseTooLargeError, SiteWarmTimeoutError, @@ -21,12 +25,14 @@ ) from ._generated.operations import OPERATIONS from ._retry import backoff_delay, parse_retry_after +from ._version import __version__ _LOOPBACK = {"localhost", "127.0.0.1", "::1"} _TERMINAL = {"completed", "canceled", "failed"} _MAX_BODY = 32 << 20 # largest response body that is read (32 MiB) _REPEATABLE = {"GET", "HEAD", "PUT", "OPTIONS"} _MAX_PAGES = 10_000 +_DEFAULT_USER_AGENT = f"sitewarm-python/{__version__} (+https://github.com/sitewarm/sdk-python)" class _Secret: @@ -71,6 +77,7 @@ class _Prepared: # 429 means "not processed". 5xx, network errors and broken bodies may have been, so they are only retried # when repeating is safe: GET, HEAD, PUT, OPTIONS, or a POST carrying an Idempotency-Key. Never DELETE/PATCH. retry_ambiguous: bool + sends_key: bool = False class _Core: @@ -95,7 +102,7 @@ def __init__( self._retry_base = retry_base_delay self._retry_max = max_retry_delay self._timeout = float(timeout) if _is_num(timeout) and timeout > 0 else 30.0 - self._user_agent = user_agent or "sitewarm-python" + self._user_agent = user_agent or _DEFAULT_USER_AGENT def __repr__(self) -> str: # the key is never part of repr or str return f"{type(self).__name__}(base_url={self._base_url!r})" @@ -128,7 +135,7 @@ def _prepare( if sends_key: # One key per call, reused by every retry, so a retried POST never starts a second run. headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4()) - return _Prepared(op.method, url, headers, content, op.method in _REPEATABLE or sends_key) + return _Prepared(op.method, url, headers, content, op.method in _REPEATABLE or sends_key, sends_key) def _decide(self, resp: httpx.Response, attempt: int, p: _Prepared) -> Union[ApiResponse, float]: """Returns the response to hand back, or the seconds to wait before retrying. Raises on errors.""" @@ -136,23 +143,53 @@ def _decide(self, resp: httpx.Response, attempt: int, p: _Prepared) -> Union[Api return ApiResponse(_parse_body(resp), resp.status_code, resp.headers) retry_after = parse_retry_after(resp.headers.get("Retry-After")) status = resp.status_code + err = api_error_for(status, _parse_problem(resp), retry_after, _rate_limit(resp.headers)) retryable = status == 429 or (status >= 500 and status != 501 and p.retry_ambiguous) + # A 409 "conflict" on a keyed POST is the original request still running (a timeout retry racing it). + # "idempotency-key-reused" is a caller bug and is never retried. + if status == 409 and p.sends_key and err.code == "conflict": + retryable = True if retryable and attempt < self._max_retries: - # A server-provided wait longer than we are willing to sleep is surfaced instead of ignored. - wait = retry_after if retry_after is not None else backoff_delay(attempt, self._retry_base, self._retry_max) - if wait <= self._retry_max: - return float(wait) - raise api_error_for(status, _parse_problem(resp), retry_after, _rate_limit(resp.headers)) + if retry_after is not None: + # Never a hot retry on "Retry-After: 0" or a date in the past; jitter spreads clients out. + # A server-provided wait longer than the cap is surfaced instead of slept. + wait = max(float(retry_after), self._retry_base) + if wait <= self._retry_max: + return wait * (1 + random.random() / 4) + else: + wait = backoff_delay(attempt, self._retry_base, self._retry_max) + if wait <= self._retry_max: + return float(wait) + raise err def _transport_error(self, err: Exception, attempt: int, p: _Prepared, status: Optional[int] = None) -> float: # status is set when the failure happened while reading the body: after a 429 nothing was processed. - if attempt < self._max_retries and (p.retry_ambiguous or status == 429): + if attempt < self._max_retries and (p.retry_ambiguous or status == 429) and _transient(err): return backoff_delay(attempt, self._retry_base, self._retry_max) if isinstance(err, httpx.TimeoutException): raise SiteWarmTimeoutError(f"request timed out after {self._timeout} s") from err raise SiteWarmConnectionError(f"request failed: {type(err).__name__}") from err +def _transient(err: BaseException) -> bool: + """False for failures that repeating cannot fix: certificate/TLS verification, unknown host, malformed request.""" + if isinstance(err, (httpx.UnsupportedProtocol, httpx.LocalProtocolError)): + return False + seen: set[int] = set() + cur: Optional[BaseException] = err + while cur is not None and id(cur) not in seen: + seen.add(id(cur)) + if isinstance(cur, ssl.SSLCertVerificationError): + return False + if isinstance(cur, socket.gaierror) and cur.errno in _DNS_NOT_FOUND: + return False + cur = cur.__cause__ or cur.__context__ + return True + + +_DNS_NOT_FOUND = {getattr(socket, n) for n in ("EAI_NONAME", "EAI_NODATA") if hasattr(socket, n)} + + class SiteWarm(_Core): """Synchronous client. ``call()`` reaches every operation of the spec by its ``operationId``. @@ -478,7 +515,7 @@ def _parse_body(resp: httpx.Response) -> Any: try: return resp.json() except ValueError: - raise SiteWarmError(f"invalid JSON in {resp.status_code} response") from None + raise SiteWarmDecodeError(resp.status_code) from None def _parse_problem(resp: httpx.Response) -> dict[str, Any]: diff --git a/src/sitewarm/_errors.py b/src/sitewarm/_errors.py index 47bf02a..b789ca4 100644 --- a/src/sitewarm/_errors.py +++ b/src/sitewarm/_errors.py @@ -24,6 +24,14 @@ def __init__(self, limit: int) -> None: self.limit = limit +class SiteWarmDecodeError(SiteWarmError): + """A 2xx response body was not valid JSON (for example an HTML page from a proxy). Never includes the body.""" + + def __init__(self, status: int) -> None: + super().__init__(f"invalid JSON in {status} response") + self.status = status + + class SiteWarmApiError(SiteWarmError): """The API answered with a non-2xx status. diff --git a/src/sitewarm/_version.py b/src/sitewarm/_version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/sitewarm/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/tests/test_lows.py b/tests/test_lows.py new file mode 100644 index 0000000..77a51fb --- /dev/null +++ b/tests/test_lows.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import socket +import ssl + +import httpx +import pytest + +import sitewarm +from sitewarm import SiteWarm, SiteWarmApiError, SiteWarmConnectionError, SiteWarmDecodeError, SiteWarmError +from sitewarm import _client + +KEY = "swk_secret_key" +BASE = "https://api.example.test/v1" +SITE = {"hostname": "example.com"} + + +def make(handler, **kw) -> SiteWarm: + kw.setdefault("retry_base_delay", 0.0) + return SiteWarm(KEY, BASE, http_client=httpx.Client(transport=httpx.MockTransport(handler)), **kw) + + +@pytest.mark.parametrize("code,want", [("conflict", 3), ("idempotency-key-reused", 1)]) +def test_409_conflict_on_keyed_post_is_retried_but_key_reused_is_not(code, want): + n = {"c": 0} + + def handler(req): + n["c"] += 1 + return httpx.Response(409, json={"type": f"https://x.test/problems/{code}", "title": "c", "status": 409}) + + with pytest.raises(SiteWarmApiError) as ei: + make(handler, max_retries=2).sites.create(SITE) + assert ei.value.code == code and n["c"] == want + + +def test_409_conflict_on_unkeyed_operation_is_not_retried(): + n = {"c": 0} + + def handler(req): + n["c"] += 1 + return httpx.Response(409, json={"type": "https://x.test/problems/conflict", "title": "c", "status": 409}) + + with pytest.raises(SiteWarmApiError): + make(handler, max_retries=2).sites.delete("s1") + assert n["c"] == 1 + + +def _chain(outer: Exception, inner: BaseException) -> Exception: + outer.__cause__ = inner + return outer + + +@pytest.mark.parametrize("make_exc,retried", [ + (lambda: _chain(httpx.ConnectError("x"), ssl.SSLCertVerificationError("bad cert")), False), + (lambda: _chain(httpx.ConnectError("x"), socket.gaierror(socket.EAI_NONAME, "not found")), False), + (lambda: httpx.LocalProtocolError("Illegal header value"), False), + (lambda: httpx.UnsupportedProtocol("bad scheme"), False), + (lambda: _chain(httpx.ConnectError("x"), socket.gaierror(socket.EAI_AGAIN, "try again")), True), + (lambda: httpx.ConnectError("refused"), True), +]) +def test_network_error_classification(make_exc, retried): + n = {"c": 0} + + def handler(req): + n["c"] += 1 + raise make_exc() + + with pytest.raises(SiteWarmConnectionError): + make(handler, max_retries=2).sites.get("s1") + assert n["c"] == (3 if retried else 1) + + +def test_retry_after_zero_waits_at_least_the_base_with_jitter(monkeypatch): + slept: list[float] = [] + monkeypatch.setattr(_client.time, "sleep", slept.append) + n = {"c": 0} + + def handler(req): + n["c"] += 1 + return httpx.Response(429, headers={"Retry-After": "0"}) if n["c"] == 1 else httpx.Response(200, json={}) + + make(handler, retry_base_delay=0.2, max_retry_delay=5.0).sites.get("s1") + assert len(slept) == 1 and 0.2 <= slept[0] <= 0.25 + + +def test_retry_after_past_http_date_is_floored(monkeypatch): + slept: list[float] = [] + monkeypatch.setattr(_client.time, "sleep", slept.append) + n = {"c": 0} + + def handler(req): + n["c"] += 1 + h = {"Retry-After": "Mon, 01 Jan 2001 00:00:00 GMT"} + return httpx.Response(429, headers=h) if n["c"] == 1 else httpx.Response(200, json={}) + + make(handler, retry_base_delay=0.2, max_retry_delay=5.0).sites.get("s1") + assert 0.2 <= slept[0] <= 0.25 + + +def test_retry_after_above_cap_is_surfaced_not_slept(monkeypatch): + monkeypatch.setattr(_client.time, "sleep", lambda s: pytest.fail("slept")) + with pytest.raises(SiteWarmApiError) as ei: + make(lambda r: httpx.Response(429, headers={"Retry-After": "60"}), max_retry_delay=5.0).sites.get("s1") + assert ei.value.retry_after_seconds == 60 + + +def test_non_json_2xx_is_a_typed_decode_error_without_body(): + c = make(lambda r: httpx.Response(200, content=b"secret-proxy-page")) + with pytest.raises(SiteWarmDecodeError) as ei: + c.sites.get("s1") + assert ei.value.status == 200 and isinstance(ei.value, SiteWarmError) and "secret-proxy-page" not in str(ei.value) + + +def test_user_agent_default_empty_custom_and_base_path_encoding(): + seen: list[str] = [] + + def handler(req): + seen.append(req.headers["user-agent"]) + return httpx.Response(200, json={}) + + for ua in (None, "", "mine/1"): + make(handler, user_agent=ua).sites.get("s1") + default = f"sitewarm-python/{sitewarm.__version__} (+https://github.com/sitewarm/sdk-python)" + assert seen == [default, default, "mine/1"] + c = SiteWarm(KEY, "https://x.test/a%2Fb/v1") + assert c._base_url == "https://x.test/a%2Fb/v1"