Skip to content

Commit 160f4bb

Browse files
committed
feat: Handle CouchDB HTTP 403 on all routes
Since CouchDB v3.4.0, there has been a new "Lockout" feature, i.e., a rate limit on tuples (IP, login) after multiple authentication failures. It's highlighted in the release note: https://docs.couchdb.org/en/stable/whatsnew/3.4.html#id4 (see the second to last bullet point). As the following upstream discussion shows, this CouchDB feature adds a new case of HTTP 403 possible on all routes: apache/couchdb#5315 (comment) This commit catches the 403 on all routes. As some routes were already catching 403 for other reasons, the exception message on these routes is changed from their previous message to `"Access forbidden: {reason}"` where `reason` is either the `reason` returned by CouchDB in the JSON body of the answer, or if it doesn't exist, by the `message` of aiohttp ClientResponseError. I manually tested a non-stream route with `await couchdb.info()`, it returns the following: ``` > await couchdb.info() ... aiocouch.exception.UnauthorizedError: Invalid credentials > await couchdb.info() # <=== Lockout ... aiocouch.exception.ForbiddenError: Access forbidden: Account is temporarily locked due to multiple authentication failures ``` Closes metricq#55
1 parent 6a1aa93 commit 160f4bb

File tree

3 files changed

+85
-44
lines changed

3 files changed

+85
-44
lines changed

aiocouch/exception.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,16 @@ class ExpectationFailedError(ValueError):
112112
pass
113113

114114

