File size: 10,681 Bytes
00f9c01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67c9044
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00f9c01
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
"""Unit tests (no DB, no network) for how Gemini errors are surfaced (28 Sep 2026):

  * a 429 that means "budget spent" (RESOURCE_EXHAUSTED / monthly spending
    cap) fails at once — no 2/4/8 s retries — as GeminiHTTPError with
    .quota == True;
  * no error message ever carries the request URL's `?key=` (requests'
    HTTPError and urllib3's connection errors both quote the keyed URL,
    which is how the live API key reached the public event stream);
  * an ordinary 429 / 5xx still retries and then fails without the key;
  * batch_ingest turns the exception into a `gemini_error:` result with
    quota set, key-free;
  * agdb.redact() and its SQL twin scrub key / api_key / token parameters
    and bearer tokens, and leave already-redacted text alone.

  python test_gemini_errors.py
"""
import json
import os
import sys

os.environ["AGENT_USE_NTRS"] = "0"

import requests

import extraction
from agent import agdb

fails = []


def check(name, cond):
    print(("PASS " if cond else "FAIL ") + name)
    if not cond:
        fails.append(name)


KEYED = "https://generativelanguage.googleapis.com/v1beta/models/x:generateContent?key=AQ.SECRETSECRETSECRET"


class FakeResp:
    def __init__(self, status, body=None, headers=None, reason="Too Many Requests"):
        self.status_code = status
        self._body = body
        self.headers = headers or {}
        self.reason = reason
        self.text = json.dumps(body) if body is not None else ""

    def json(self):
        if self._body is None:
            raise ValueError("no json")
        return self._body


CALLS: list = []
SLEEPS: list = []
SCRIPT: list = []


def fake_request(method, url, timeout=None, **kw):
    CALLS.append(url)
    nxt = SCRIPT.pop(0) if SCRIPT else FakeResp(200, {})
    if isinstance(nxt, Exception):
        raise nxt
    return nxt


extraction.requests.request = fake_request
_sleep = lambda s: SLEEPS.append(s)

# --- 1. spending cap: fail fast, quota flag, no key --------------------------------
cap_body = {"error": {"code": 429, "status": "RESOURCE_EXHAUSTED",
                      "message": "Your project has exceeded its monthly spending cap. Please go to AI Studio at https://ai.studio/spend to manage your project spend cap."}}
SCRIPT[:] = [FakeResp(429, cap_body)]
CALLS.clear(); SLEEPS.clear()
try:
    extraction._request_with_retry("POST", KEYED, json={}, _sleep=_sleep)
    check("spending cap raises", False)
except extraction.GeminiHTTPError as exc:
    check("spending cap raises GeminiHTTPError at once (1 call, no sleep)", len(CALLS) == 1 and SLEEPS == [])
    check("…with quota == True and the API's status", exc.quota and exc.status == "RESOURCE_EXHAUSTED")
    check("…message carries the API's detail, not the keyed URL",
          "spending cap" in str(exc) and "key=" not in str(exc) and "SECRET" not in str(exc))
    check("…is still a requests.RequestException (batch_ingest's except clause)",
          isinstance(exc, requests.RequestException))

# --- 2. ordinary 429 (rate limit) still retries, then fails without the key ----------
SCRIPT[:] = [FakeResp(429, {"error": {"code": 429, "status": "UNAVAILABLE", "message": "slow down"}})] * 4
CALLS.clear(); SLEEPS.clear()
try:
    extraction._request_with_retry("POST", KEYED, json={}, _sleep=_sleep)
    check("plain 429 raises after retries", False)
except extraction.GeminiHTTPError as exc:
    check("plain 429: retried MAX_RETRIES times with backoff", len(CALLS) == extraction.MAX_RETRIES + 1 and len(SLEEPS) == extraction.MAX_RETRIES)
    check("plain 429: not a quota refusal", not exc.quota)
    check("plain 429: message key-free", "key=" not in str(exc) and "SECRET" not in str(exc))

