@biffo/cli 0.252.0 → 0.252.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,14 @@ class Settings(BaseSettings):
23
23
  # get or persist data (ADR-0002). Never add a database client here.
24
24
  core_api_url: str = ""
25
25
 
26
+ # NOT a free choice: this Lambda sits behind an HTTP API whose integration
27
+ # timeout is 29s, and `core_client` retries once, so the ceiling is
28
+ # 2 x this value < 29s. Twelve leaves comfortable room above core's measured
29
+ # ~7.8s cold start (tabsii-platform#567) and still lands at 24s worst case.
30
+ # Raising it past ~14 makes the gateway time out first, which returns a 504
31
+ # with no message rather than the explained one core_client produces.
32
+ core_api_timeout_seconds: float = 12.0
33
+
26
34
  environment: str = "dev"
27
35
  log_level: str = "INFO"
28
36
  cors_origins: list[str] = ["http://localhost:3000"]
@@ -1,3 +1,32 @@
1
+ """The only route from this sibling to the core project's API (ADR-0002/0007).
2
+
3
+ ## The timeout budget, and why it is not a free choice (tabsii-crm#221)
4
+
5
+ Every call here runs inside a Lambda behind an HTTP API whose **integration
6
+ timeout is 29s** (`aws apigatewayv2 get-integrations` — the Lambda itself is
7
+ configured for 30s, so the gateway is the binding constraint). Anything this
8
+ client waits for is spent from that 29s. Exceed it and the caller gets a
9
+ gateway 504 with no message at all, which is strictly worse than a slow answer.
10
+
11
+ So the numbers below are derived, not picked:
12
+
13
+ first attempt CORE_API_TIMEOUT_SECONDS 12s
14
+ one retry CORE_API_TIMEOUT_SECONDS 12s
15
+ ----
16
+ worst case 24s < 29s gateway ceiling
17
+
18
+ The previous value was a hardcoded `timeout=10`, copied into every call site.
19
+ Against core's measured **~7.8s cold start** (tabsii-platform#567) that left
20
+ roughly two seconds for the actual work, and a cold core produced
21
+ `httpx.ReadTimeout` -> an unhandled exception -> a 500 Internal Server Error on
22
+ whichever surface a session happened to hit first. Reloading fixed it, because
23
+ the second request found the Lambda warm — which is precisely what the retry
24
+ now does automatically.
25
+
26
+ Worth knowing which way the risk runs in a quiet app: a surface hit *less*
27
+ often than a busy landing page makes a cold core **more** likely, not less.
28
+ """
29
+
1
30
  import httpx
2
31
  from fastapi import HTTPException, Security, status
3
32
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -6,6 +35,39 @@ from .config import settings
6
35
 
7
36
  _security = HTTPBearer()
8
37
 
38
+ #: What a caller is told when core does not answer in time. A timeout is a
39
+ #: transient condition with an obvious user action, so it says so — unlike the
40
+ #: bare "Internal Server Error" this replaces, which described the symptom to
41
+ #: the one person who could do nothing about it.
42
+ _TIMEOUT_DETAIL = (
43
+ "The service took too long to respond. This usually clears on a retry — "
44
+ "please try again in a moment."
45
+ )
46
+
47
+
48
+ def _may_retry(method: str, exc: httpx.TimeoutException) -> bool:
49
+ """Whether re-sending ``method`` after ``exc`` is safe.
50
+
51
+ **This is a correctness question, not a tuning one.** A blanket retry would
52
+ be a data-integrity bug: `ReadTimeout` means the request *was* delivered and
53
+ we simply never saw the answer, so core may well have processed it. Re-sending
54
+ a POST in that state creates the row twice, and the user sees one success.
55
+
56
+ * ``ConnectTimeout`` — the connection was never established, so core cannot
57
+ have seen the request. Safe for **any** verb.
58
+ * ``ReadTimeout`` and everything else — sent, outcome unknown. Safe only for
59
+ ``GET``, which changes nothing by definition.
60
+
61
+ PUT and DELETE are idempotent in HTTP's sense and are still excluded
62
+ deliberately: core's DELETE is a soft delete, so a retry that lands after a
63
+ first attempt succeeded would answer 404 and turn a success into an error the
64
+ caller cannot distinguish from a real one. The cold-start case this exists for
65
+ is a page load — a GET — so nothing is lost by being strict here.
66
+ """
67
+ if isinstance(exc, httpx.ConnectTimeout):
68
+ return True
69
+ return method == "GET"
70
+
9
71
 