115+
class ClientResponseError(aiohttp.ClientResponseError):
116+
def __init__(self, reason, *args: Any, **kwargs: Any):
117+
super().__init__(*args, **kwargs)
118+
self.reason = reason
119+
120+
115121
def raise_for_endpoint(
116122
endpoint: Endpoint,
117123
message: str,
118-
exception: aiohttp.ClientResponseError,
124+
exception: ClientResponseError,
119125
exception_type: Optional[Type[Exception]] = None,
120126
) -> NoReturn:
121127
if exception_type is None:
@@ -143,6 +149,9 @@ def raise_for_endpoint(
143149

144150
message_input = {}
145151

152+
with suppress(AttributeError):
153+
message_input["reason"] = exception.reason
154+
message_input["reason"] = message_input.get("reason", exception.message)
146155
with suppress(AttributeError):
147156
message_input["id"] = endpoint.id
148157
message_input["endpoint"] = endpoint.endpoint
@@ -165,7 +174,7 @@ def decorator_raises(func: FuncT) -> FuncT:
165174
async def wrapper(endpoint: Endpoint, *args: Any, **kwargs: Any) -> Any:
166175
try:
167176
return await func(endpoint, *args, **kwargs)
168-
except aiohttp.ClientResponseError as exception:
177+
except ClientResponseError as exception:
169178
if status == exception.status:
170179
raise_for_endpoint(endpoint, message, exception, exception_type)
171180
raise exception
@@ -186,7 +195,7 @@ async def wrapper(
186195
try:
187196
async for data in func(endpoint, *args, **kwargs):
188197
yield data
189-
except aiohttp.ClientResponseError as exception:
198+
except ClientResponseError as exception:
190199
if status == exception.status:
191200
raise_for_endpoint(endpoint, message, exception, exception_type)
192201
raise exception

aiocouch/remote.py

+60-24
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
import aiohttp
3838

3939
from . import database, document
40-
from .exception import NotFoundError, generator_raises, raises
40+
from .exception import ClientResponseError, NotFoundError, generator_raises, raises
4141
from .typing import JsonDict
4242

4343

@@ -160,7 +160,21 @@ async def _request(
160160
async with self._http_session.request(
161161
method, url=f"{self._server}{path}", **kwargs
162162
) as resp:
163-
resp.raise_for_status()
163+
if not resp.ok:
164+
reason = None
165+
with suppress(Exception):
166+
reason = (await resp.json())["reason"]
167+
# Copied from aiohttp v3.9.5 raise_for_status():
168+
assert resp.reason is not None
169+
resp.release()
170+
raise ClientResponseError(
171+
reason,
172+
resp.request_info,
173+
resp.history,
174+
status=resp.status,
175+
message=resp.reason,
176+
headers=resp.headers,
177+
)
164178
return (
165179
HTTPResponse(resp),
166180
await resp.json() if return_json else await resp.read(),
@@ -179,14 +193,29 @@ async def _streamed_request(
179193
async with self._http_session.request(
180194
method, url=f"{self._server}{path}", **kwargs
181195
) as resp:
182-
resp.raise_for_status()
196+
if not resp.ok:
197+
reason = None
198+
with suppress(Exception):
199+
reason = (await resp.json())["reason"]
200+
# Copied from aiohttp v3.9.5 raise_for_status():
201+
assert resp.reason is not None
202+
resp.release()
203+
raise ClientResponseError(
204+
reason,
205+
resp.request_info,
206+
resp.history,
207+
status=resp.status,
208+
message=resp.reason,
209+
headers=resp.headers,
210+
)
183211

184212
async for line in resp.content:
185213
# this should only happen for empty lines
186214
with suppress(json.JSONDecodeError):
187215
yield json.loads(line)
188216

189217
@raises(401, "Invalid credentials")
218+
@raises(403, "Access forbidden: {reason}")
190219
async def _all_dbs(self, **params: Any) -> List[str]:
191220
_, json = await self._get("/_all_dbs", params)
192221
assert not isinstance(json, bytes)
@@ -203,12 +232,14 @@ async def close(self) -> None:
203232
await asyncio.sleep(0.250 if has_ssl_conn else 0)
204233

205234
@raises(401, "Invalid credentials")
235+
@raises(403, "Access forbidden: {reason}")
206236
async def _info(self) -> JsonDict:
207237
_, json = await self._get("/")
208238
assert not isinstance(json, bytes)
209239
return json
210240

211241
@raises(401, "Authentication failed, check provided credentials.")
242+
@raises(403, "Access forbidden: {reason}")
212243
async def _check_session(self) -> RequestResult:
213244
return await self._get("/_session")
214245

@@ -223,19 +254,19 @@ def endpoint(self) -> str:
223254
return f"/{_quote_id(self.id)}"
224255

225256
@raises(401, "Invalid credentials")
226-
@raises(403, "Read permission required")
257+
@raises(403, "Access forbidden: {reason}")
227258
async def _exists(self) -> bool:
228259
try:
229260
await self._remote._head(self.endpoint)
230261
return True
231-
except aiohttp.ClientResponseError as e:
262+
except ClientResponseError as e:
232263
if e.status == 404:
233264
return False
234265
else:
235266
raise e
236267

237268
@raises(401, "Invalid credentials")
238-
@raises(403, "Read permission required")
269+
@raises(403, "Access forbidden: {reason}")
239270
@raises(404, "Requested database not found ({id})")
240271
async def _get(self) -> JsonDict:
241272
_, json = await self._remote._get(self.endpoint)
@@ -244,6 +275,7 @@ async def _get(self) -> JsonDict:
244275

245276
@raises(400, "Invalid database name")
246277
@raises(401, "CouchDB Server Administrator privileges required")
278+
@raises(403, "Access forbidden: {reason}")
247279
@raises(412, "Database already exists")
248280
async def _put(self, **params: Any) -> JsonDict:
249281
_, json = await self._remote._put(self.endpoint, params=params)
@@ -252,13 +284,14 @@ async def _put(self, **params: Any) -> JsonDict:
252284

253285
@raises(400, "Invalid database name or forgotten document id by accident")
254286
@raises(401, "CouchDB Server Administrator privileges required")
287+
@raises(403, "Access forbidden: {reason}")
255288
@raises(404, "Database doesn't exist or invalid database name ({id})")
256289
async def _delete(self) -> None:
257290
await self._remote._delete(self.endpoint)
258291

259292
@raises(400, "The request provided invalid JSON data or invalid query parameter")
260293
@raises(401, "Read permission required")
261-
@raises(403, "Read permission required")
294+
@raises(403, "Access forbidden: {reason}")
262295
@raises(404, "Invalid database name")
263296
@raises(415, "Bad Content-Type value")
264297
async def _bulk_get(self, docs: List[str], **params: Any) -> JsonDict:
@@ -270,7 +303,7 @@ async def _bulk_get(self, docs: List[str], **params: Any) -> JsonDict:
270303

271304
@raises(400, "The request provided invalid JSON data")
272305
@raises(401, "Invalid credentials")
273-
@raises(403, "Write permission required")
306+
@raises(403, "Access forbidden: {reason}")
274307
@raises(417, "At least one document was rejected by the validation function")
275308
async def _bulk_docs(self, docs: List[JsonDict], **data: Any) -> JsonDict:
276309
data["docs"] = docs
@@ -280,7 +313,7 @@ async def _bulk_docs(self, docs: List[JsonDict], **data: Any) -> JsonDict:
280313

281314
@raises(400, "Invalid request")
282315
@raises(401, "Read privilege required for document '{id}'")
283-
@raises(403, "Read permission required")
316+
@raises(403, "Access forbidden: {reason}")
284317
@raises(500, "Query execution failed", RuntimeError)
285318
async def _find(self, selector: Any, **data: Any) -> JsonDict:
286319
data["selector"] = selector
@@ -290,6 +323,7 @@ async def _find(self, selector: Any, **data: Any) -> JsonDict:
290323

291324
@raises(400, "Invalid request")
292325
@raises(401, "Admin permission required")
326+
@raises(403, "Access forbidden: {reason}")
293327
@raises(404, "Database not found")
294328
@raises(500, "Execution error")
295329
async def _index(self, index: JsonDict, **data: Any) -> JsonDict:
@@ -299,20 +333,21 @@ async def _index(self, index: JsonDict, **data: Any) -> JsonDict:
299333
return json
300334

301335
@raises(401, "Invalid credentials")
302-
@raises(403, "Permission required")
336+
@raises(403, "Access forbidden: {reason}")
303337
async def _get_security(self) -> JsonDict:
304338
_, json = await self._remote._get(f"{self.endpoint}/_security")
305339
assert not isinstance(json, bytes)
306340
return json
307341

308342
@raises(401, "Invalid credentials")
309-
@raises(403, "Permission required")
343+
@raises(403, "Access forbidden: {reason}")
310344
async def _put_security(self, doc: JsonDict) -> JsonDict:
311345
_, json = await self._remote._put(f"{self.endpoint}/_security", doc)
312346
assert not isinstance(json, bytes)
313347
return json
314348

315349
@generator_raises(400, "Invalid request")
350+
@generator_raises(403, "Access forbidden: {reason}")
316351
async def _changes(self, **params: Any) -> AsyncGenerator[JsonDict, None]:
317352
if "feed" in params and params["feed"] == "continuous":
318353
params.setdefault("heartbeat", True)
@@ -329,6 +364,7 @@ async def _changes(self, **params: Any) -> AsyncGenerator[JsonDict, None]:
329364
yield result
330365

331366
@raises(400, "Invalid database or JSON payload")
367+
@raises(403, "Access forbidden: {reason}")
332368
@raises(415, "Bad Content-Type header value")
333369
@raises(500, "Internal server error or timeout")
334370
async def _purge(self, docs: JsonDict, **params: Any) -> JsonDict:
@@ -350,13 +386,13 @@ def endpoint(self) -> str:
350386
return f"{self._database.endpoint}/{_quote_id(self.id)}"
351387

352388
@raises(401, "Read privilege required for document '{id}'")
353-
@raises(403, "Read privilege required for document '{id}'")
389+
@raises(403, "Access forbidden: {reason}")
354390
@raises(404, "Document {id} was not found")
355391
async def _head(self) -> None:
356392
await self._database._remote._head(self.endpoint)
357393

358394
@raises(401, "Read privilege required for document '{id}'")
359-
@raises(403, "Read privilege required for document '{id}'")
395+
@raises(403, "Access forbidden: {reason}")
360396
@raises(404, "Document {id} was not found")
361397
async def _info(self) -> JsonDict:
362398
response, _ = await self._database._remote._head(self.endpoint)
@@ -376,7 +412,7 @@ async def _exists(self) -> bool:
376412

377413
@raises(400, "The format of the request or revision was invalid")
378414
@raises(401, "Read privilege required for document '{id}'")
379-
@raises(403, "Read privilege required for document '{id}'")
415+
@raises(403, "Access forbidden: {reason}")
380416
@raises(404, "Document {id} was not found")
381417
async def _get(self, **params: Any) -> JsonDict:
382418
_, json = await self._database._remote._get(self.endpoint, params)
@@ -385,7 +421,7 @@ async def _get(self, **params: Any) -> JsonDict:
385421

386422
@raises(400, "The format of the request or revision was invalid")
387423
@raises(401, "Write privilege required for document '{id}'")
388-
@raises(403, "Write privilege required for document '{id}'")
424+
@raises(403, "Access forbidden: {reason}")
389425
@raises(404, "Specified database or document ID doesn't exists ({endpoint})")
390426
@raises(
391427
409,
@@ -401,7 +437,7 @@ async def _put(
401437

402438
@raises(400, "Invalid request body or parameters")
403439
@raises(401, "Write privilege required for document '{id}'")
404-
@raises(403, "Write privilege required for document '{id}'")
440+
@raises(403, "Access forbidden: {reason}")
405441
@raises(404, "Specified database or document ID doesn't exists ({endpoint})")
406442
@raises(
407443
409, "Specified revision ({rev}) is not the latest for target document '{id}'"
@@ -414,7 +450,7 @@ async def _delete(self, rev: str, **params: Any) -> Tuple[HTTPResponse, JsonDict
414450

415451
@raises(400, "Invalid request body or parameters")
416452
@raises(401, "Read or write privileges required")
417-
@raises(403, "Read or write privileges required")
453+
@raises(403, "Access forbidden: {reason}")
418454
@raises(
419455
404, "Specified database, document ID or revision doesn't exists ({endpoint})"
420456
)
@@ -444,23 +480,23 @@ def endpoint(self) -> str:
444480
return f"{self._document.endpoint}/{_quote_id(self.id)}"
445481

446482
@raises(401, "Read privilege required for document '{document_id}'")
447-
@raises(403, "Read privilege required for document '{document_id}'")
483+
@raises(403, "Access forbidden: {reason}")
448484
async def _exists(self) -> bool:
449485
try:
450486
response, _ = await self._document._database._remote._head(
451487
self.endpoint, return_json=False
452488
)
453489
self.content_type = response.headers["Content-Type"]
454490
return True
455-
except aiohttp.ClientResponseError as e:
491+
except ClientResponseError as e:
456492
if e.status == 404:
457493
return False
458494
else:
459495
raise e
460496

461497
@raises(400, "Invalid request parameters")
462498
@raises(401, "Read privilege required for document '{document_id}'")
463-
@raises(403, "Read privilege required for document '{document_id}'")
499+
@raises(403, "Access forbidden: {reason}")
464500
@raises(404, "Document '{document_id}' or attachment '{id}' doesn't exists")
465501
async def _get(self, **params: Any) -> bytes:
466502
response, data = await self._document._database._remote._get_bytes(
@@ -472,7 +508,7 @@ async def _get(self, **params: Any) -> bytes:
472508

473509
@raises(400, "Invalid request body or parameters")
474510
@raises(401, "Write privilege required for document '{document_id}'")
475-
@raises(403, "Write privilege required for document '{document_id}'")
511+
@raises(403, "Access forbidden: {reason}")
476512
@raises(404, "Document '{document_id}' doesn't exists")
477513
@raises(
478514
409, "Specified revision {document_rev} is not the latest for target document"
@@ -490,7 +526,7 @@ async def _put(
490526

491527
@raises(400, "Invalid request body or parameters")
492528
@raises(401, "Write privilege required for document '{document_id}'")
493-
@raises(403, "Write privilege required for document '{document_id}'")
529+
@raises(403, "Access forbidden: {reason}")
494530
@raises(404, "Specified database or document ID doesn't exists ({endpoint})")
495531
@raises(
496532
409, "Specified revision {document_rev} is not the latest for target document"
@@ -519,7 +555,7 @@ def endpoint(self) -> str:
519555

520556
@raises(400, "Invalid request")
521557
@raises(401, "Read privileges required")
522-
@raises(403, "Read privileges required")
558+
@raises(403, "Access forbidden: {reason}")
523559
@raises(404, "Specified database, design document or view is missing")
524560
async def _get(self, **params: Any) -> JsonDict:
525561
_, json = await self._database._remote._get(self.endpoint, params)
@@ -528,7 +564,7 @@ async def _get(self, **params: Any) -> JsonDict:
528564

529565
@raises(400, "Invalid request")
530566
@raises(401, "Write privileges required")
531-
@raises(403, "Write privileges required")
567+
@raises(403, "Access forbidden: {reason}")
532568
@raises(404, "Specified database, design document or view is missing")
533569
async def _post(self, keys: List[str], **params: Any) -> JsonDict:
534570
_, json = await self._database._remote._post(

0 commit comments

Comments
 (0)