guard.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. ########################################################################
  2. # Searx-Qt - Lightweight desktop application for Searx.
  3. # Copyright (C) 2020-2022 CYBERDEViL
  4. #
  5. # This file is part of Searx-Qt.
  6. #
  7. # Searx-Qt is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # Searx-Qt is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. #
  20. ########################################################################
  21. """ Conditionally place failing instances on the blacklist or on a timeout (the
  22. temporary blacklist).
  23. """
  24. from copy import deepcopy
  25. from searxqt.core.http import ErrorType
  26. from searxqt.utils.time import nowInMinutes
  27. from searxqt.translations import _
  28. class Condition:
  29. """ This describes the condition of a rule to trigger.
  30. """
  31. def __init__(
  32. self, errorType, amount=0,
  33. period=60, status=None
  34. ):
  35. """
  36. @param errorType: Error type to meet this conditiion.
  37. @type errorType: ErrorType
  38. @param amount: Amount of failed searches (in a row) of this errorType
  39. to meet this condition.
  40. @type amount: uint
  41. @param period: The last x minutes where the fails have to occur in.
  42. Set to 0 for forever.
  43. @type period: uint
  44. @param status: Status code of the failed search request. Set to None
  45. when irrelevant.
  46. @type status: uint or None
  47. """
  48. self.__errorType = errorType
  49. self.__amount = amount
  50. self.__period = period
  51. self.__status = status
  52. @property
  53. def errorType(self):
  54. return self.__errorType
  55. @errorType.setter
  56. def errorType(self, errorType):
  57. self.__errorType = errorType
  58. @property
  59. def amount(self):
  60. return self.__amount
  61. @amount.setter
  62. def amount(self, amount):
  63. self.__amount = amount
  64. @property
  65. def period(self):
  66. return self.__period
  67. @period.setter
  68. def period(self, period):
  69. self.__period = period
  70. @property
  71. def status(self):
  72. return self.__status
  73. @status.setter
  74. def status(self, status):
  75. self.__status = status
  76. def serialize(self):
  77. return {
  78. "errorType": self.__errorType,
  79. "amount": self.__amount,
  80. "period": self.__period,
  81. "status": self.__status
  82. }
  83. def deserialize(self, data):
  84. self.__errorType = data.get('errorType', ErrorType.Other)
  85. self.__amount = data.get('amount', 0)
  86. self.__period = data.get('period', 0)
  87. self.__status = data.get('status', None)
  88. def evaluate(self, instanceLog):
  89. # First check if our errorType is present in the instanceLog
  90. if self.__errorType not in instanceLog:
  91. return False
  92. amountPeroidCount = 0
  93. startTime = nowInMinutes() - self.__period
  94. for logTime, statusCode, errorMsg in instanceLog[self.__errorType]:
  95. # Response status code
  96. if self.__status is not None:
  97. # This rule has defined a specific status code.
  98. if self.__status != statusCode:
  99. # Don't count incidents that have different status code.
  100. continue
  101. # Count occurrences in time-frame.
  102. if self.__period:
  103. if logTime - startTime > 0:
  104. amountPeroidCount += 1
  105. elif self.__amount:
  106. amountPeroidCount += 1
  107. else:
  108. break
  109. return True if amountPeroidCount >= self.__amount else False
  110. class ConsequenceType:
  111. Blacklist = 0
  112. Timeout = 1
  113. ConsequenceTypeStr = {
  114. ConsequenceType.Blacklist: _("Blacklist"),
  115. ConsequenceType.Timeout: _("Timeout")
  116. }
  117. class Consequence:
  118. """ This describes the consequence for a instance of a rule that has it's
  119. condition met.
  120. """
  121. def __init__(self, type_=ConsequenceType.Timeout, duration=0):
  122. """
  123. @param consequence: Put the failing instance on the blacklist or
  124. timeout? See class Consequences
  125. @type consequence: uint
  126. @param duration: Only used when consequence == Consequences.Timeout. It
  127. is the duration in minutes the instance should be on
  128. timeout.
  129. @type duration: uint
  130. """
  131. self.__type = type_
  132. self.__duration = duration
  133. @property
  134. def type(self):
  135. return self.__type
  136. @type.setter
  137. def type(self, type_):
  138. self.__type = type_
  139. @property
  140. def duration(self):
  141. return self.__duration
  142. @duration.setter
  143. def duration(self, duration):
  144. self.__duration = duration
  145. def serialize(self):
  146. return {
  147. "type": self.__type,
  148. "duration": self.__duration
  149. }
  150. def deserialize(self, data):
  151. self.__type = data.get('type', ConsequenceType.Timeout)
  152. self.__duration = data.get('duration', 0)
  153. class Rule:
  154. def __init__(
  155. self,
  156. errorType=ErrorType.Other,
  157. consequenceType=ConsequenceType.Timeout,
  158. amount=0,
  159. period=0,
  160. duration=0,
  161. status=None
  162. ):
  163. self.__condition = Condition(
  164. errorType,
  165. amount=amount,
  166. period=period,
  167. status=status
  168. )
  169. self.__consequence = Consequence(consequenceType, duration)
  170. @property
  171. def condition(self):
  172. return self.__condition
  173. @property
  174. def consequence(self):
  175. return self.__consequence
  176. def meetsConditions(self, instanceLog):
  177. return self.__condition.evaluate(instanceLog)
  178. def serialize(self):
  179. return {
  180. "condition": self.__condition.serialize(),
  181. "consequence": self.__consequence.serialize()
  182. }
  183. def deserialize(self, data):
  184. self.__condition.deserialize(data.get('condition', {}))
  185. self.__consequence.deserialize(data.get('consequence', {}))
  186. class Guard:
  187. """ Guard can have rules (condition and consequence) for failing searches.
  188. When enabled it logs failing instances so from that log can be evaluated
  189. (by the rules) whether the failing instance should be places on a timeout
  190. or the blacklist (the consequence).
  191. Guard itself doesn't handle the consequence itself but the consequence can
  192. be requested by other objects to handle.
  193. When enabled, each search response should be reported by calling
  194. `reportSearchResult()` for Guard to properly handle.
  195. The fail log of a instance will be cleared when a valid search response is
  196. reported to Guard, so instances have to fail in a row!
  197. The order of rules does matter! Rules with a lower index have higher
  198. priority.
  199. """
  200. # Defaults
  201. Enabled = False
  202. StoreLog = False
  203. LogPeriod = 7 # In days
  204. def __init__(self):
  205. self.__enabled = Guard.Enabled
  206. # Store logs on disk when True (bool).
  207. self.__storeLog = Guard.StoreLog
  208. # Max log period in days (uint) from now.
  209. self.__logPeriod = Guard.LogPeriod
  210. self.__log = {}
  211. self.__rules = []
  212. def reset(self):
  213. """ Reset Guard to default values, this will also clear the log and
  214. made rules.
  215. """
  216. self.__enabled = Guard.Enabled
  217. self.__storeLog = Guard.StoreLog
  218. self.clear()
  219. def clear(self):
  220. """ Clear the log and rules.
  221. """
  222. self.clearLog()
  223. self.__rules.clear()
  224. def clearLog(self):
  225. """ Clear the whole log.
  226. """
  227. self.__log.clear()
  228. def clearInstanceLog(self, instanceUrl):
  229. """ Clear the log of a specific instance by url.
  230. @param instanceUrl: Url of the instance
  231. @type instanceUrl: str
  232. """
  233. if instanceUrl in self.__log:
  234. del self.__log[instanceUrl]
  235. def doesStoreLog(self):
  236. """
  237. @return: Whether the log is stored on disk or not.
  238. @rtype: bool
  239. """
  240. return self.__storeLog
  241. def setStoreLog(self, state):
  242. """
  243. @param state: Store log on disk?
  244. @type state: bool
  245. """
  246. self.__storeLog = state
  247. def maxLogPeriod(self):
  248. """ Maximum log period in days.
  249. """
  250. return self.__logPeriod
  251. def setMaxLogPeriod(self, days):
  252. """ Set the maximum log period in days. This is only relevant when
  253. doesStoreLog() returns True.
  254. @param days: For how many days should the logs be stored.
  255. @type days: uint
  256. """
  257. self.__logPeriod = days
  258. def isEnabled(self):
  259. """ Returns whether Guard is enabled or not.
  260. """
  261. return self.__enabled
  262. def setEnabled(self, state):
  263. """ Enable/disable Guard.
  264. @param state: Enabled or disabled state of Guard as a bool.
  265. @type state: bool
  266. """
  267. self.__enabled = state
  268. def rules(self):
  269. """ Returns a list with Rules
  270. @rtype: list
  271. """
  272. return self.__rules
  273. def log(self):
  274. """ Returns the log
  275. @rtype: dict
  276. """
  277. return self.__log
  278. def addRule(
  279. self,
  280. errorType,
  281. consequenceType,
  282. amount=0,
  283. period=0,
  284. duration=0,
  285. status=None
  286. ):
  287. """ Add a new rule.
  288. A rule will only trigger when searches of a specific instance fails
  289. `amount` times in the last `period`, the fails have to be from the
  290. same `errorType`.
  291. The `consequenceType` defines what to do with the instance when this
  292. rule gets triggered, for now it can be put on a timeout or on the
  293. blacklist. When the type is
  294. `searxqt.core.guard.ConsequenceType.Timeout` a `duration` in minutes
  295. can be given to define for how long the timeout should last. When the
  296. `duration` is left to `0` with `Timeout` type it will be put on the
  297. timeout list until restart/switch-profile or manual removal.
  298. @param errorType: The search error type for this rule to trigger.
  299. @type errorType: searxqt.core.http.ErrorType
  300. @param consequenceType: The action that will be taken on trigger.
  301. @type consequenceType: searxqt.core.guard.ConsequenceType
  302. @param amount: The amount of failures with the set errorType of this
  303. rule that have to occur in a row to trigger this rule.
  304. @type amount: uint
  305. @param period: Period in minutes where the fails have to occur in.
  306. `0` is always.
  307. @type period: uint
  308. """
  309. rule = Rule(
  310. errorType,
  311. consequenceType,
  312. amount=amount,
  313. period=period,
  314. duration=duration,
  315. status=status
  316. )
  317. self.__rules.append(rule)
  318. def moveRule(self, index, toIndex):
  319. """ Move a rule from index to a new index.
  320. @param index: Rule index
  321. @type index: uint
  322. @param toIndex: New Rule index
  323. @type toIndex: uint
  324. """
  325. rule = self.__rules.pop(index)
  326. self.__rules.insert(toIndex, rule)
  327. def delRule(self, index):
  328. """ Delete a rule by it's index
  329. @param index: Rule index
  330. @type index: uint
  331. """
  332. del self.__rules[index]
  333. def popRule(self, index=0):
  334. """ Pops a rule
  335. @param index: Rule index
  336. @type index: uint
  337. """
  338. return self.__rules.pop(index)
  339. def getRule(self, index):
  340. """ Get a rule by index
  341. @param index: Rule index
  342. @type index: uint
  343. @return: Guard Rule
  344. @rtype: searxqt.core.guard.Rule
  345. """
  346. return self.__rules[index]
  347. def serialize(self):
  348. """ Serialize this object.
  349. @return: Current data of this object.
  350. @rtype: dict
  351. """
  352. return {
  353. "rules": [rule.serialize() for rule in self.__rules],
  354. "log": self.__log if self.doesStoreLog() else {},
  355. "enabled": self.__enabled,
  356. "storeLog": self.doesStoreLog(),
  357. "maxLogDays": self.maxLogPeriod()
  358. }
  359. def deserialize(self, data):
  360. """ Deserialize data into this object.
  361. @param data: Data to set.
  362. @type data: dict
  363. """
  364. self.reset()
  365. for ruleData in data.get("rules", []):
  366. rule = Rule()
  367. rule.deserialize(ruleData)
  368. self.__rules.append(rule)
  369. self.setEnabled(data.get("enabled", Guard.Enabled))
  370. self.setStoreLog(data.get("storeLog", Guard.StoreLog))
  371. self.setMaxLogPeriod(data.get("maxLogDays", Guard.LogPeriod))
  372. if self.doesStoreLog():
  373. dataLog = deepcopy(data.get("log", {}))
  374. # Remove old logs
  375. deltaMax = self.__logPeriod * 24 * 60
  376. now = nowInMinutes()
  377. for url in dataLog:
  378. for errorType in dataLog[url]:
  379. if not dataLog[url][errorType]:
  380. # No log entries for this error type.
  381. continue
  382. index = len(dataLog[url][errorType]) - 1
  383. while True:
  384. logEntry = dataLog[url][errorType][index]
  385. delta = int(now - logEntry[0])
  386. if delta > deltaMax:
  387. # This log enrtry is older then the max set log
  388. # time, so delete this entry.
  389. del dataLog[url][errorType][index]
  390. if not index:
  391. # Processed last log entry for this errorType.
  392. break
  393. index -= 1
  394. # Update log
  395. self.__log.update(dataLog)
  396. def getConsequence(self, instanceUrl):
  397. """ Get consequence for a instance by url. It will return `None` when
  398. none of the rules triggered.
  399. @param instanceUrl: url of the instance.
  400. @type instanceUrl: str
  401. @return: Consequence for this instance, should it be put on the
  402. blacklist, a timeout or should nothing be done?
  403. @rtype: searxqt.core.guard.Consequence or None
  404. """
  405. if instanceUrl in self.__log:
  406. instanceLog = self.__log[instanceUrl]
  407. for rule in self.__rules:
  408. if rule.meetsConditions(instanceLog):
  409. return rule.consequence
  410. return None
  411. def reportSearchResult(self, instanceUrl, searchResult):
  412. """ Search results (failed or not) should be reported to Guard through
  413. this method so Guard can evaluate with `getConsequence`. When the
  414. search succeeded, previous fail logs for this instance will be removed.
  415. So search fails have to occur in a row. Failed searches will be
  416. logged.
  417. @param instanceUrl: url of the instance.
  418. @type instanceUrl: str
  419. @param searchResult: Search result
  420. @type searchResult: searxqt.core.http.HttpResponse
  421. """
  422. if searchResult.error == ErrorType.Success:
  423. # Clear the log for given instanceUrl when we have a valid result.
  424. # This will reset the counting of failures for the instance. This
  425. # also means that search failures have to occur in a row for one of
  426. # the rules to trigger.
  427. self.clearInstanceLog(instanceUrl)
  428. else:
  429. # Search failed; add the incident to the log.
  430. self.reportSearchFail(instanceUrl, searchResult)
  431. def reportSearchFail(self, instanceUrl, searchResult):
  432. """ Log failed search
  433. @param instanceUrl: url of the instance.
  434. @type instanceUrl: str
  435. @param searchResult: Search result
  436. @type searchResult: searxqt.core.http.HttpResponse
  437. """
  438. if instanceUrl not in self.__log:
  439. # No previous log for this instance
  440. self.__log.update({instanceUrl: {}})
  441. errorType = searchResult.error
  442. if errorType not in self.__log[instanceUrl]:
  443. # New errorType for this instance.
  444. self.__log[instanceUrl].update({errorType: []})
  445. self.__log[instanceUrl][errorType].append((
  446. nowInMinutes(),
  447. searchResult.statusCode,
  448. searchResult.errorMessage
  449. ))