10
72
  class CoreApiError(Exception):
11
73
  def __init__(self, status_code: int, detail: str) -> None:
@@ -17,29 +79,48 @@ class CoreApiError(Exception):
17
79
  def _extract_detail(response: httpx.Response) -> str:
18
80
  """The message a caller should see for an upstream error, not the whole body.
19
81
 
20
- The core's own error responses are JSON like ``{"detail": "..."}`` --
82
+ The core's own error responses are JSON like ``{"detail": "..."}``
21
83
  ``response.text`` is that ENTIRE body, so raising it as-is and then handing
22
- it to FastAPI's ``HTTPException(detail=...)`` serialises it a second time.
84
+ it to FastAPI's `HTTPException(detail=...)` serialises it a second time.
23
85
  The client then receives ``{"detail": "{\\"detail\\": \\"<message>\\"}"}``:
24
86
  valid JSON, but a JSON *string* rather than the message inside it, and any
25
- component that renders ``detail`` shows that raw escaped blob to a user
26
- (tabsii-crm#137).
87
+ component that renders `detail` shows that raw escaped blob to a user
88
+ (tabsii-crm#137, and independently tabsii-lms — see the note at the foot).
27
89
 
28
- Parsed rather than assumed: a non-JSON or JSON-without-``detail`` upstream
90
+ Parsed rather than assumed: a non-JSON or JSON-without-`detail` upstream
29
91
  body (a proxy timeout page, a differently-shaped error) falls back to the
30
92
  raw text unchanged, so this never hides information the caller had before.
31
93
 
32
- Fixed in two siblings independently before it was ever fixed HERE, which is
33
- why three more were still shipping ``response.text`` months later. The
34
- browser-side twin is ``extractErrorMessage`` in
35
- ``apps/frontend/src/lib/api-client.ts``; keep the two in step.
94
+ **`detail` is not always a string, and a dict one is the same bug.** Core's
95
+ generic CRUD layer answers an integrity error with
96
+ ``{"detail": {"message": "...", "constraint": "..."}}``
97
+ (`routing/crud_handlers._integrity_error_response`), which the `isinstance`
98
+ check above rejects — so it fell to `response.text` and rebuilt
99
+ tabsii-crm#137 exactly, one shape further in. The visible cost there was a
100
+ delete refusal (tabsii-crm#272): the schema had written the user a sentence
101
+ naming what still depends on the record and prescribing the remedy, and the
102
+ browser showed the escaped JSON blob wrapped around it instead.
103
+
104
+ So a dict `detail` carrying a **string `message`** is unwrapped to that
105
+ message. `constraint` is deliberately dropped rather than appended — it is
106
+ a database object name, which is the schema reconnaissance
107
+ tabsii-platform#473 exists to keep out of a browser, and it says nothing to
108
+ the person reading the sentence.
109
+
110
+ Only the message key is trusted: a dict `detail` **without** one still falls
111
+ back to the raw text, because inventing a summary from a shape nobody has
112
+ declared would hide information rather than surface it.
36
113
  """
37
114
  try:
38
115
  body = response.json()
39
116
  except ValueError:
40
117
  return response.text
41
- if isinstance(body, dict) and isinstance(body.get("detail"), str):
42
- return body["detail"]
118
+ if isinstance(body, dict):
119
+ detail = body.get("detail")
120
+ if isinstance(detail, str):
121
+ return detail
122
+ if isinstance(detail, dict) and isinstance(detail.get("message"), str):
123
+ return detail["message"]
43
124
  return response.text
44
125
 
45
126
 
