@biffo/cli 0.252.1 → 0.252.3
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
|
|
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
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
61
|
-
|
|
260
|
+
assert await CoreApiClient("a-token").post("/api/v1/leads", {"n": 1}) == {"created": True}
|
|
261
|
+
assert calls == ["POST", "POST"]
|
|
62
262
|
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
package/scripts/verify.sh
CHANGED
|
@@ -242,6 +242,14 @@ SKIPPED=""
|
|
|
242
242
|
# repo demonstrably has -- absence and blindness reading identically is the
|
|
243
243
|
# defect, not a formatting nit.
|
|
244
244
|
NOT_RUN=""
|
|
245
|
+
# Checks that DID run but could not produce a verdict -- kept apart from
|
|
246
|
+
# FAILED because "cannot tell" and "found something wrong" are different
|
|
247
|
+
# facts, and #703 recorded the cost of rendering them the same: a killed
|
|
248
|
+
# pg-test lane printed `verify failed: pg-test` naming no failing test, and
|
|
249
|
+
# sent the reader hunting a bug that was never there. Same convention
|
|
250
|
+
# `wait-for-checks.sh` and `branch-health.sh` already use: a distinct bucket,
|
|
251
|
+
# a distinct exit code (2), and it is NEVER treated as a pass.
|
|
252
|
+
INCONCLUSIVE=""
|
|
245
253
|
# Defined up here, not inside run_check. `run_check` returns EARLY in --list
|
|
246
254
|
# mode, before it would set this -- so `pytest_record "$d" "$LAST_CHECK_SECONDS"`
|
|
247
255
|
# read an unset variable and `set -u` killed the script silently, mid-list.
|
|
@@ -657,14 +665,23 @@ fi
|
|
|
657
665
|
# assertion failure on a feature branch that a local run would have caught. The
|
|
658
666
|
# gate simply did not run it: `verify.sh` had no reference to Postgres in any
|
|
659
667
|
# form, so a required check that costs a full CI round trip had no local
|
|
660
|
-
# counterpart.
|
|
668
|
+
# counterpart.
|
|
661
669
|
#
|
|
662
670
|
# The budget is deliberately its own, and larger than pytest's. `pytest_is_fast`
|
|
663
671
|
# excludes a suite over 15s because a slow unit suite slows every push for a
|
|
664
672
|
# class of failure the fast checks mostly catch first; this lane is the opposite
|
|
665
|
-
# trade -- it is the ONLY local sight of a required check, and
|
|
666
|
-
# ~7-minute CI round trip pays for itself the first time it
|
|
667
|
-
|
|
673
|
+
# trade -- it is the ONLY local sight of a required check, and paying tens of
|
|
674
|
+
# seconds against a ~7-minute CI round trip pays for itself the first time it
|
|
675
|
+
# fires.
|
|
676
|
+
#
|
|
677
|
+
# Re-measured 2026-08-06 (#703): tabsii-platform's lane had grown from the
|
|
678
|
+
# 310 tests / ~28s this number was originally chosen against to 791-805 tests
|
|
679
|
+
# in one day, and six consecutive runs came in at 93, 101, 105, 107, 107, 108s
|
|
680
|
+
# -- against the 120s budget that left as little as 12s of margin, and
|
|
681
|
+
# shrinking with every test the suite gains. Doubled to 240s, the same
|
|
682
|
+
# doubling this file already suggests as the retry (below) -- comfortable
|
|
683
|
+
# headroom today, and re-measure this comment again once it stops being so.
|
|
684
|
+
PG_TEST_BUDGET_SECONDS="${BIFFO_VERIFY_PG_BUDGET:-240}"
|
|
668
685
|
PG_TEST_DSN="${BIFFO_TEST_PG_DSN:-${TABSII_TEST_PG_DSN:-}}"
|
|
669
686
|
|
|
670
687
|
# `.claude/worktrees` is excluded alongside `.worktrees`, and finding out why
|
|
@@ -702,7 +719,17 @@ pg_test_run() {
|
|
|
702
719
|
# tabsii-platform (#703) -- the same push succeeded on retry, unchanged.
|
|
703
720
|
#
|
|
704
721
|
# Same discipline as `wait-for-checks` and the dependency audits: "could not
|
|
705
|
-
# determine" must never wear the clothes of "found something wrong"
|
|
722
|
+
# determine" must never wear the clothes of "found something wrong" -- and
|
|
723
|
+
# since that discipline is a distinct EXIT STATUS everywhere else in this
|
|
724
|
+
# estate (2 = cannot tell, never a pass), not just a distinct message, this
|
|
725
|
+
# returns 2 rather than 1. The message alone was #1346's fix; it left the
|
|
726
|
+
# exit code lumped in with a real failure because `run_check`'s generic
|
|
727
|
+
# caller only sees pass/fail -- the pg-test call site below now reads this
|
|
728
|
+
# return value directly instead of going through it, precisely so a timeout
|
|
729
|
+
# can carry its own status without teaching every OTHER check (ruff, pyright,
|
|
730
|
+
# bandit, pytest, terraform fmt, ...) that exit 2 means something different
|
|
731
|
+
# from a real failure, when several of those tools already use 2 for their
|
|
732
|
+
# OWN internal errors.
|
|
706
733
|
if [ "$_pg_rc" -eq 124 ] || [ "$_pg_rc" -eq 137 ]; then
|
|
707
734
|
echo "TIMED OUT after ${_pg_elapsed}s (budget ${PG_TEST_BUDGET_SECONDS}s)."
|
|
708
735
|
echo ""
|
|
@@ -720,7 +747,7 @@ pg_test_run() {
|
|
|
720
747
|
echo "Partial output before the kill (NOT a result):"
|
|
721
748
|
tail -15 "$_out"
|
|
722
749
|
rm -f "$_out"
|
|
723
|
-
return
|
|
750
|
+
return 2
|
|
724
751
|
fi
|
|
725
752
|
|
|
726
753
|
if [ "$_pg_rc" -ne 0 ]; then
|
|
@@ -847,8 +874,42 @@ else
|
|
|
847
874
|
skip pg-test "no pyproject.toml above the Postgres modules"
|
|
848
875
|
else
|
|
849
876
|
_pg_rel=$(echo "$_pg_modules" | sed "s|^$_pg_dir/||" | tr '\n' ' ')
|
|
850
|
-
#
|
|
851
|
-
|
|
877
|
+
# Not a plain `run_check` call: `pg_test_run` returns THREE states (0 pass,
|
|
878
|
+
# 1 real failure, 2 timed out/inconclusive -- see its own comment), and
|
|
879
|
+
# `run_check` only ever sees pass/fail, so routing through it would collapse
|
|
880
|
+
# a timeout back into FAILED and reproduce the exact #703 defect this whole
|
|
881
|
+
# change exists to fix. This reads the return code directly instead.
|
|
882
|
+
if [ -n "$LIST" ]; then
|
|
883
|
+
# shellcheck disable=SC2086
|
|
884
|
+
echo pg_test_run "$_pg_dir" "$_pg_rel"
|
|
885
|
+
else
|
|
886
|
+
_pg_check_start=$(date +%s)
|
|
887
|
+
# shellcheck disable=SC2086
|
|
888
|
+
pg_test_run "$_pg_dir" "$_pg_rel" >"/tmp/biffo-verify-pg-check.$$" 2>&1
|
|
889
|
+
_pg_check_rc=$?
|
|
890
|
+
_pg_check_elapsed=$(($(date +%s) - _pg_check_start))
|
|
891
|
+
case "$_pg_check_rc" in
|
|
892
|
+
0)
|
|
893
|
+
PASSED="$PASSED pg-test"
|
|
894
|
+
printf ' \033[32mOK\033[0m %-16s %ss\n' "pg-test" "$_pg_check_elapsed"
|
|
895
|
+
;;
|
|
896
|
+
2)
|
|
897
|
+
# Cannot tell -- never a pass, and deliberately never FAILED either.
|
|
898
|
+
# pg_test_run's own output already explains why and how to re-run,
|
|
899
|
+
# so it is printed in full here rather than truncated the way a
|
|
900
|
+
# genuine failure's output is below.
|
|
901
|
+
INCONCLUSIVE="$INCONCLUSIVE pg-test"
|
|
902
|
+
printf ' \033[33mINCONCLUSIVE\033[0m %-16s %ss\n' "pg-test" "$_pg_check_elapsed"
|
|
903
|
+
sed 's/^/ /' "/tmp/biffo-verify-pg-check.$$"
|
|
904
|
+
;;
|
|
905
|
+
*)
|
|
906
|
+
FAILED="$FAILED pg-test"
|
|
907
|
+
printf ' \033[31mFAIL\033[0m %-16s %ss\n' "pg-test" "$_pg_check_elapsed"
|
|
908
|
+
sed 's/^/ /' "/tmp/biffo-verify-pg-check.$$" | tail -25
|
|
909
|
+
;;
|
|
910
|
+
esac
|
|
911
|
+
rm -f "/tmp/biffo-verify-pg-check.$$"
|
|
912
|
+
fi
|
|
852
913
|
fi
|
|
853
914
|
fi
|
|
854
915
|
|
|
@@ -1106,6 +1167,22 @@ if [ -n "$FAILED" ]; then
|
|
|
1106
1167
|
printf 'Most format failures are one command: pnpm run format\n\n'
|
|
1107
1168
|
exit 1
|
|
1108
1169
|
fi
|
|
1170
|
+
if [ -n "$INCONCLUSIVE" ]; then
|
|
1171
|
+
# A real FAILED above always wins this race -- it is the more actionable
|
|
1172
|
+
# fact and must not be buried under a lane that merely ran out of time. Only
|
|
1173
|
+
# once nothing genuinely failed does "could not tell" get to speak for the
|
|
1174
|
+
# whole run, and even then it is never a pass: same three-valued contract as
|
|
1175
|
+
# `wait-for-checks.sh` and `branch-health.sh` (0 green, 1 failed, 2 cannot
|
|
1176
|
+
# tell), and `cli/src/lib/packaged-script-command.ts` already promises every
|
|
1177
|
+
# script reached through `scripts/biffo.sh` passes its exit code through
|
|
1178
|
+
# unchanged -- so this is not a new contract, it is this file finally
|
|
1179
|
+
# keeping the one that already existed.
|
|
1180
|
+
printf '\033[33mverify inconclusive:\033[0m%s\n' "$INCONCLUSIVE"
|
|
1181
|
+
printf 'Not a failure -- the lane above could not produce a verdict (see its own\n'
|
|
1182
|
+
printf 'output, printed above, for why and what to do about it). Do not go\n'
|
|
1183
|
+
printf 'looking for a bug on this evidence; re-run once the cause is addressed.\n\n'
|
|
1184
|
+
exit 2
|
|
1185
|
+
fi
|
|
1109
1186
|
if [ -z "$PASSED" ]; then
|
|
1110
1187
|
# "Nothing applicable ran" is a different outcome from "checks passed", and
|
|
1111
1188
|
# conflating them is the exact failure this gate exists to remove -- the
|