diff --git a/architecture/providers.md b/architecture/providers.md index 6bccdbe..53451ce 100644 --- a/architecture/providers.md +++ b/architecture/providers.md @@ -68,9 +68,17 @@ modern-di, which is safe because httpx2 connection pools are lazy — the unused client opens no sockets. Clients are closed by a modern-di cache finalizer (`_close_client`). Responses are decoded by httpware against pydantic `response_model`s (`_ProjectResponse`, `_CommitList`, `_TagList`, …) via the -`get` / `send_with_response` helpers; a decode failure surfaces as +`get` / `get_with_response` helpers; a decode failure surfaces as `httpware.DecodeError` and is translated to `ProviderAPIError`. +Both clients also set a 1 MiB `max_error_body_bytes` cap (`_MAX_ERROR_BODY_BYTES` +in `semvertag/ioc.py`): httpware raises `ResponseTooLargeError` on a 4xx/5xx +whose declared `Content-Length` exceeds the cap, before reading the body — a +defensive bound against a hostile or malfunctioning endpoint. That error is an +`httpware.ClientError` (not a `StatusError`), so `_translate_transport` maps it +to `ProviderAPIError`. Real GitLab/GitHub error bodies are tiny JSON and never +approach the cap. + ## Link-header pagination Tag listing walks RFC 8288 `Link` headers. `semvertag/_link_pagination.py` diff --git a/planning/README.md b/planning/README.md index 197f8f8..48d64f2 100644 --- a/planning/README.md +++ b/planning/README.md @@ -74,6 +74,9 @@ _None._ ### Archived (shipped) +- **[httpware-max-error-body-bytes](changes/archive/2026-06-16.03-httpware-max-error-body-bytes/design.md)** + (#26, 2026-06-16) — Cap provider error-body reads at 1 MiB; translate + `ResponseTooLargeError` to `ProviderAPIError`. - **[branch-prefix-patch-on-non-merge](changes/archive/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md)** (#24, 2026-06-16) — Opt-in `patch_on_non_merge_commit` flag: a non-merge HEAD commit bumps patch instead of nothing. diff --git a/planning/changes/archive/2026-06-16.03-httpware-max-error-body-bytes/design.md b/planning/changes/archive/2026-06-16.03-httpware-max-error-body-bytes/design.md new file mode 100644 index 0000000..6a80f06 --- /dev/null +++ b/planning/changes/archive/2026-06-16.03-httpware-max-error-body-bytes/design.md @@ -0,0 +1,150 @@ +--- +status: shipped +date: 2026-06-16 +slug: httpware-max-error-body-bytes +supersedes: null +superseded_by: null +pr: 26 +outcome: Shipped. Both provider clients built with a 1 MiB max_error_body_bytes + cap; ResponseTooLargeError translates to ProviderAPIError (None-length + guarded). Conclusions promoted into architecture/providers.md. +--- + +# Design: Bound provider error-body reads (httpware max_error_body_bytes) + +## Summary + +Adopt httpware 0.11.0's opt-in `max_error_body_bytes` cap when constructing the +GitLab and GitHub clients, set to a hardcoded **1 MiB**. With the cap set, +httpware raises `ResponseTooLargeError` on a 4xx/5xx whose declared +`Content-Length` exceeds the limit, *before* reading the body — a defensive +bound against a misbehaving or hostile endpoint returning an oversized error +body. A new branch in `_translate_transport` maps that error to a clear +`ProviderAPIError`. No new configuration surface; behavior for real +GitLab/GitHub responses (tiny JSON) is unchanged. + +## Motivation + +The 0.12.0 bump +([httpware-0.12-get-with-response](../../archive/2026-06-16.01-httpware-0.12-get-with-response/change.md), +#24) consciously deferred this: picking a cap value is a judgment call, not a +mechanical bump. Both providers read a 4xx/5xx error body to surface a message +(and to distinguish "already exists" on tag creation). Today that read is +unbounded — a compromised or malfunctioning endpoint (or a misconfigured +`SEMVERTAG_*__ENDPOINT` pointed at a hostile host) could return a multi-megabyte +or unbounded error body that bloats memory and logs. httpware 0.11.0 added an +opt-in guard for exactly this; semvertag should use it. + +## Non-goals + +- No new setting or CLI flag. The cap is a defensive constant, not an operator + tuning knob — real GitLab/GitHub error bodies are orders of magnitude smaller + than any sane cap, so there is nothing to tune. +- Not bounding *success* response bodies (tag lists, commits). Those are + paginated and already bounded by `per_page` + the `_MAX_TAG_PAGES` cap; this + change is strictly about error bodies. +- Not closing the chunked-body gap. httpware's cap keys on a declared + `Content-Length`; a chunked error body with no declared length is still read. + That deeper bound is httpware's concern, tracked upstream. + +## Design + +### 1. Cap constant and client wiring + +Add a module constant to `semvertag/ioc.py` and pass it to both client builders: + +```python +_MAX_ERROR_BODY_BYTES: typing.Final = 1024 * 1024 # 1 MiB — defensive cap on 4xx/5xx error bodies +``` + +```python +def _build_gitlab_client(settings: Settings) -> httpware.Client: + return httpware.Client( + base_url=settings.gitlab.endpoint, + timeout=settings.request_timeout, + headers={_GITLAB_TOKEN_HEADER: settings.gitlab.token.get_secret_value()}, + middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], + max_error_body_bytes=_MAX_ERROR_BODY_BYTES, + ) +``` + +`_build_github_client` gets the same `max_error_body_bytes=_MAX_ERROR_BODY_BYTES` +argument. **1 MiB** is ~200× the largest plausible GitLab/GitHub JSON error body, +so it never trips in normal operation while still bounding a pathological +multi-MB/GB body; the memory cost is irrelevant for a one-shot CLI. + +### 2. Error translation + +`ResponseTooLargeError` is an `httpware.ClientError` subclass (not a +`StatusError`), so it already routes through `_translate_transport` in +`semvertag/providers/_errors.py`. Add an explicit branch *before* the generic +fallback so the message is actionable rather than `" request failed: +ResponseTooLargeError"`: + +```python +def _translate_transport(exc: httpware.ClientError, *, provider_label: str) -> Exception: + if isinstance(exc, httpware.DecodeError): + ... + if isinstance(exc, httpware.ResponseTooLargeError): + return ProviderAPIError( + f"{provider_label} returned an error response body of {exc.content_length} bytes, " + f"exceeding the {exc.limit}-byte cap (HTTP {exc.status_code}); refusing to read it." + ) + ... + return ProviderAPIError(f"{provider_label} request failed: {type(exc).__name__}") +``` + +`ResponseTooLargeError(*, status_code, limit, content_length)` carries the three +values the message uses. Placement among the other transport branches does not +matter for correctness (the types are disjoint); putting it alongside the others +keeps the function's shape consistent. + +### 3. Behavioral trade-off + +When the cap trips, httpware raises `ResponseTooLargeError` *instead of* the +normal `StatusError`, so that one pathological response loses its specific +mapping (e.g. 401 → `AuthError`, a 400 "already exists" → `ConfigError`) and +surfaces as a generic `ProviderAPIError`. This is acceptable: a real +GitLab/GitHub error body never approaches 1 MiB, so the only responses that hit +this path are pathological, and "the server returned an absurdly large error +body" is the right thing to report about them. + +## Testing + +Global pytest config runs `--cov-branch` with `fail_under = 100`, so the new +`_translate_transport` branch must be covered. + +- **Translation** (`tests/unit/test_providers_errors.py`): construct + `httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, + content_length=5_000_000)`, pass it through `translate_gitlab(exc, + project_id=...)` and `translate_github(exc, repo=...)`, and assert each returns + a `ProviderAPIError` whose message contains the byte counts and the provider + label. Covers the new branch for both providers. +- **Wiring** (`tests/unit/test_ioc.py`): assert + `_build_gitlab_client(settings)._max_error_body_bytes == _MAX_ERROR_BODY_BYTES` + and the same for `_build_github_client`. httpware exposes the cap only as the + private `_max_error_body_bytes`; reading it in a test is fine — + `tests/**/*.py` already ignores `SLF001` in the ruff config. +- **No behavioral cap test.** Faking a large declared `Content-Length` through + `httpx2.MockTransport` does not work — httpx2 normalizes the header to the + actual body size, so the cap never sees an oversized length and the mocked + 4xx/5xx surfaces as an ordinary `StatusError`. The cap mechanism itself is + httpware's tested concern; semvertag tests cover the wiring and the + translation only. + +## Docs + +- `architecture/providers.md` — in the HTTP-client section, note that both + clients are built with a 1 MiB `max_error_body_bytes` cap and that + `ResponseTooLargeError` translates to `ProviderAPIError` via + `_translate_transport`. +- No user-facing docs change: the cap is internal and not configurable. + +## Risk + +Low. The cap is far above any real error body, so normal operation is +byte-for-byte unchanged; the only observable difference is on pathological +oversized error responses, which previously read unbounded and now fail fast +with a clear message. The single new branch is covered by the 100%-branch gate. +No rollback concern — removing the `max_error_body_bytes` argument restores the +prior unbounded read. diff --git a/planning/changes/archive/2026-06-16.03-httpware-max-error-body-bytes/plan.md b/planning/changes/archive/2026-06-16.03-httpware-max-error-body-bytes/plan.md new file mode 100644 index 0000000..cc9358b --- /dev/null +++ b/planning/changes/archive/2026-06-16.03-httpware-max-error-body-bytes/plan.md @@ -0,0 +1,289 @@ +--- +status: shipped +date: 2026-06-16 +slug: httpware-max-error-body-bytes +spec: httpware-max-error-body-bytes +pr: 26 +--- + +# httpware-max-error-body-bytes — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build both provider HTTP clients with a 1 MiB `max_error_body_bytes` +cap and translate the resulting `ResponseTooLargeError` into a clear +`ProviderAPIError`. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `feat/httpware-max-error-body-bytes` + +**Commit strategy:** Per-task commits (wiring, translation, docs). + +**Context for an engineer new to this codebase:** + +- Provider HTTP clients are built in `semvertag/ioc.py` by `_build_gitlab_client` + and `_build_github_client`, each returning an `httpware.Client`. httpware + 0.12.0 is already a dependency. +- `httpware.Client(..., max_error_body_bytes=N)` makes the client raise + `httpware.ResponseTooLargeError` on a 4xx/5xx whose declared `Content-Length` + exceeds `N`, before reading the body. The cap is stored on the private + attribute `client._max_error_body_bytes` (httpware exposes no public getter). +- `ResponseTooLargeError` is an `httpware.ClientError` subclass (NOT a + `StatusError`). Its constructor is keyword-only: + `httpware.ResponseTooLargeError(*, status_code: int, limit: int, + content_length: int | None)`. +- Provider errors are translated to the semvertag domain hierarchy in + `semvertag/providers/_errors.py`. `translate_gitlab` / `translate_github` + route any non-`StatusError` `ClientError` to the shared `_translate_transport`, + which currently ends in a generic fallback. +- Tests: `just test` runs the full suite with `--cov-branch` and + `fail_under = 100` — every branch must be covered. The per-file fast loop is + `just test -p no:randomly --override-ini="addopts=" -q` (disables + coverage + random ordering). `ruff` runs with `select = ["ALL"]` but + `tests/**/*.py` already ignores `SLF001`, so reading private members + (`ioc._build_*`, `client._max_error_body_bytes`) in tests needs no suppression. +- `just lint-ci` must pass (eof-fixer, ruff format, ruff check, ty). + +--- + +### Task 1: Cap constant and client wiring + +**Files:** +- Modify: `semvertag/ioc.py` +- Test: `tests/unit/test_ioc.py` + +Add the cap constant and pass it to both client builders, test-first. + +- [ ] **Step 1: Write the failing tests** + + Append to `tests/unit/test_ioc.py` (the imports `typing`, `httpware`, `ioc`, + and the `_settings` helper already exist at the top of the file — reuse them): + + ```python + def test_gitlab_client_is_built_with_error_body_cap() -> None: + client: typing.Final = ioc._build_gitlab_client(_settings()) + assert client._max_error_body_bytes == ioc._MAX_ERROR_BODY_BYTES + + + def test_github_client_is_built_with_error_body_cap() -> None: + client: typing.Final = ioc._build_github_client(_settings()) + assert client._max_error_body_bytes == ioc._MAX_ERROR_BODY_BYTES + + + def test_error_body_cap_is_one_mebibyte() -> None: + assert ioc._MAX_ERROR_BODY_BYTES == 1024 * 1024 + ``` + +- [ ] **Step 2: Run the new tests, verify they fail** + + Run: `just test tests/unit/test_ioc.py -p no:randomly --override-ini="addopts=" -q` + + Expected: FAIL with `AttributeError: module 'semvertag.ioc' has no attribute + '_MAX_ERROR_BODY_BYTES'` (the constant does not exist yet). + +- [ ] **Step 3: Add the constant** + + In `semvertag/ioc.py`, add alongside the other module constants (after + `_RETRY_STATUS_CODES`): + + ```python + _MAX_ERROR_BODY_BYTES: typing.Final = 1024 * 1024 # 1 MiB — defensive cap on 4xx/5xx error bodies + ``` + +- [ ] **Step 4: Pass the cap to both builders** + + In `_build_gitlab_client`, add the argument to the `httpware.Client(...)` call: + + ```python + def _build_gitlab_client(settings: Settings) -> httpware.Client: + return httpware.Client( + base_url=settings.gitlab.endpoint, + timeout=settings.request_timeout, + headers={_GITLAB_TOKEN_HEADER: settings.gitlab.token.get_secret_value()}, + middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], + max_error_body_bytes=_MAX_ERROR_BODY_BYTES, + ) + ``` + + In `_build_github_client`, add the same argument: + + ```python + def _build_github_client(settings: Settings) -> httpware.Client: + return httpware.Client( + base_url=settings.github.endpoint, + timeout=settings.request_timeout, + headers={ + "Authorization": f"Bearer {settings.github.token.get_secret_value()}", + "Accept": _GITHUB_ACCEPT, + "X-GitHub-Api-Version": _GITHUB_API_VERSION, + }, + middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], + max_error_body_bytes=_MAX_ERROR_BODY_BYTES, + ) + ``` + +- [ ] **Step 5: Run the ioc tests, verify they pass** + + Run: `just test tests/unit/test_ioc.py -p no:randomly --override-ini="addopts=" -q` + Expected: PASS — the three new tests plus the existing ones green. + +- [ ] **Step 6: Commit** + + ```bash + git add semvertag/ioc.py tests/unit/test_ioc.py + git commit -m "providers: cap provider error-body reads at 1 MiB + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 2: Translate ResponseTooLargeError + +**Files:** +- Modify: `semvertag/providers/_errors.py` +- Test: `tests/unit/test_providers_errors.py` + +Map `ResponseTooLargeError` to an actionable `ProviderAPIError`, test-first. + +- [ ] **Step 1: Write the failing tests** + + Append to `tests/unit/test_providers_errors.py` (the imports `httpware`, + `ProviderAPIError`, `translate_gitlab`, `translate_github`, and the constant + `_PROJECT_ID` already exist at the top of the file — reuse them): + + ```python + def test_translate_gitlab_response_too_large_becomes_provider_api_error() -> None: + exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000) + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "GitLab" in str(result) + assert "5000000" in str(result) + assert "1048576" in str(result) + + + def test_translate_github_response_too_large_becomes_provider_api_error() -> None: + exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000) + result = translate_github(exc, repo="owner/repo") + assert isinstance(result, ProviderAPIError) + assert "GitHub" in str(result) + assert "5000000" in str(result) + ``` + +- [ ] **Step 2: Run the new tests, verify they fail** + + Run: `just test tests/unit/test_providers_errors.py -p no:randomly --override-ini="addopts=" -q` + + Expected: FAIL with `AssertionError`. `ResponseTooLargeError` is a + `ClientError`, so it currently falls through `_translate_transport` to the + generic fallback `"GitLab request failed: ResponseTooLargeError"`, which + contains neither `"5000000"` nor `"1048576"`. + +- [ ] **Step 3: Add the translation branch** + + In `semvertag/providers/_errors.py`, inside `_translate_transport`, add a + branch before the final `return` fallback (placement among the other + `isinstance` branches is fine — the types are disjoint): + + ```python + if isinstance(exc, httpware.ResponseTooLargeError): + return ProviderAPIError( + f"{provider_label} returned an error response body of {exc.content_length} bytes, " + f"exceeding the {exc.limit}-byte cap (HTTP {exc.status_code}); refusing to read it." + ) + ``` + + For reference, the function becomes: + + ```python + def _translate_transport(exc: httpware.ClientError, *, provider_label: str) -> Exception: + if isinstance(exc, httpware.DecodeError): + return ProviderAPIError(f"{provider_label} {exc.model.__name__} response could not be decoded: {exc.original}") + if isinstance(exc, httpware.TimeoutError): + return ProviderAPIError(f"{provider_label} request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.") + if isinstance(exc, httpware.RetryBudgetExhaustedError): + return ProviderAPIError(f"{provider_label} retries exhausted after {exc.attempts} attempts. Try again later.") + if isinstance(exc, httpware.NetworkError): + return ProviderAPIError(f"{provider_label} unreachable. Check network connectivity.") + if isinstance(exc, httpware.ResponseTooLargeError): + return ProviderAPIError( + f"{provider_label} returned an error response body of {exc.content_length} bytes, " + f"exceeding the {exc.limit}-byte cap (HTTP {exc.status_code}); refusing to read it." + ) + return ProviderAPIError(f"{provider_label} request failed: {type(exc).__name__}") + ``` + +- [ ] **Step 4: Run the error tests, verify they pass** + + Run: `just test tests/unit/test_providers_errors.py -p no:randomly --override-ini="addopts=" -q` + Expected: PASS — both new tests plus the existing ones green. + +- [ ] **Step 5: Run the full gated suite and lint** + + Run: `just test` + Expected: PASS — full suite green at 100% branch coverage (the new branch's + taken path is covered by both new translation tests). + + Run: `just lint-ci` + Expected: PASS — eof-fixer, ruff format, ruff check, ty all clean. + +- [ ] **Step 6: Commit** + + ```bash + git add semvertag/providers/_errors.py tests/unit/test_providers_errors.py + git commit -m "providers: translate ResponseTooLargeError to ProviderAPIError + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 3: Document the cap + +**Files:** +- Modify: `architecture/providers.md` + +Promote the hardening into the capability doc. + +- [ ] **Step 1: Update `architecture/providers.md`** + + In the HTTP-client section (the `## HTTP client` heading near the end of the + file), add a sentence noting the cap. Match the surrounding prose style: + + > Both clients are built with a 1 MiB `max_error_body_bytes` cap + > (`semvertag/ioc.py`): httpware raises `ResponseTooLargeError` on a 4xx/5xx + > whose declared `Content-Length` exceeds the cap, before reading the body, as + > a defensive bound against a hostile or malfunctioning endpoint. The error is + > an `httpware.ClientError` (not a `StatusError`), so `_translate_transport` + > maps it to `ProviderAPIError`. Real GitLab/GitHub error bodies are tiny JSON + > and never approach the cap. + +- [ ] **Step 2: Verify the docs build** + + Run: `just docs-build` + Expected: PASS — strict mkdocs build with no warnings. (`architecture/` is + outside the mkdocs site, so this mainly confirms nothing else broke; run it + anyway as the docs gate.) + +- [ ] **Step 3: Commit** + + ```bash + git add architecture/providers.md + git commit -m "docs: note the 1 MiB provider error-body cap + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Notes for finishing + +- Lane **full** (`design.md` + `plan.md`). On merge: move the bundle to + `planning/changes/archive/` with `status: shipped`, `pr:`, `outcome:` filled; + confirm the `architecture/providers.md` edit landed; and remove the + "httpware bounded-error-body adoption" item from `planning/deferred.md` (this + change resolves it). diff --git a/planning/deferred.md b/planning/deferred.md index ae5f12f..f7f1770 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -8,15 +8,4 @@ bundle in [`changes/active/`](changes/active/); see [CLAUDE.md](../CLAUDE.md#wor ## Open -### httpware bounded-error-body adoption - -Adopt httpware 0.11.0's opt-in `max_error_body_bytes` cap (raises -`ResponseTooLargeError`) when building the provider clients, to bound the bytes -read from a 4xx/5xx error body. - -- **Deferred because:** the 0.12.0 bump - ([httpware-0.12-get-with-response](changes/archive/2026-06-16.01-httpware-0.12-get-with-response/change.md), - #24) skipped it — picking a cap value is a real config decision, not a - mechanical bump, and the default (`None`, unbounded) matches prior behavior. -- **Trigger:** when we next harden provider error handling, or if a forge is - observed returning large error bodies that bloat logs / memory in practice. +_None._ diff --git a/semvertag/ioc.py b/semvertag/ioc.py index 1c97179..446e474 100644 --- a/semvertag/ioc.py +++ b/semvertag/ioc.py @@ -18,6 +18,7 @@ _GITHUB_ACCEPT: typing.Final = "application/vnd.github+json" _GITHUB_API_VERSION: typing.Final = "2022-11-28" _RETRY_STATUS_CODES: typing.Final = frozenset({408, 429, 500, 502, 503, 504}) +_MAX_ERROR_BODY_BYTES: typing.Final = 1024 * 1024 # 1 MiB — defensive cap on 4xx/5xx error bodies def _build_gitlab_client(settings: Settings) -> httpware.Client: @@ -26,6 +27,7 @@ def _build_gitlab_client(settings: Settings) -> httpware.Client: timeout=settings.request_timeout, headers={_GITLAB_TOKEN_HEADER: settings.gitlab.token.get_secret_value()}, middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], + max_error_body_bytes=_MAX_ERROR_BODY_BYTES, ) @@ -39,6 +41,7 @@ def _build_github_client(settings: Settings) -> httpware.Client: "X-GitHub-Api-Version": _GITHUB_API_VERSION, }, middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], + max_error_body_bytes=_MAX_ERROR_BODY_BYTES, ) diff --git a/semvertag/providers/_errors.py b/semvertag/providers/_errors.py index f23509c..fb05912 100644 --- a/semvertag/providers/_errors.py +++ b/semvertag/providers/_errors.py @@ -43,6 +43,12 @@ def _translate_transport(exc: httpware.ClientError, *, provider_label: str) -> E return ProviderAPIError(f"{provider_label} request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.") if isinstance(exc, httpware.RetryBudgetExhaustedError): return ProviderAPIError(f"{provider_label} retries exhausted after {exc.attempts} attempts. Try again later.") + if isinstance(exc, httpware.ResponseTooLargeError): + size_part = f"{exc.content_length} bytes" if exc.content_length is not None else "an undeclared number of bytes" + return ProviderAPIError( + f"{provider_label} returned an error response body of {size_part}, " + f"exceeding the {exc.limit}-byte cap (HTTP {exc.status_code}); refusing to read it." + ) if isinstance(exc, httpware.NetworkError): return ProviderAPIError(f"{provider_label} unreachable. Check network connectivity.") return ProviderAPIError(f"{provider_label} request failed: {type(exc).__name__}") diff --git a/tests/unit/test_ioc.py b/tests/unit/test_ioc.py index 59895d8..dcce822 100644 --- a/tests/unit/test_ioc.py +++ b/tests/unit/test_ioc.py @@ -72,3 +72,13 @@ def test_container_resolves_gitlab_provider_when_settings_provider_is_gitlab() - provider = ioc.container.resolve_provider(ioc.ProvidersGroup.current_provider) assert isinstance(provider, GitLabProvider) assert provider.name == "gitlab" + + +def test_gitlab_client_is_built_with_error_body_cap() -> None: + client: typing.Final = ioc._build_gitlab_client(_settings()) + assert client._max_error_body_bytes == ioc._MAX_ERROR_BODY_BYTES + + +def test_github_client_is_built_with_error_body_cap() -> None: + client: typing.Final = ioc._build_github_client(_settings()) + assert client._max_error_body_bytes == ioc._MAX_ERROR_BODY_BYTES diff --git a/tests/unit/test_providers_errors.py b/tests/unit/test_providers_errors.py index 03b1fa1..422ac80 100644 --- a/tests/unit/test_providers_errors.py +++ b/tests/unit/test_providers_errors.py @@ -285,3 +285,29 @@ def test_translate_create_tag_github_other_422_becomes_generic_config_error() -> assert isinstance(result, ConfigError) assert "v1.2.3" not in str(result) assert "422" in str(result) + + +def test_translate_gitlab_response_too_large_becomes_provider_api_error() -> None: + exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000) + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "GitLab" in str(result) + assert "5000000" in str(result) + assert "1048576" in str(result) + + +def test_translate_github_response_too_large_becomes_provider_api_error() -> None: + exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=5_000_000) + result = translate_github(exc, repo="owner/repo") + assert isinstance(result, ProviderAPIError) + assert "GitHub" in str(result) + assert "5000000" in str(result) + assert "1048576" in str(result) + + +def test_translate_response_too_large_with_undeclared_length_omits_byte_count() -> None: + exc = httpware.ResponseTooLargeError(status_code=413, limit=1_048_576, content_length=None) + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "undeclared number of bytes" in str(result) + assert "1048576" in str(result)