@@ -60,27 +141,59 @@ class CoreApiClient:
60
141
  def __init__(self, bearer_token: str) -> None:
61
142
  self._bearer_token = bearer_token
62
143
 
144
+ async def _send(self, method: str, path: str, body: dict | None = None) -> httpx.Response:
145
+ """Issue one core request, retrying once where that is provably safe.
146
+
147
+ Every verb goes through here so the timeout is stated **once**. It was
148
+ previously copied into every method, which is how it stayed at 10s while
149
+ core's cold start grew past it — a literal repeated once per verb is one
150
+ place to forget per verb (tabsii-crm#221).
151
+ """
152
+ headers = {"Authorization": f"Bearer {self._bearer_token}"}
153
+ attempts = 2
154
+ for attempt in range(1, attempts + 1):
155
+ try:
156
+ async with httpx.AsyncClient(
157
+ base_url=settings.core_api_url,
158
+ timeout=settings.core_api_timeout_seconds,
159
+ ) as client:
160
+ return await client.request(method, path, json=body, headers=headers)
161
+ except httpx.TimeoutException as exc:
162
+ if attempt == attempts or not _may_retry(method, exc):
163
+ raise CoreApiError(status.HTTP_504_GATEWAY_TIMEOUT, _TIMEOUT_DETAIL) from exc
164
+ raise AssertionError("unreachable: the loop either returns or raises")
165
+
63
166
  async def get(self, path: str) -> dict:
64
- async with httpx.AsyncClient(base_url=settings.core_api_url, timeout=10) as client:
65
- response = await client.get(
66
- path,
67
- headers={"Authorization": f"Bearer {self._bearer_token}"},
68
- )
167
+ response = await self._send("GET", path)
69
168
  if response.is_error:
70
169
  raise CoreApiError(response.status_code, _extract_detail(response))
71
170
  return response.json() # type: ignore[no-any-return]
72
171
 
73
172
  async def post(self, path: str, body: dict) -> dict:
74
- async with httpx.AsyncClient(base_url=settings.core_api_url, timeout=10) as client:
75
- response = await client.post(
76
- path,
77
- json=body,
78
- headers={"Authorization": f"Bearer {self._bearer_token}"},
79
- )
173
+ response = await self._send("POST", path, body)
80
174
  if response.is_error:
81
175
  raise CoreApiError(response.status_code, _extract_detail(response))
82
176
  return response.json() # type: ignore[no-any-return]
83
177
 
178
+ async def put(self, path: str, body: dict) -> dict:
179
+ response = await self._send("PUT", path, body)
180
+ if response.is_error:
181
+ raise CoreApiError(response.status_code, _extract_detail(response))
182
+ return response.json() # type: ignore[no-any-return]
183
+
184
+ async def patch(self, path: str, body: dict) -> dict:
185
+ response = await self._send("PATCH", path, body)
186
+ if response.is_error:
187
+ raise CoreApiError(response.status_code, _extract_detail(response))
188
+ return response.json() # type: ignore[no-any-return]
189
+
190
+ async def delete(self, path: str) -> dict:
191
+ response = await self._send("DELETE", path)
192
+ if response.is_error:
193
+ raise CoreApiError(response.status_code, _extract_detail(response))
194
+ # DELETE may return an empty body; tolerate that.
195
+ return response.json() if response.content else {} # type: ignore[no-any-return]
196
+
84
197
 