# --- 3. 5xx then 200 recovers; 400 fails once, key-free ------------------------------
SCRIPT[:] = [FakeResp(503, None, reason="Service Unavailable"), FakeResp(200, {"ok": 1})]
CALLS.clear(); SLEEPS.clear()
r = extraction._request_with_retry("POST", KEYED, json={}, _sleep=_sleep)
check("503 then 200: returns the 200 after one retry", r is not None and r.status_code == 200 and len(CALLS) == 2)
SCRIPT[:] = [FakeResp(400, {"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "bad schema"}}, reason="Bad Request")]
CALLS.clear(); SLEEPS.clear()
try:
    extraction._request_with_retry("POST", KEYED, json={}, _sleep=_sleep)
    check("400 raises", False)
except extraction.GeminiHTTPError as exc:
    check("400: fails once, names the detail, key-free",
          len(CALLS) == 1 and "INVALID_ARGUMENT" in str(exc) and "SECRET" not in str(exc))

# --- 4. connection error quoting the keyed URL is re-raised clean ---------------------
conn_exc = requests.ConnectionError(f"HTTPSConnectionPool(host='g', port=443): Max retries exceeded with url: /v1beta/models/x:generateContent?key=AQ.SECRETSECRETSECRET (Caused by X)")
SCRIPT[:] = [conn_exc] * (extraction.MAX_RETRIES + 1)
CALLS.clear(); SLEEPS.clear()
try:
    extraction._request_with_retry("POST", KEYED, json={}, _sleep=_sleep)
    check("connection error raises", False)
except requests.RequestException as exc:
    check("connection error: retried, then re-raised without the key",
          len(CALLS) == extraction.MAX_RETRIES + 1 and "SECRET" not in str(exc) and "<redacted>" in str(exc))

# --- 5. batch_ingest: gemini_error result, quota flag, key-free ---------------------------
import batch_ingest
from pathlib import Path
import tempfile

def raising_extract(pdf_bytes, filename, api_key):
    raise extraction.GeminiHTTPError("429 RESOURCE_EXHAUSTED from Gemini: Your project has exceeded its monthly spending cap.",
                                     FakeResp(429, cap_body), "RESOURCE_EXHAUSTED", "monthly spending cap")

def leaking_extract(pdf_bytes, filename, api_key):
    raise requests.HTTPError(f"429 Client Error: Too Many Requests for url: {KEYED}")

class _DB:      # minimal backend: nothing is seen, nothing is written
    @staticmethod
    def seen_sha1(conn, sha1): return False

tmp = Path(tempfile.mkdtemp()) / "x.pdf"
tmp.write_bytes(b"%PDF-1.4 fake")
batch_ingest.extract_from_pdf = raising_extract
res = batch_ingest.process_pdf(tmp, None, "k", db=_DB)
check("process_pdf: quota refusal → gemini_error result with quota=True",
      (res.error or "").startswith("gemini_error") and res.quota is True and "spending cap" in res.error)
batch_ingest.extract_from_pdf = leaking_extract
res = batch_ingest.process_pdf(tmp, None, "k", db=_DB)
check("process_pdf: a raw HTTPError with the keyed URL is stored key-free",
      (res.error or "").startswith("gemini_error") and "SECRET" not in res.error and "<redacted>" in res.error
      and res.quota is False)

# --- 5b. thinking option: applied to every call; 400 on it → retried without, then off ----
extraction._THINKING_UNSUPPORTED = False
extraction.THINKING = "low"
check("thinking_config: low → thinkingLevel", extraction.thinking_config() == {"thinkingLevel": "low"})
check("thinking_config: off → budget 0", extraction.thinking_config("off") == {"thinkingBudget": 0})
check("thinking_config: 2048 → budget", extraction.thinking_config("2048") == {"thinkingBudget": 2048})
check("thinking_config: dynamic → nothing sent", extraction.thinking_config("dynamic") is None)
check("thinking_config: garbage → nothing sent", extraction.thinking_config("lots") is None)
SENT: list = []
def fake_request2(method, url, timeout=None, **kw):
    import copy
    SENT.append(copy.deepcopy(kw.get("json")))   # the payload dict is mutated on retry
    nxt = SCRIPT.pop(0) if SCRIPT else FakeResp(200, {})
    if isinstance(nxt, Exception):
        raise nxt
    return nxt
extraction.requests.request = fake_request2
SCRIPT[:] = [FakeResp(200, {"ok": 1})]; SENT.clear()
extraction.gemini_request(KEYED, {"contents": [], "generationConfig": {"temperature": 0}}, _sleep=_sleep)
check("gemini_request adds thinkingConfig to the payload",
      SENT and SENT[0]["generationConfig"].get("thinkingConfig") == {"thinkingLevel": "low"})
# the model rejects the option
SCRIPT[:] = [FakeResp(400, {"error": {"code": 400, "status": "INVALID_ARGUMENT",
                                      "message": "Invalid value at 'generation_config.thinking_config.thinking_level'"}},
                      reason="Bad Request"),
             FakeResp(200, {"ok": 2})]
SENT.clear()
r = extraction.gemini_request(KEYED, {"contents": [], "generationConfig": {"temperature": 0}}, _sleep=_sleep)
check("400 naming thinking → retried once without thinkingConfig, 200 returned",
      r is not None and r.status_code == 200 and len(SENT) == 2
      and "thinkingConfig" in SENT[0]["generationConfig"] and "thinkingConfig" not in SENT[1]["generationConfig"])
check("…and the option is off for the rest of the process", extraction._THINKING_UNSUPPORTED is True
      and extraction.thinking_config() is None)
SCRIPT[:] = [FakeResp(200, {"ok": 3})]; SENT.clear()
extraction.gemini_request(KEYED, {"contents": [], "generationConfig": {}}, _sleep=_sleep)
check("later calls send no thinkingConfig", "thinkingConfig" not in SENT[0]["generationConfig"])
extraction._THINKING_UNSUPPORTED = False
# an unrelated 400 is not retried
SCRIPT[:] = [FakeResp(400, {"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "bad schema"}}, reason="Bad Request")]
SENT.clear()
try:
    extraction.gemini_request(KEYED, {"contents": [], "generationConfig": {}}, _sleep=_sleep)
    check("unrelated 400 raises", False)
except extraction.GeminiHTTPError:
    check("unrelated 400: raised once, thinking option kept", len(SENT) == 1 and extraction._THINKING_UNSUPPORTED is False)
extraction.requests.request = fake_request

# --- 6. agdb.redact ------------------------------------------------------------------------
check("redact: ?key=", agdb.redact("x for url: https://g/v1?key=AQ.abc-def") == "x for url: https://g/v1?key=<redacted>")
check("redact: &api_key= keeps the rest of the query", agdb.redact("/works?search=q&api_key=oa123&per-page=5") == "/works?search=q&api_key=<redacted>&per-page=5")
check("redact: bearer token", agdb.redact("Authorization: Bearer abcdefghijklmnop") == "Authorization: Bearer <redacted>")
check("redact: case-insensitive, JSON-embedded", agdb.redact('{"e":"…?Key=SECRET"}') == '{"e":"…?Key=<redacted>"}')
check("redact: already redacted is left alone", agdb.redact("u?key=<redacted>&a=1") == "u?key=<redacted>&a=1")
check("redact: plain text untouched", agdb.redact("the key to the frontier") == "the key to the frontier")
check("redact: non-strings pass through", agdb.redact(None) is None and agdb.redact(5) == 5)

print()
print("all tests passed" if not fails else f"{len(fails)} FAILED: {fails}")
sys.exit(1 if fails else 0)