85
198
  def get_core_client(
86
199
  credentials: HTTPAuthorizationCredentials = Security(_security),
@@ -91,3 +204,22 @@ def get_core_client(
91
204
  detail="core_api_url is not configured",
92
205
  )
93
206
  return CoreApiClient(credentials.credentials)
207
+
208
+
209
+ # ── Why this file is shared, and what it cost to learn ──────────────────────
210
+ #
211
+ # Every sibling talks to core through a copy of this module. That copy used to
212
+ # drift, and the drift was expensive twice over:
213
+ #
214
+ # • `_extract_detail` was written TWICE, independently, in two siblings
215
+ # fixing the same user-visible bug, because neither author could discover
216
+ # the other had already solved it (biffo-template#1107/#1108).
217
+ # • The retry-and-timeout work was then ported a second time the same way
218
+ # (tabsii-crm#221 -> tabsii-lms#65/#66), and the dict-`detail` fix
219
+ # (tabsii-crm#272) reached only the repo that found it.
220
+ #
221
+ # So this file is declared in `shared-files.json` under `mustBeUniform`: the
222
+ # sync measures its variants and fails when they exceed the recorded baseline,
223
+ # rather than overwriting anybody. If you fix something here, fix it UPSTREAM in
224
+ # the skeleton and let it reach the others — a fix that lives in one sibling is
225
+ # a fix the next author will write again from scratch.
@@ -1,29 +1,27 @@
1
- """A proxied error must reach a caller as a plain message, not a JSON string
2
- re-serialised from an already-JSON upstream body.
3
-
4
- `CoreApiClient` raises `CoreApiError(status_code, detail)` on every verb, and a
5
- router turns that into `HTTPException(status_code=exc.status_code,
6
- detail=exc.detail)`. When `detail` was `response.text` — the WHOLE upstream
7
- body — a core error like `{"detail": "Method Not Allowed"}` became, once
8
- FastAPI serialised it a second time, `{"detail": "{\\"detail\\": \\"Method Not
9
- Allowed\\"}"}`: valid JSON, but a JSON *string* rather than the message inside
10
- it. Any component that renders `detail` showed that raw escaped blob to a user.
1
+ """tabsii-crm#137 — a proxied error must reach a caller as a plain message, not a
2
+ JSON string re-serialised from an already-JSON upstream body.
11
3
 
12
- Found and fixed independently in two siblings (tabsii-crm#137, tabsii-lms#13)
13
- before it was ever fixed in this skeleton, so three more siblings were still
14
- shipping the bug months later. The test lives here now so a sibling is born
15
- with it.
4
+ `CoreApiClient` raises `CoreApiError(status_code, detail)` on every verb, and
5
+ every router does `HTTPException(status_code=exc.status_code, detail=exc.detail)`.
6
+ Before this fix, `detail` was `response.text` the WHOLE upstream body so a
7
+ core error like `{"detail": "Method Not Allowed"}` became, once FastAPI
8
+ serialised it a second time, `{"detail": "{\\"detail\\": \\"Method Not
9
+ Allowed\\"}"}`: valid JSON, but a JSON *string* rather than the message inside
10
+ it. Any component that renders `detail` showed that raw escaped blob.
16
11
  """
17
12
 
13
+ from pathlib import Path
14
+
18
15
  import httpx
19
16
  import pytest
20
17
 
18
+ from api import core_client as core_client_module
21
19
  from api.config import settings
22
20
  from api.core_client import CoreApiClient, CoreApiError, _extract_detail
23
21
 
24
22
 
25
23
  class TestExtractDetail:
26
- """The parsing rule alone, with no network involved."""
24
+ """Unit-level: the parsing rule alone, with no network involved."""
27
25
 
28
26
  def test_unwraps_a_json_detail_body(self) -> None:
29
27
  response = httpx.Response(422, json={"detail": "Method Not Allowed"})
@@ -46,28 +44,243 @@ class TestExtractDetail:
46
44
  assert _extract_detail(response) == response.text
47
45
 
48
46
 
47
+ class TestIntegrityRefusalDetail:
48
+ """tabsii-crm#272 — the same bug as tabsii-crm#137, one shape further in.
49
+
50
+ Core's generic CRUD layer answers an integrity error with a **dict**
51
+ `detail` (`{"message": ..., "constraint": ...}`), which the original
52
+ string-only check rejected. So it fell through to `response.text` and
53
+ rebuilt the exact defect this module was written to fix.
54
+
55
+ It was not hypothetical. A delete refused because other records still
56
+ depend on the row came back from the schema as a sentence written FOR THE
57
+ USER, naming what depends on it and prescribing the remedy — and the user
58
+ saw the escaped JSON blob wrapped around that sentence instead.
59
+ """
60
+
61
+ #: A real refusal of that shape, trimmed. The remedy clause is the
62
+ #: load-bearing part — it is the only thing telling the user what to do
63
+ #: instead, and it is exactly what the escaped blob buried.
64
+ _REFUSAL = (
65
+ "record 0c0de102 cannot be deleted: 3 dependent record(s) reference it. "
66
+ "Set status to 'archived' instead — an archived record leaves the "
67
+ "active list while every reference stays intact."
68
+ )
69
+
70
+ def test_a_dict_detail_is_unwrapped_to_its_message(self) -> None:
71
+ response = httpx.Response(
72
+ 409, json={"detail": {"message": self._REFUSAL, "constraint": None}}
73
+ )
74
+ assert _extract_detail(response) == self._REFUSAL
75
+
76
+ def test_the_raw_body_does_not_reach_the_caller(self) -> None:
77
+ """The failure mode stated positively: no JSON punctuation, because a
78
+ blob starting `{"detail":{"message":` is what the author actually saw."""
79
+ response = httpx.Response(
80
+ 409, json={"detail": {"message": self._REFUSAL, "constraint": None}}
81
+ )
82
+ extracted = _extract_detail(response)
83
+ assert not extracted.startswith("{")
84
+ assert '"detail"' not in extracted
85
+
86
+ def test_the_constraint_name_is_dropped_not_appended(self) -> None:
87
+ """A constraint name is a database object name — schema reconnaissance
88
+ (tabsii-platform#473), and meaningless to whoever reads the sentence."""
89
+ response = httpx.Response(
90
+ 409,
91
+ json={"detail": {"message": "That name is already taken.", "constraint": "uq_x_name"}},
92
+ )
93
+ assert _extract_detail(response) == "That name is already taken."
94
+
95
+ def test_a_dict_detail_with_no_message_still_falls_back(self) -> None:
96
+ """Only the declared key is trusted. Summarising a shape nobody has
97
+ declared would hide information rather than surface it — the same
98
+ principle as the non-JSON fallback above."""
99
+ response = httpx.Response(500, json={"detail": {"code": "E17", "constraint": None}})
100
+ assert _extract_detail(response) == response.text
101
+
102
+
49
103
  class TestCoreApiClientErrorPassthrough:
50
- """A mocked upstream error reaches `CoreApiError.detail` flat."""
104
+ """End-to-end: a mocked upstream error reaches `CoreApiError.detail` flat."""
51
105
 
52
106
  async def test_get_surfaces_the_inner_message_not_the_whole_body(
53
107
  self, monkeypatch: pytest.MonkeyPatch
54
108
  ) -> None:
55
- monkeypatch.setattr(settings, "core_api_url", "https://core.example.com")
109
+ def handler(request: httpx.Request) -> httpx.Response:
110
+ return httpx.Response(404, json={"detail": "Brand not found"})
111
+
112
+ _install_mock_transport(monkeypatch, handler)
113
+
114
+ client = CoreApiClient("a-token")
115
+ with pytest.raises(CoreApiError) as exc_info:
116
+ await client.get("/api/v1/brands/does-not-exist")
117
+
118
+ assert exc_info.value.status_code == 404
119
+ # The regression this guards: NOT '{"detail": "Brand not found"}'.
120
+ assert exc_info.value.detail == "Brand not found"
56
121
 
122
+ async def test_post_surfaces_the_inner_message_not_the_whole_body(
123
+ self, monkeypatch: pytest.MonkeyPatch
124
+ ) -> None:
57
125
  def handler(request: httpx.Request) -> httpx.Response:
58
- return httpx.Response(403, json={"detail": "Administrator access required"})
126
+ return httpx.Response(405, json={"detail": "Method Not Allowed"})
127
+
128
+ _install_mock_transport(monkeypatch, handler)
129
+
130
+ client = CoreApiClient("a-token")
131
+ with pytest.raises(CoreApiError) as exc_info:
132
+ await client.post("/api/v1/fdds", {})
133
+
134
+ assert exc_info.value.status_code == 405
135
+ assert exc_info.value.detail == "Method Not Allowed"
136
+
137
+
138
+ def _install_mock_transport(monkeypatch: pytest.MonkeyPatch, handler) -> None:
139
+ """Make `CoreApiClient`'s internal `httpx.AsyncClient(...)` use a
140
+ `MockTransport` instead of a real connection, while leaving every other
141
+ constructor argument (`base_url`, `timeout`) exactly as production passes
142
+ them.
143
+
144
+ `settings.core_api_url` also needs a real-looking base URL here: it
145
+ defaults to `""` outside a deployed environment, and httpx's cookie
146
+ handling chokes on a scheme-less, host-less request URL before the mock
147
+ transport is ever reached.
148
+ """
149
+ monkeypatch.setattr(settings, "core_api_url", "https://core.example.test")
150
+
151
+ real_async_client = httpx.AsyncClient
152
+
153
+ def fake_async_client(*args: object, **kwargs: object) -> httpx.AsyncClient:
154
+ kwargs["transport"] = httpx.MockTransport(handler)
155
+ return real_async_client(*args, **kwargs)
156
+
157
+ monkeypatch.setattr("api.core_client.httpx.AsyncClient", fake_async_client)
158
+
159
+
160
+ class TestTimeoutHandling:
161
+ """tabsii-crm#221 — a cold core must not surface as "Internal Server Error".
162
+
163
+ The reported failure: a landing page rendered *"Signed in, but could
164
+ not reach the API: Internal Server Error"* whenever core had gone cold. The
165
+ BFF's hardcoded 10s budget was shorter than core's ~7.8s cold start plus the
166
+ work itself, so `httpx.ReadTimeout` escaped as an unhandled exception.
167
+ """
168
+
169
+ async def test_a_timeout_becomes_a_504_with_an_explained_message(
170
+ self, monkeypatch: pytest.MonkeyPatch
171
+ ) -> None:
172
+ """Not a 500, and not the raw exception.
173
+
174
+ A 504 is what actually happened — an upstream did not answer — and the
175
+ detail tells the reader the one thing they can act on. The old behaviour
176
+ told them the platform was broken when it was merely asleep.
177
+ """
178
+
179
+ def handler(request: httpx.Request) -> httpx.Response:
180
+ raise httpx.ReadTimeout("core is cold", request=request)
181
+
182
+ _install_mock_transport(monkeypatch, handler)
183
+
184
+ client = CoreApiClient("a-token")
185
+ with pytest.raises(CoreApiError) as exc_info:
186
+ await client.get("/api/v1/whoami")
187
+
188
+ assert exc_info.value.status_code == 504
189
+ assert "try again" in exc_info.value.detail.lower()
190
+ assert "internal server error" not in exc_info.value.detail.lower()
191
+
192
+ async def test_a_get_is_retried_once_and_a_warm_second_attempt_succeeds(
193
+ self, monkeypatch: pytest.MonkeyPatch
194
+ ) -> None:
195
+ """The actual repair for the reported bug.
196
+
197
+ The user's own workaround was to reload the page, which worked because
198
+ the second request found the Lambda warm. This does that for them.
199
+ """
200
+ calls: list[str] = []
201
+
202
+ def handler(request: httpx.Request) -> httpx.Response:
203
+ calls.append(request.method)
204
+ if len(calls) == 1:
205
+ raise httpx.ReadTimeout("core is cold", request=request)
206
+ return httpx.Response(200, json={"ok": True})
207
+
208
+ _install_mock_transport(monkeypatch, handler)
209
+
210
+ assert await CoreApiClient("a-token").get("/api/v1/whoami") == {"ok": True}
211
+ assert calls == ["GET", "GET"], "the first attempt must be retried exactly once"
212
+
213
+ async def test_a_post_is_never_retried_after_a_read_timeout(
214
+ self, monkeypatch: pytest.MonkeyPatch
215
+ ) -> None:
216
+ """**The data-integrity half, and the reason `_may_retry` exists.**
217
+
218
+ A `ReadTimeout` means the request reached core and the answer was lost —
219
+ core may have processed it. Re-sending a POST in that state creates the
220
+ row twice and the caller sees a single success. So a write times out
221
+ once and stops.
222
+
223
+ If a later change makes this fail, the fix is never "retry everything":
224
+ it is to give the write an idempotency key so a retry can be recognised.
225
+ """
226
+ calls: list[str] = []
227
+
228
+ def handler(request: httpx.Request) -> httpx.Response:
229
+ calls.append(request.method)
230
+ raise httpx.ReadTimeout("core is cold", request=request)
231
+
232
+ _install_mock_transport(monkeypatch, handler)
233
+
234
+ with pytest.raises(CoreApiError) as exc_info:
235
+ await CoreApiClient("a-token").post("/api/v1/leads", {"name": "x"})
236
+
237
+ assert exc_info.value.status_code == 504
238
+ assert calls == ["POST"], "a write must NOT be re-sent — it may have been applied"
239
+
240
+ async def test_a_post_is_retried_after_a_connect_timeout(
241
+ self, monkeypatch: pytest.MonkeyPatch
242
+ ) -> None:
243
+ """The distinction the test above depends on.
244
+
245
+ A `ConnectTimeout` never established a connection, so core provably did
246
+ not see the request and re-sending cannot duplicate anything. Without
247
+ this case the previous test would pass just as well against a client that
248
+ never retried writes for the *wrong* reason.
249
+ """
250
+ calls: list[str] = []
251
+
252
+ def handler(request: httpx.Request) -> httpx.Response:
253
+ calls.append(request.method)
254
+ if len(calls) == 1:
255
+ raise httpx.ConnectTimeout("no connection", request=request)
256
+ return httpx.Response(200, json={"created": True})
257
+
258
+ _install_mock_transport(monkeypatch, handler)
59
259
 
60
- transport = httpx.MockTransport(handler)
61
- original = httpx.AsyncClient
260
+ assert await CoreApiClient("a-token").post("/api/v1/leads", {"n": 1}) == {"created": True}
261
+ assert calls == ["POST", "POST"]
62
262
 
63
- def patched(*args, **kwargs): # type: ignore[no-untyped-def]
64
- kwargs["transport"] = transport
65
- return original(*args, **kwargs)
263
+ async def test_the_retry_budget_fits_inside_the_gateway_ceiling(self) -> None:
264
+ """The arithmetic, asserted rather than left in a comment.
66
265
 
67
- monkeypatch.setattr(httpx, "AsyncClient", patched)
266
+ The HTTP API integration times out at 29s. Two attempts at the configured
267
+ timeout must finish inside that, or the gateway answers first and the
268
+ caller gets a bare 504 with none of the explanation above. Someone raising
269
+ the timeout to "be safe" would silently reintroduce the original bug in a
270
+ new form; this stops them.
271
+ """
272
+ gateway_ceiling_seconds = 29
273
+ assert settings.core_api_timeout_seconds * 2 < gateway_ceiling_seconds
68
274
 
69
- with pytest.raises(CoreApiError) as excinfo:
70
- await CoreApiClient("token").get("/api/v1/admin/users")
275
+ async def test_every_verb_shares_one_timeout(self) -> None:
276
+ """The duplication that let the value go stale is gone.
71
277
 
72
- assert excinfo.value.detail == "Administrator access required"
73
- assert excinfo.value.status_code == 403
278
+ The timeout was copied into all five methods, so it stayed at 10 while
279
+ core's cold start grew past it. Any reappearance of a literal here means
280
+ the next person has five places to update again.
281
+ """
282
+ source = Path(core_client_module.__file__).read_text()
283
+ code = "\n".join(line for line in source.splitlines() if not line.strip().startswith("#"))
284
+ body = code.split('"""', 2)[-1] # drop the module docstring
285
+ assert "timeout=10" not in body
286
+ assert body.count("settings.core_api_timeout_seconds") == 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.252.0",
3
+ "version": "0.252.2",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",