Update upstream source from tag 'upstream/3.1.0'
Update to upstream version '3.1.0'
with Debian dir c09863f36a09d42a467f8a1fb4912406344bc776
Carsten Schoenert
1 year, 4 months ago
0 | 0 | [run] |
1 | 1 | omit = |
2 | /*/flask_limiter/_compat.py | |
3 | /*/flask_limiter/_version* | |
4 | /*/tests/*.py | |
5 | /*/flask_limiter/contrib/*.py | |
2 | /**/flask_limiter/_compat.py | |
3 | /**/flask_limiter/_version* | |
4 | /**/tests/*.py | |
5 | /**/flask_limiter/contrib/*.py | |
6 | 6 | versioneer.py |
7 | 7 | setup.py |
8 | 8 | [report] |
6 | 6 | runs-on: ubuntu-latest |
7 | 7 | strategy: |
8 | 8 | matrix: |
9 | python-version: [3.8, 3.9, "3.10"] | |
9 | python-version: [3.8, 3.9, "3.10", "3.11"] | |
10 | 10 | steps: |
11 | 11 | - uses: actions/checkout@v3 |
12 | 12 | - name: Cache dependencies |
43 | 43 | strategy: |
44 | 44 | fail-fast: false |
45 | 45 | matrix: |
46 | python-version: [3.8, 3.9, "3.10", "3.11.0-beta.5"] | |
46 | python-version: [3.8, 3.9, "3.10", "3.11"] | |
47 | 47 | flask-version: [">2,<2.1", ">=2.1,<2.2", ">=2.2,<2.3"] |
48 | 48 | steps: |
49 | 49 | - uses: actions/checkout@v3 |
0 | doc-warnings: no | |
1 | test-warnings: no | |
2 | strictness: veryhigh | |
3 | max-line-length: 80 | |
4 | autodetect: yes | |
5 | requirements: | |
6 | - requirements/main.txt | |
7 | ignore-paths: | |
8 | - tests | |
9 | - doc | |
10 | ignore-patterns: | |
11 | - versioneer.py | |
12 | - flask_limiter/_versions.py | |
13 |
0 | # autogenerated pyup.io config file | |
1 | # see https://pyup.io/docs/configuration/ for all available options | |
2 | ||
3 | schedule: every week |
0 | [style] | |
1 | # Align closing bracket with visual indentation. | |
2 | align_closing_bracket_with_visual_indent=True | |
3 | ||
4 | # Allow dictionary keys to exist on multiple lines. For example: | |
5 | # | |
6 | # x = { | |
7 | # ('this is the first element of a tuple', | |
8 | # 'this is the second element of a tuple'): | |
9 | # value, | |
10 | # } | |
11 | allow_multiline_dictionary_keys=True | |
12 | ||
13 | # Allow lambdas to be formatted on more than one line. | |
14 | allow_multiline_lambdas=True | |
15 | ||
16 | # Allow splits before the dictionary value. | |
17 | allow_split_before_dict_value=True | |
18 | ||
19 | # Insert a blank line before a class-level docstring. | |
20 | blank_line_before_class_docstring=False | |
21 | ||
22 | # Insert a blank line before a 'def' or 'class' immediately nested | |
23 | # within another 'def' or 'class'. For example: | |
24 | # | |
25 | # class Foo: | |
26 | # # <------ this blank line | |
27 | # def method(): | |
28 | # ... | |
29 | blank_line_before_nested_class_or_def=False | |
30 | ||
31 | # Do not split consecutive brackets. Only relevant when | |
32 | # dedent_closing_brackets is set. For example: | |
33 | # | |
34 | # call_func_that_takes_a_dict( | |
35 | # { | |
36 | # 'key1': 'value1', | |
37 | # 'key2': 'value2', | |
38 | # } | |
39 | # ) | |
40 | # | |
41 | # would reformat to: | |
42 | # | |
43 | # call_func_that_takes_a_dict({ | |
44 | # 'key1': 'value1', | |
45 | # 'key2': 'value2', | |
46 | # }) | |
47 | coalesce_brackets=True | |
48 | ||
49 | # The column limit. | |
50 | column_limit=79 | |
51 | ||
52 | # Indent width used for line continuations. | |
53 | continuation_indent_width=4 | |
54 | ||
55 | # Put closing brackets on a separate line, dedented, if the bracketed | |
56 | # expression can't fit in a single line. Applies to all kinds of brackets, | |
57 | # including function definitions and calls. For example: | |
58 | # | |
59 | # config = { | |
60 | # 'key1': 'value1', | |
61 | # 'key2': 'value2', | |
62 | # } # <--- this bracket is dedented and on a separate line | |
63 | # | |
64 | # time_series = self.remote_client.query_entity_counters( | |
65 | # entity='dev3246.region1', | |
66 | # key='dns.query_latency_tcp', | |
67 | # transform=Transformation.AVERAGE(window=timedelta(seconds=60)), | |
68 | # start_ts=now()-timedelta(days=3), | |
69 | # end_ts=now(), | |
70 | # ) # <--- this bracket is dedented and on a separate line | |
71 | dedent_closing_brackets=True | |
72 | ||
73 | # Place each dictionary entry onto its own line. | |
74 | each_dict_entry_on_separate_line=True | |
75 | ||
76 | # The regex for an i18n comment. The presence of this comment stops | |
77 | # reformatting of that line, because the comments are required to be | |
78 | # next to the string they translate. | |
79 | i18n_comment= | |
80 | ||
81 | # The i18n function call names. The presence of this function stops | |
82 | # reformattting on that line, because the string it has cannot be moved | |
83 | # away from the i18n comment. | |
84 | i18n_function_call= | |
85 | ||
86 | # Indent the dictionary value if it cannot fit on the same line as the | |
87 | # dictionary key. For example: | |
88 | # | |
89 | # config = { | |
90 | # 'key1': | |
91 | # 'value1', | |
92 | # 'key2': value1 + | |
93 | # value2, | |
94 | # } | |
95 | indent_dictionary_value=False | |
96 | ||
97 | # The number of columns to use for indentation. | |
98 | indent_width=4 | |
99 | ||
100 | # Join short lines into one line. E.g., single line 'if' statements. | |
101 | join_multiple_lines=True | |
102 | ||
103 | # Do not include spaces around selected binary operators. For example: | |
104 | # | |
105 | # 1 + 2 * 3 - 4 / 5 | |
106 | # | |
107 | # will be formatted as follows when configured with a value "*,/": | |
108 | # | |
109 | # 1 + 2*3 - 4/5 | |
110 | # | |
111 | no_spaces_around_selected_binary_operators=set() | |
112 | ||
113 | # Use spaces around default or named assigns. | |
114 | spaces_around_default_or_named_assign=False | |
115 | ||
116 | # Use spaces around the power operator. | |
117 | spaces_around_power_operator=False | |
118 | ||
119 | # The number of spaces required before a trailing comment. | |
120 | spaces_before_comment=2 | |
121 | ||
122 | # Insert a space between the ending comma and closing bracket of a list, | |
123 | # etc. | |
124 | space_between_ending_comma_and_closing_bracket=True | |
125 | ||
126 | # Split before arguments if the argument list is terminated by a | |
127 | # comma. | |
128 | split_arguments_when_comma_terminated=False | |
129 | ||
130 | # Set to True to prefer splitting before '&', '|' or '^' rather than | |
131 | # after. | |
132 | split_before_bitwise_operator=True | |
133 | ||
134 | # Split before a dictionary or set generator (comp_for). For example, note | |
135 | # the split before the 'for': | |
136 | # | |
137 | # foo = { | |
138 | # variable: 'Hello world, have a nice day!' | |
139 | # for variable in bar if variable != 42 | |
140 | # } | |
141 | split_before_dict_set_generator=True | |
142 | ||
143 | # Split after the opening paren which surrounds an expression if it doesn't | |
144 | # fit on a single line. | |
145 | split_before_expression_after_opening_paren=False | |
146 | ||
147 | # If an argument / parameter list is going to be split, then split before | |
148 | # the first argument. | |
149 | split_before_first_argument=False | |
150 | ||
151 | # Set to True to prefer splitting before 'and' or 'or' rather than | |
152 | # after. | |
153 | split_before_logical_operator=True | |
154 | ||
155 | # Split named assignments onto individual lines. | |
156 | split_before_named_assigns=True | |
157 | ||
158 | # The penalty for splitting right after the opening bracket. | |
159 | split_penalty_after_opening_bracket=30 | |
160 | ||
161 | # The penalty for splitting the line after a unary operator. | |
162 | split_penalty_after_unary_operator=10000 | |
163 | ||
164 | # The penalty for splitting right before an if expression. | |
165 | split_penalty_before_if_expr=0 | |
166 | ||
167 | # The penalty of splitting the line around the '&', '|', and '^' | |
168 | # operators. | |
169 | split_penalty_bitwise_operator=300 | |
170 | ||
171 | # The penalty for characters over the column limit. | |
172 | split_penalty_excess_character=4500 | |
173 | ||
174 | # The penalty incurred by adding a line split to the unwrapped line. The | |
175 | # more line splits added the higher the penalty. | |
176 | split_penalty_for_added_line_split=30 | |
177 | ||
178 | # The penalty of splitting a list of "import as" names. For example: | |
179 | # | |
180 | # from a_very_long_or_indented_module_name_yada_yad import (long_argument_1, | |
181 | # long_argument_2, | |
182 | # long_argument_3) | |
183 | # | |
184 | # would reformat to something like: | |
185 | # | |
186 | # from a_very_long_or_indented_module_name_yada_yad import ( | |
187 | # long_argument_1, long_argument_2, long_argument_3) | |
188 | split_penalty_import_names=0 | |
189 | ||
190 | # The penalty of splitting the line around the 'and' and 'or' | |
191 | # operators. | |
192 | split_penalty_logical_operator=300 | |
193 | ||
194 | # Use the Tab character for indentation. | |
195 | use_tabs=False | |
196 |
1 | 1 | |
2 | 2 | Changelog |
3 | 3 | ========= |
4 | ||
5 | v3.1.0 | |
6 | ------ | |
7 | Release Date: 2022-12-29 | |
8 | ||
9 | * Feature | |
10 | ||
11 | * Skip logging an error if a decorated limit uses a callable | |
12 | to return the "current" rate limit and returns an empty string. | |
13 | Treat this is a signal that the rate limit should be skipped for | |
14 | this request. | |
15 | ||
16 | v3.0.0 | |
17 | ------ | |
18 | Release Date: 2022-12-28 | |
19 | ||
20 | * Breaking changes | |
21 | ||
22 | * Change order of extension constructor arguments to only require | |
23 | ``key_func`` as the first positional argument and all other arguments | |
24 | as keyword arguments. | |
25 | * Separate positional/keyword arguments in limit/shared_limit decorators | |
26 | * Remove deprecated config variable RATELIMIT_STORAGE_URL | |
27 | * Remove legacy backward compatibility path for flask < 2 | |
28 | ||
29 | * Features | |
30 | ||
31 | * Allow scoping regular limit decorators / context managers | |
32 | ||
33 | v3.1.0 | |
34 | ------ | |
35 | Release Date: 2022-12-29 | |
36 | ||
37 | v3.0.0b2 | |
38 | -------- | |
39 | Release Date: 2022-12-28 | |
40 | ||
41 | * Breaking changes | |
42 | ||
43 | * Remove deprecated config variable RATELIMIT_STORAGE_URL | |
44 | * Remove legacy backward compatibility path for flask < 2 | |
45 | * Enforce key_func as a required argument | |
46 | ||
47 | * Chores | |
48 | ||
49 | * Simplify registration of decorated function & blueprint limits | |
50 | ||
51 | ||
52 | v3.1.0 | |
53 | ------ | |
54 | Release Date: 2022-12-29 | |
55 | ||
56 | v3.0.0b1 | |
57 | -------- | |
58 | Release Date: 2022-12-26 | |
59 | ||
60 | * Breaking changes | |
61 | ||
62 | * Change order of extension constructor arguments to only require | |
63 | ``key_func`` as the first positional argument and all other arguments | |
64 | as keyword arguments. | |
65 | * Separate positional/keyword arguments in limit/shared_limit decorators | |
66 | ||
67 | * Features | |
68 | ||
69 | * Allow scoping regular limit decorators / context managers | |
70 | ||
71 | v2.9.2 | |
72 | ------ | |
73 | Release Date: 2022-12-26 | |
74 | ||
75 | * Feature | |
76 | ||
77 | * Extend customization by http method to shared_limit decorator | |
78 | ||
79 | v2.9.1 | |
80 | ------ | |
81 | Release Date: 2022-12-26 | |
82 | ||
83 | * Chores | |
84 | ||
85 | * Update documentation quick start | |
86 | * Refresh documentation for class based views | |
87 | ||
88 | v2.9.0 | |
89 | ------ | |
90 | Release Date: 2022-12-24 | |
91 | ||
92 | * Features | |
93 | ||
94 | * Allow using `limit` & `shared_limit` decorators on pure | |
95 | functions that are not decorated as routes. The functions | |
96 | when called from within a request context will get rate limited. | |
97 | * Allow using `limit` as a context manager to rate limit a code block | |
98 | explicitly within a request | |
99 | ||
100 | * Chores | |
101 | ||
102 | * Updated development dependencies | |
103 | * Fix error running tests depending on docker locally | |
104 | * Update internals to use dataclasses | |
105 | ||
106 | v2.8.1 | |
107 | ------ | |
108 | Release Date: 2022-11-15 | |
109 | ||
110 | * Chores | |
111 | ||
112 | * Add sponsorship banner to rtd | |
113 | * Update documentation dependencies | |
114 | ||
115 | v2.8.0 | |
116 | ------ | |
117 | Release Date: 2022-11-13 | |
118 | ||
119 | * Breaking changes | |
120 | ||
121 | * Any exception raised when calling an ``on_breach`` callback will | |
122 | be re-raised instead of being absorbed unless ``swallow_errors`` is set. | |
123 | In the case of ``swallow_errors`` the exception will now be logged | |
124 | at ``ERROR`` level instead of ``WARN`` | |
125 | * Reduce log level of rate limit exceeded log messages to ``INFO`` | |
126 | ||
127 | v2.7.0 | |
128 | ------ | |
129 | Release Date: 2022-10-25 | |
130 | ||
131 | * Bug Fix | |
132 | ||
133 | * Add default value for RateLimitExceeded optional parameter | |
134 | * Fix suppression of errors when using conditional deduction (`Issue 363 <https://github.com/alisaifee/flask-limiter/issues/363>`_) | |
135 | ||
136 | v2.6.3 | |
137 | ------ | |
138 | Release Date: 2022-09-22 | |
139 | ||
140 | * Compatibility | |
141 | ||
142 | * Ensure typing_extensions dependency has a minimum version | |
143 | ||
144 | * Chores | |
145 | ||
146 | * Documentation tweaks | |
147 | * Update CI to use 3.11 rc2 | |
4 | 148 | |
5 | 149 | v2.6.2 |
6 | 150 | ------ |
747 | 891 | |
748 | 892 | |
749 | 893 | |
894 | ||
895 | ||
896 | ||
897 | ||
898 | ||
899 | ||
900 | ||
901 | ||
902 | ||
903 | ||
904 |
15 | 15 | |
16 | 16 | |docs| |ci| |codecov| |pypi| |license| |
17 | 17 | |
18 | Flask-Limiter provides rate limiting features to flask applications. | |
18 | **Flask-Limiter** adds rate limiting to `Flask <https://flask.palletsprojects.com>`_ applications. | |
19 | 19 | |
20 | It allows configuring various backends to persist the rate limits, which is | |
21 | provided by the `limits <https://github.com/alisaifee/limits>`_ library. | |
20 | ---- | |
21 | ||
22 | Sponsored by `Zuplo <https://zuplo.link/3NuX0co>`_ a fully-managed API Gateway for developers. | |
23 | Add `dynamic rate-limiting <https://zuplo.link/flask-dynamic-rate-limit>`_ authentication and more to any API in minutes. | |
24 | Learn more at `zuplo.com <https://zuplo.link/3NuX0co>`_ | |
25 | ||
26 | ---- | |
27 | ||
28 | ||
29 | You can configure rate limits at different levels such as: | |
30 | ||
31 | - Application wide global limits per user | |
32 | - Default limits per route | |
33 | - By `Blueprints <https://flask-limiter.readthedocs.io/en/latest/recipes.html#rate-limiting-all-routes-in-a-blueprint>`_ | |
34 | - By `Class-based views <https://flask-limiter.readthedocs.io/en/latest/recipes.html#using-flask-pluggable-views>`_ | |
35 | - By `individual routes <https://flask-limiter.readthedocs.io/en/latest/index.html#decorators-to-declare-rate-limits>`_ | |
36 | ||
37 | **Flask-Limiter** can be `configured <https://flask-limiter.readthedocs.io/en/latest/configuration.html>`_ to fit your application in many ways, including: | |
38 | ||
39 | - Persistance to various commonly used `storage backends <https://flask-limiter.readthedocs.io/en/latest/#configuring-a-storage-backend>`_ | |
40 | (such as Redis, Memcached & MongoDB) | |
41 | via `limits <https://limits.readthedocs.io/en/stable/storage.html>`__ | |
42 | - Any rate limiting strategy supported by `limits <https://limits.readthedocs.io/en/stable/strategies.html>`__ | |
43 | ||
44 | Follow the quickstart below to get started or `read the documentation <http://flask-limiter.readthedocs.org/en/latest>`_ for more details. | |
45 | ||
22 | 46 | |
23 | 47 | Quickstart |
24 | 48 | =========== |
25 | 49 | |
26 | Add the rate limiter to your flask app. | |
50 | Install | |
51 | ------- | |
52 | .. code-block:: bash | |
27 | 53 | |
54 | pip install Flask-Limiter | |
55 | ||
56 | Add the rate limiter to your flask app | |
57 | --------------------------------------- | |
28 | 58 | .. code-block:: python |
59 | ||
60 | # app.py | |
29 | 61 | |
30 | 62 | from flask import Flask |
31 | 63 | from flask_limiter import Limiter |
33 | 65 | |
34 | 66 | app = Flask(__name__) |
35 | 67 | limiter = Limiter( |
36 | app, | |
37 | key_func=get_remote_address, | |
68 | get_remote_address, | |
69 | app=app, | |
38 | 70 | default_limits=["2 per minute", "1 per second"], |
39 | 71 | storage_uri="memory://", |
40 | 72 | # Redis |
64 | 96 | def ping(): |
65 | 97 | return 'PONG' |
66 | 98 | |
67 | app.run() | |
99 | Inspect the limits using the command line interface | |
100 | --------------------------------------------------- | |
101 | .. code-block:: bash | |
102 | ||
103 | $ FLASK_APP=app:app flask limiter list | |
104 | ||
105 | app | |
106 | ├── fast: /fast | |
107 | │ ├── 2 per 1 minute | |
108 | │ └── 1 per 1 second | |
109 | ├── ping: /ping | |
110 | │ └── Exempt | |
111 | └── slow: /slow | |
112 | └── 1 per 1 day | |
113 | ||
114 | Run the app | |
115 | ----------- | |
116 | .. code-block:: bash | |
117 | ||
118 | $ FLASK_APP=app:app flask run | |
68 | 119 | |
69 | 120 | |
70 | ||
71 | Test it out. The ``fast`` endpoint respects the default rate limit while the | |
121 | Test it out | |
122 | ----------- | |
123 | The ``fast`` endpoint respects the default rate limit while the | |
72 | 124 | ``slow`` endpoint uses the decorated one. ``ping`` has no rate limit associated |
73 | 125 | with it. |
74 | 126 | |
75 | 127 | .. code-block:: bash |
76 | 128 | |
77 | $ curl localhost:5000/fast | |
78 | 42 | |
79 | $ curl localhost:5000/fast | |
80 | 42 | |
81 | $ curl localhost:5000/fast | |
82 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> | |
83 | <title>429 Too Many Requests</title> | |
84 | <h1>Too Many Requests</h1> | |
85 | <p>2 per 1 minute</p> | |
86 | $ curl localhost:5000/slow | |
87 | 24 | |
88 | $ curl localhost:5000/slow | |
89 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> | |
90 | <title>429 Too Many Requests</title> | |
91 | <h1>Too Many Requests</h1> | |
92 | <p>1 per 1 day</p> | |
93 | $ curl localhost:5000/ping | |
94 | PONG | |
95 | $ curl localhost:5000/ping | |
96 | PONG | |
97 | $ curl localhost:5000/ping | |
98 | PONG | |
99 | $ curl localhost:5000/ping | |
100 | PONG | |
129 | $ curl localhost:5000/fast | |
130 | 42 | |
131 | $ curl localhost:5000/fast | |
132 | 42 | |
133 | $ curl localhost:5000/fast | |
134 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> | |
135 | <title>429 Too Many Requests</title> | |
136 | <h1>Too Many Requests</h1> | |
137 | <p>2 per 1 minute</p> | |
138 | $ curl localhost:5000/slow | |
139 | 24 | |
140 | $ curl localhost:5000/slow | |
141 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> | |
142 | <title>429 Too Many Requests</title> | |
143 | <h1>Too Many Requests</h1> | |
144 | <p>1 per 1 day</p> | |
145 | $ curl localhost:5000/ping | |
146 | PONG | |
147 | $ curl localhost:5000/ping | |
148 | PONG | |
149 | $ curl localhost:5000/ping | |
150 | PONG | |
151 | $ curl localhost:5000/ping | |
152 | PONG | |
101 | 153 | |
102 | 154 | |
103 | 155 | |
104 | 156 | |
105 | For more details `read the docs <http://flask-limiter.readthedocs.org/en/latest>`_ |
11 | 11 | } |
12 | 12 | .badges { |
13 | 13 | display: flex; |
14 | padding: 5px; | |
15 | flex-direction: rootow; | |
14 | padding: 10px; | |
15 | flex-direction: row; | |
16 | 16 | justify-content: center; |
17 | 17 | } |
18 | 18 | .header-badge { |
19 | 19 | padding: 2px; |
20 | 20 | } |
21 | ||
22 | .sponsorship { | |
23 | display: flex; | |
24 | padding: 5px; | |
25 | flex-direction: row; | |
26 | justify-content: center; | |
27 | margin-top: 10px; | |
28 | margin-bottom: 5px; | |
29 | border-top: solid 1pt var(--bg3); | |
30 | border-bottom: solid 1pt var(--bg3); | |
31 | font-family: "Be Vietnam Pro", sans-serif; | |
32 | font-weight: 500; | |
33 | font-size: smaller; | |
34 | } | |
35 | .sponsorship a { | |
36 | color: rgb(255, 0, 189); | |
37 | text-decoration: none; | |
38 | } | |
39 | ||
40 | .sponsorship .left { | |
41 | padding-left: 10px; | |
42 | padding-right: 10px; | |
43 | } | |
44 | .sponsorship .right { | |
45 | display: flex; | |
46 | flex-direction: column; | |
47 | justify-content: center; | |
48 | padding: 10px; | |
49 | } | |
50 | .sponsorship-button { | |
51 | display: flex; | |
52 | justify-content: center; | |
53 | align-items: center; | |
54 | } | |
55 | .sponsorship-button a { | |
56 | background-color: rgb(255, 0, 189); | |
57 | color: white; | |
58 | text-decoration: none; | |
59 | font-weight: bold; | |
60 | font-family: "Be Vietnam Pro", sans-serif; | |
61 | padding: 10px 10px 9px; | |
62 | border-radius: 10px; | |
63 | border: 1pt solid rgb(255, 0, 189); | |
64 | text-align: center; | |
65 | } | |
66 | .sponsorship-button a:hover { | |
67 | background-color: white; | |
68 | text-align: center; | |
69 | border: solid 1pt black; | |
70 | color: black; | |
71 | } | |
72 | ||
73 | @media only screen and (max-width: 768px) { | |
74 | .sponsorship { | |
75 | flex-direction: column; | |
76 | } | |
77 | .sponsorship .left { | |
78 | text-align: center; | |
79 | } | |
80 | .sponsorship .right { | |
81 | padding: 0; | |
82 | } | |
83 | }⏎ |
10 | 10 | --------- |
11 | 11 | .. autoflag:: ExemptionScope |
12 | 12 | .. autoclass:: RequestLimit |
13 | .. autoclass:: HEADERS | |
14 | 13 | .. automodule:: flask_limiter.util |
15 | 14 | |
16 | 15 | Exceptions |
10 | 10 | |
11 | 11 | from theme_config import * |
12 | 12 | |
13 | description = "Flask-Limiter provides rate limiting features to flask applications." | |
13 | description = "Flask-Limiter adds rate limiting to flask applications." | |
14 | 14 | copyright = "2022, Ali-Akber Saifee" |
15 | 15 | project = "Flask-Limiter" |
16 | 16 | |
31 | 31 | html_theme_options[ |
32 | 32 | "announcement" |
33 | 33 | ] = f""" |
34 | This is a development version. The documentation for the latest version: <b>{release}</b> can be found <a href="/en/stable">here</a> | |
34 | This is a development version. The documentation for the latest stable version can be found <a href="/en/stable">here</a> | |
35 | 35 | """ |
36 | 36 | html_title = f"{project} <small><b style='color: var(--color-brand-primary)'>{{dev}}</b></small>" |
37 | 37 | except: |
42 | 42 | templates_path = ["./_templates"] |
43 | 43 | html_css_files = [ |
44 | 44 | "custom.css", |
45 | "https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap", | |
45 | "colors.css", | |
46 | "https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Be+Vietnam+Pro:wght@500&display=swap", | |
46 | 47 | ] |
47 | 48 | |
48 | 49 | html_theme_options.update({"light_logo": "tap-icon.png", "dark_logo": "tap-icon.png"}) |
19 | 19 | |
20 | 20 | $ pytest |
21 | 21 | |
22 | |version| | |
23 | 22 | |
24 | |release| | |
25 | ||
26 | Running the tests will automatically invoke :program:`docker-compose` with the config at :githubsrc:`docker-compose.yml` | |
23 | Running the tests will automatically invoke :program:`docker-compose` with the following config (:githubsrc:`docker-compose.yml`) | |
27 | 24 | |
28 | 25 | .. literalinclude:: ../../docker-compose.yml |
7 | 7 | :width: 600px |
8 | 8 | :align: center |
9 | 9 | :class: logo |
10 | ||
11 | .. container:: sponsorship | |
12 | ||
13 | .. container:: left | |
14 | ||
15 | | Sponsored by `Zuplo <https://zuplo.link/3hLbXv5>`_, a fully-managed API gateway for developers. | |
16 | | Add `dynamic rate-limiting <https://zuplo.link/3hxWXAv>`_, authentication and more to any API in minutes. | |
17 | ||
18 | .. container:: right | |
19 | ||
20 | .. container:: sponsorship-button | |
21 | ||
22 | `Try Zuplo <https://zuplo.link/3hLbXv5>`_ | |
10 | 23 | |
11 | 24 | ============= |
12 | 25 | Flask-Limiter |
33 | 46 | .. image:: https://img.shields.io/github/last-commit/alisaifee/flask-limiter?logo=github&style=for-the-badge&labelColor=#282828 |
34 | 47 | :target: https://github.com/alisaifee/flask-limiter |
35 | 48 | :class: header-badge |
36 | .. image:: https://img.shields.io/github/workflow/status/alisaifee/flask-limiter/CI?logo=github&style=for-the-badge&labelColor=#282828 | |
49 | .. image:: https://img.shields.io/github/actions/workflow/status/alisaifee/flask-limiter/main.yml?logo=github&style=for-the-badge&labelColor=#282828 | |
37 | 50 | :target: https://github.com/alisaifee/flask-limiter/actions/workflows/main.yml |
38 | 51 | :class: header-badge |
39 | 52 | .. image:: https://img.shields.io/codecov/c/github/alisaifee/flask-limiter?logo=codecov&style=for-the-badge&labelColor=#282828 |
43 | 56 | :target: https://pypi.org/project/flask-limiter |
44 | 57 | :class: header-badge |
45 | 58 | |
46 | **Flask-Limiter** provides rate limiting features to :class:`~flask.Flask` applications. | |
59 | **Flask-Limiter** adds rate limiting to :class:`~flask.Flask` applications. | |
47 | 60 | |
48 | 61 | By adding the extension to your flask application, you can configure various |
49 | 62 | rate limits at different levels (e.g. application wide, per :class:`~flask.Blueprint`, |
90 | 103 | =========== |
91 | 104 | A very basic setup can be achieved as follows: |
92 | 105 | |
93 | .. code-block:: python | |
94 | ||
95 | from flask import Flask | |
96 | from flask_limiter import Limiter | |
97 | from flask_limiter.util import get_remote_address | |
98 | ||
99 | app = Flask(__name__) | |
100 | limiter = Limiter( | |
101 | app, | |
102 | key_func=get_remote_address, | |
103 | default_limits=["200 per day", "50 per hour"], | |
104 | storage_uri="memory://", | |
105 | ) | |
106 | @app.route("/slow") | |
107 | @limiter.limit("1 per day") | |
108 | def slow(): | |
109 | return ":(" | |
110 | ||
111 | @app.route("/medium") | |
112 | @limiter.limit("1/second", override_defaults=False) | |
113 | def medium(): | |
114 | return ":|" | |
115 | ||
116 | @app.route("/fast") | |
117 | def fast(): | |
118 | return ":)" | |
119 | ||
120 | @app.route("/ping") | |
121 | @limiter.exempt | |
122 | def ping(): | |
123 | return "PONG" | |
124 | ||
106 | .. literalinclude:: ../../examples/sample.py | |
107 | :language: py | |
125 | 108 | |
126 | 109 | The above Flask app will have the following rate limiting characteristics: |
127 | 110 | |
142 | 125 | Every time a request exceeds the rate limit, the view function will not get called and instead |
143 | 126 | a `429 <http://tools.ietf.org/html/rfc6585#section-4>`_ http error will be raised. |
144 | 127 | |
128 | The extension adds a ``limiter`` subcommand to the :doc:`Flask CLI <flask:cli>` which can be used to inspect | |
129 | the effective configuration and applied rate limits (See :ref:`cli:Command Line Interface` for more details). | |
130 | ||
131 | Given the quick start example above: | |
132 | ||
133 | ||
134 | .. code-block:: shell | |
135 | ||
136 | $ flask limiter config | |
137 | ||
138 | .. program-output:: FLASK_APP=../../examples/sample.py:app flask limiter config | |
139 | :shell: | |
140 | ||
141 | .. code-block:: shell | |
142 | ||
143 | $ flask limiter limits | |
144 | ||
145 | .. program-output:: FLASK_APP=../../examples/sample.py:app flask limiter limits | |
146 | :shell: | |
145 | 147 | |
146 | 148 | The Flask-Limiter extension |
147 | 149 | --------------------------- |
156 | 158 | from flask_limiter.util import get_remote_address |
157 | 159 | .... |
158 | 160 | |
159 | limiter = Limiter(app, key_func=get_remote_address) | |
161 | limiter = Limiter(get_remote_address, app=app) | |
160 | 162 | |
161 | 163 | Deferred app initialization using :meth:`~flask_limiter.Limiter.init_app` |
162 | 164 | |
163 | 165 | .. code-block:: python |
164 | 166 | |
165 | limiter = Limiter(key_func=get_remote_address) | |
167 | limiter = Limiter(get_remote_address) | |
166 | 168 | limiter.init_app(app) |
167 | 169 | |
168 | 170 | At this point it might be a good idea to look at the configuration options |
190 | 192 | .... |
191 | 193 | |
192 | 194 | limiter = Limiter( |
193 | app, | |
194 | key_func=get_remote_address, | |
195 | get_remote_address, | |
196 | app=app, | |
195 | 197 | storage_uri="memcached://localhost:11211", |
196 | 198 | storage_options={} |
197 | 199 | ) |
209 | 211 | .... |
210 | 212 | |
211 | 213 | limiter = Limiter( |
212 | app, key_func=get_remote_address, | |
214 | get_remote_address, | |
215 | app=app, | |
213 | 216 | storage_uri="redis://localhost:6379", |
214 | storage_options={"connect_timeout": 30}, | |
217 | storage_options={"socket_connect_timeout": 30}, | |
215 | 218 | strategy="fixed-window", # or "moving-window" |
216 | 219 | ) |
217 | 220 | |
229 | 232 | |
230 | 233 | pool = redis.connection.BlockingConnectionPool.from_url("redis://.....") |
231 | 234 | limiter = Limiter( |
232 | app, key_func=get_remote_address, | |
235 | get_remote_address, | |
236 | app=app, | |
233 | 237 | storage_uri="redis://", |
234 | 238 | storage_options={"connection_pool": pool}, |
235 | 239 | strategy="fixed-window", # or "moving-window" |
248 | 252 | .... |
249 | 253 | |
250 | 254 | limiter = Limiter( |
251 | app, | |
252 | key_func=get_remote_address, | |
255 | get_remote_address, | |
256 | app=app, | |
253 | 257 | storage_uri="redis+cluster://localhost:7000,localhost:7001,localhost:7002", |
254 | storage_options={"connect_timeout": 30}, | |
258 | storage_options={"socket_connect_timeout": 30}, | |
255 | 259 | strategy="fixed-window", # or "moving-window" |
256 | 260 | ) |
257 | 261 | |
264 | 268 | .... |
265 | 269 | |
266 | 270 | limiter = Limiter( |
267 | app, key_func=get_remote_address, | |
271 | get_remote_address, | |
272 | app=app, | |
268 | 273 | storage_uri="mongodb://localhost:27017", |
269 | 274 | strategy="fixed-window", # or "moving-window" |
270 | 275 | ) |
108 | 108 | 429 |
109 | 109 | ) |
110 | 110 | |
111 | .. note:: | |
112 | .. versionchanged:: 2.8.0 | |
113 | Any errors encountered when calling an :paramref:`~Limiter.on_breach` callback will | |
114 | be re-raised unless :paramref:`~Limiter.swallow_errors` is set to ``True`` | |
115 | ||
111 | 116 | For specific rate limit decorated routes |
112 | 117 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ |
113 | 118 | .. versionadded:: 2.6.0 |
176 | 181 | For scenarios where the decision to count the current request towards a rate limit |
177 | 182 | can only be made after the request has completed, a callable that accepts the current |
178 | 183 | :class:`flask.Response` object as its argument can be provided to the :meth:`~Limiter.limit` or |
179 | :meth:`~Limiter.shared_limit` decorators through the ``deduct_when`` keyword arugment. | |
184 | :meth:`~Limiter.shared_limit` decorators through the ``deduct_when`` keyword argument. | |
180 | 185 | A truthy response from the callable will result in a deduction from the rate limit. |
181 | 186 | |
182 | 187 | As an example, to only count non `200` responses towards the rate limit |
194 | 199 | |
195 | 200 | |
196 | 201 | `deduct_when` can also be provided for default limits by providing the |
197 | :paramref:`~flask_limiter.Limiter.default_limits_deduct_when` paramter | |
202 | :paramref:`~flask_limiter.Limiter.default_limits_deduct_when` parameter | |
198 | 203 | to the :class:`~flask_limiter.Limiter` constructor. |
199 | 204 | |
200 | 205 | |
201 | 206 | .. note:: All requests will be tested for the rate limit and rejected accordingly |
202 | if the rate limit is already hit. The providion of the `deduct_when` | |
207 | if the rate limit is already hit. The provision of the `deduct_when` | |
203 | 208 | argument only changes whether the request will count towards depleting the rate limit. |
204 | 209 | |
205 | 210 | |
206 | Using Flask Pluggable Views | |
207 | --------------------------- | |
208 | ||
209 | If you are using a class based approach to defining view function, the regular | |
210 | method of decorating a view function to apply a per route rate limit will not | |
211 | work. You can add rate limits to your view classes using the following approach. | |
211 | .. _using-flask-pluggable-views: | |
212 | ||
213 | Rate limiting Class-based Views | |
214 | ------------------------------- | |
215 | ||
216 | If you are taking a class based approach for defining views, | |
217 | the recommended method (:doc:`flask:views`) of adding decorators is | |
218 | to add the :meth:`~Limiter.limit` decorator to :attr:`~flask.views.View.decorators` in your view subclass as shown in the | |
219 | example below | |
212 | 220 | |
213 | 221 | |
214 | 222 | .. code-block:: python |
215 | 223 | |
216 | 224 | app = Flask(__name__) |
217 | limiter = Limiter(app, key_func=get_remote_address) | |
225 | limiter = Limiter(get_remote_address, app=app) | |
218 | 226 | |
219 | 227 | class MyView(flask.views.MethodView): |
220 | 228 | decorators = [limiter.limit("10/second")] |
229 | ||
221 | 230 | def get(self): |
222 | 231 | return "get" |
223 | 232 | |
233 | 242 | keyword argument. |
234 | 243 | |
235 | 244 | |
236 | The above approach has been tested with sub-classes of :class:`flask.views.View`, | |
237 | :class:`flask.views.MethodView` and :class:`flask_restful.Resource`. | |
238 | ||
239 | 245 | Rate limiting all routes in a :class:`~flask.Blueprint` |
240 | 246 | ------------------------------------------------------- |
241 | 247 | |
242 | 248 | .. warning:: :class:`~flask.Blueprint` instances that are registered on another blueprint |
243 | 249 | instead of on the main :class:`~flask.Flask` instance had not been considered |
244 | 250 | upto :ref:`changelog:v2.3.0`. Effectively **they neither inherited** the rate limits |
245 | explicitely registered on the parent :class:`~flask.Blueprint` **nor were they | |
251 | explicitly registered on the parent :class:`~flask.Blueprint` **nor were they | |
246 | 252 | exempt** from rate limits if the parent had been marked exempt. |
247 | 253 | (See :issue:`326`, and the :ref:`recipes:nested blueprints` section below). |
248 | 254 | |
249 | 255 | :meth:`~Limiter.limit`, :meth:`~Limiter.shared_limit` & |
250 | 256 | :meth:`~Limiter.exempt` can all be tpplied to :class:`flask.Blueprint` instances as well. |
251 | In the following example the **login** Blueprint has a special rate limit applied to all its routes, while | |
252 | the **help** Blueprint is exempt from all rate limits. The **regular** Blueprint follows the default rate limits. | |
257 | In the following example the ``login`` Blueprint has a special rate limit applied to all its routes, while | |
258 | the ``doc`` Blueprint is exempt from all rate limits. The ``regular`` Blueprint follows the default rate limits. | |
253 | 259 | |
254 | 260 | |
255 | 261 | .. code-block:: python |
273 | 279 | return "login" |
274 | 280 | |
275 | 281 | |
276 | limiter = Limiter(app, default_limits = ["1/second"], key_func=get_remote_address) | |
282 | limiter = Limiter(get_remote_address, app=app, default_limits = ["1/second"]) | |
277 | 283 | limiter.limit("60/hour")(login) |
278 | 284 | limiter.exempt(doc) |
279 | 285 | |
318 | 324 | =========================================================== |
319 | 325 | |
320 | 326 | Using combinations of :paramref:`~Limiter.limit.override_defaults` parameter |
321 | when explicitely declaring limits on Blueprints and the :paramref:`~Limiter.exempt.flags` | |
327 | when explicitly declaring limits on Blueprints and the :paramref:`~Limiter.exempt.flags` | |
322 | 328 | parameter when exempting Blueprints with :meth:`~Limiter.exempt` |
323 | 329 | the resolution of inherited and descendent limits within the scope of a Blueprint |
324 | 330 | can be controlled. |
407 | 413 | |
408 | 414 | |
409 | 415 | app = Flask(__name__) |
410 | limiter = Limiter(app, key_func=get_remote_address) | |
416 | limiter = Limiter(get_remote_address, app=app) | |
411 | 417 | |
412 | 418 | def error_handler(): |
413 | 419 | return app.config.get("DEFAULT_ERROR_MESSAGE") |
436 | 442 | |
437 | 443 | |
438 | 444 | app = Flask(__name__) |
439 | limiter = Limiter(app, key_func=get_remote_address) | |
445 | limiter = Limiter(get_remote_address, app=app) | |
440 | 446 | |
441 | 447 | |
442 | 448 | @app.route("/") |
482 | 488 | # for example if the request goes through one proxy |
483 | 489 | # before hitting your application server |
484 | 490 | app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) |
485 | limiter = Limiter(app, key_func=get_remote_address) | |
491 | limiter = Limiter(get_remote_address, app=app) |
0 | import os | |
0 | 1 | import jinja2 |
1 | 2 | from flask import Blueprint, Flask, jsonify, request, render_template, make_response |
2 | 3 | from flask.views import View |
28 | 29 | return 1 |
29 | 30 | |
30 | 31 | limiter = Limiter( |
31 | key_func=get_remote_address, | |
32 | get_remote_address, | |
32 | 33 | default_limits=["20/hour", "1000/hour", default_limit_extra], |
33 | 34 | default_limits_exempt_when=lambda: request.headers.get("X-Internal"), |
34 | 35 | default_limits_deduct_when=lambda response: response.status_code == 200, |
35 | 36 | default_limits_cost=default_cost, |
36 | 37 | application_limits=["5000/hour"], |
37 | 38 | headers_enabled=True, |
39 | storage_uri=os.environ.get("FLASK_RATELIMIT_STORAGE_URI", "memory://"), | |
38 | 40 | ) |
39 | 41 | |
40 | 42 | app = Flask(__name__) |
0 | from flask import Flask | |
1 | from flask_limiter import Limiter | |
2 | from flask_limiter.util import get_remote_address | |
3 | ||
4 | app = Flask(__name__) | |
5 | limiter = Limiter( | |
6 | get_remote_address, | |
7 | app=app, | |
8 | default_limits=["200 per day", "50 per hour"], | |
9 | storage_uri="memory://", | |
10 | ) | |
11 | ||
12 | ||
13 | @app.route("/slow") | |
14 | @limiter.limit("1 per day") | |
15 | def slow(): | |
16 | return ":(" | |
17 | ||
18 | ||
19 | @app.route("/medium") | |
20 | @limiter.limit("1/second", override_defaults=False) | |
21 | def medium(): | |
22 | return ":|" | |
23 | ||
24 | ||
25 | @app.route("/fast") | |
26 | def fast(): | |
27 | return ":)" | |
28 | ||
29 | ||
30 | @app.route("/ping") | |
31 | @limiter.exempt | |
32 | def ping(): | |
33 | return "PONG" |
23 | 23 | # setup.py/versioneer.py will grep for the variable names, so they must |
24 | 24 | # each be defined on a line of their own. _version.py will just call |
25 | 25 | # get_keywords(). |
26 | git_refnames = " (tag: 2.6.2, stable)" | |
27 | git_full = "f56d9c0af9930ee58d706bf7aabdf8751ded12e0" | |
28 | git_date = "2022-08-24 09:35:12 -0700" | |
26 | git_refnames = " (tag: 3.1.0, stable)" | |
27 | git_full = "745964acbc31048af1fedca09a1637df8278c760" | |
28 | git_date = "2022-12-29 11:54:32 -0800" | |
29 | 29 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} |
30 | 30 | return keywords |
31 | 31 |
19 | 19 | |
20 | 20 | from flask_limiter import Limiter |
21 | 21 | from flask_limiter.constants import ConfigVars, ExemptionScope, HeaderNames |
22 | from flask_limiter.util import get_qualified_name | |
22 | 23 | from flask_limiter.wrappers import Limit |
23 | 24 | |
24 | 25 | limiter_theme = Theme( |
67 | 68 | def render_limit_state( |
68 | 69 | limiter: Limiter, endpoint: str, limit: Limit, key: str, method: str |
69 | 70 | ) -> str: |
70 | args = limit.args_for(endpoint, key, method) | |
71 | args = [key, limit.scope_for(endpoint, method)] | |
71 | 72 | if not limiter.storage or (limiter.storage and not limiter.storage.check()): |
72 | 73 | return ": [error]Storage not available[/error]" |
73 | 74 | test = limiter.limiter.test(limit.limit, *args) |
116 | 117 | |
117 | 118 | for limit in limits: |
118 | 119 | if endpoint: |
120 | view_func = app.view_functions.get(endpoint, None) | |
119 | 121 | source = ( |
120 | 122 | "blueprint" |
121 | 123 | if blueprint |
122 | 124 | and limit in limiter.limit_manager.blueprint_limits(app, blueprint) |
123 | 125 | else "route" |
124 | if limit in limiter.limit_manager.route_limits(app, endpoint) | |
126 | if limit | |
127 | in limiter.limit_manager.decorated_limits( | |
128 | get_qualified_name(view_func) if view_func else "" | |
129 | ) | |
125 | 130 | else "default" |
126 | 131 | ) |
127 | 132 | else: |
524 | 529 | for limit in application_limits: |
525 | 530 | limiter.limiter.clear( |
526 | 531 | limit.limit, |
527 | *limit.args_for("", key, method), | |
532 | key, | |
533 | limit.scope_for("", method), | |
528 | 534 | ) |
529 | 535 | node.add(f"{render_limit(limit)}: [success]Cleared[/success]") |
530 | 536 | console.print(node) |
541 | 547 | for rule_method in details["rule"].methods: |
542 | 548 | limiter.limiter.clear( |
543 | 549 | limit.limit, |
544 | *limit.args_for(endpoint, key, rule_method), | |
550 | key, | |
551 | limit.scope_for(endpoint, rule_method), | |
545 | 552 | ) |
546 | 553 | else: |
547 | 554 | limiter.limiter.clear( |
548 | 555 | limit.limit, |
549 | *limit.args_for(endpoint, key, method), | |
556 | key, | |
557 | limit.scope_for(endpoint, method), | |
550 | 558 | ) |
551 | 559 | node.add( |
552 | 560 | f"{render_limit(limit)}: [success]Cleared[/success]" |
18 | 18 | DEFAULT_LIMITS_COST = "RATELIMIT_DEFAULTS_COST" |
19 | 19 | STRATEGY = "RATELIMIT_STRATEGY" |
20 | 20 | STORAGE_URI = "RATELIMIT_STORAGE_URI" |
21 | STORAGE_URL = "RATELIMIT_STORAGE_URL" # Deprecated due to inconsistency. | |
22 | 21 | STORAGE_OPTIONS = "RATELIMIT_STORAGE_OPTIONS" |
23 | 22 | HEADERS_ENABLED = "RATELIMIT_HEADERS_ENABLED" |
24 | 23 | HEADER_LIMIT = "RATELIMIT_HEADER_LIMIT" |
9 | 9 | class RateLimitExceeded(exceptions.TooManyRequests): |
10 | 10 | """Exception raised when a rate limit is hit.""" |
11 | 11 | |
12 | def __init__(self, limit: Limit, response: Optional[Response]) -> None: | |
12 | def __init__(self, limit: Limit, response: Optional[Response] = None) -> None: | |
13 | 13 | """ |
14 | 14 | :param limit: The actual rate limit that was hit. |
15 | 15 | Used to construct the default response message |
0 | 0 | from __future__ import annotations |
1 | 1 | |
2 | 2 | import dataclasses |
3 | import traceback | |
3 | 4 | import warnings |
5 | from types import TracebackType | |
6 | ||
7 | from ordered_set import OrderedSet | |
8 | ||
9 | from .util import get_qualified_name | |
4 | 10 | |
5 | 11 | """ |
6 | 12 | Flask-Limiter Extension |
12 | 18 | import weakref |
13 | 19 | from collections import defaultdict |
14 | 20 | from functools import partial, wraps |
15 | from typing import overload | |
21 | from typing import Type, overload | |
16 | 22 | |
17 | 23 | import flask |
18 | 24 | import flask.wrappers |
44 | 50 | |
45 | 51 | @dataclasses.dataclass |
46 | 52 | class LimiterContext: |
47 | rate_limiting_complete: bool = False | |
53 | rate_limiting_complete: dict[str, bool] = dataclasses.field(default_factory=dict) | |
48 | 54 | view_rate_limit: Optional[RequestLimit] = None |
49 | 55 | view_rate_limits: List[RequestLimit] = dataclasses.field(default_factory=list) |
50 | 56 | conditional_deductions: Dict[Limit, List[str]] = dataclasses.field( |
51 | 57 | default_factory=dict |
52 | 58 | ) |
59 | seen_limits: OrderedSet[Limit] = dataclasses.field(default_factory=OrderedSet) | |
53 | 60 | |
54 | 61 | def reset(self) -> None: |
55 | self.rate_limiting_complete = False | |
62 | self.rate_limiting_complete.clear() | |
56 | 63 | self.view_rate_limit = None |
57 | 64 | self.view_rate_limits.clear() |
58 | 65 | self.conditional_deductions.clear() |
62 | 69 | """ |
63 | 70 | The :class:`Limiter` class initializes the Flask-Limiter extension. |
64 | 71 | |
65 | :param app: :class:`flask.Flask` instance to initialize the extension with. | |
66 | 72 | :param key_func: a callable that returns the domain to rate limit |
67 | 73 | by. |
74 | :param app: :class:`flask.Flask` instance to initialize the extension with. | |
68 | 75 | :param default_limits: a variable list of strings or callables |
69 | 76 | returning strings denoting default limits to apply to all routes. |
70 | 77 | :ref:`ratelimit-string` for more details. |
96 | 103 | upon instantiation. |
97 | 104 | :param auto_check: whether to automatically check the rate limit in |
98 | 105 | the before_request chain of the application. default ``True`` |
99 | :param swallow_errors: whether to swallow errors when hitting a rate | |
106 | :param swallow_errors: whether to swallow any errors when hitting a rate | |
100 | 107 | limit. An exception will still be logged. default ``False`` |
101 | 108 | :param fail_on_first_breach: whether to stop processing remaining limits |
102 | 109 | after the first breach. default ``True`` |
118 | 125 | |
119 | 126 | def __init__( |
120 | 127 | self, |
128 | key_func: Callable[[], str], | |
129 | *, | |
121 | 130 | app: Optional[flask.Flask] = None, |
122 | key_func: Optional[Callable[[], str]] = None, | |
123 | 131 | default_limits: Optional[List[Union[str, Callable[[], str]]]] = None, |
124 | 132 | default_limits_per_method: Optional[bool] = None, |
125 | 133 | default_limits_exempt_when: Optional[Callable[[], bool]] = None, |
179 | 187 | self._fail_on_first_breach = fail_on_first_breach |
180 | 188 | self._on_breach = on_breach |
181 | 189 | |
182 | # No longer optional | |
183 | assert key_func | |
184 | ||
185 | 190 | self._key_func = key_func |
186 | 191 | self._key_prefix = key_prefix |
187 | 192 | |
188 | 193 | _default_limits = ( |
189 | 194 | [ |
190 | 195 | LimitGroup( |
191 | limit, | |
192 | self._key_func, | |
193 | None, | |
194 | False, | |
195 | None, | |
196 | None, | |
197 | None, | |
198 | None, | |
199 | None, | |
200 | None, | |
201 | 1, | |
196 | limit_provider=limit, | |
197 | key_function=self._key_func, | |
202 | 198 | ) |
203 | 199 | for limit in default_limits |
204 | 200 | ] |
209 | 205 | _application_limits = ( |
210 | 206 | [ |
211 | 207 | LimitGroup( |
212 | limit, | |
213 | self._key_func, | |
214 | "global", | |
215 | False, | |
216 | None, | |
217 | None, | |
218 | None, | |
219 | None, | |
220 | None, | |
221 | None, | |
222 | 1, | |
208 | limit_provider=limit, | |
209 | key_function=self._key_func, | |
210 | scope="global", | |
211 | shared=True, | |
223 | 212 | ) |
224 | 213 | for limit in application_limits |
225 | 214 | ] |
231 | 220 | for limit in in_memory_fallback: |
232 | 221 | self._in_memory_fallback.append( |
233 | 222 | LimitGroup( |
234 | limit, | |
235 | self._key_func, | |
236 | None, | |
237 | False, | |
238 | None, | |
239 | None, | |
240 | None, | |
241 | None, | |
242 | None, | |
243 | None, | |
244 | 1, | |
223 | limit_provider=limit, | |
224 | key_function=self._key_func, | |
245 | 225 | ) |
246 | 226 | ) |
247 | 227 | |
259 | 239 | self.limit_manager = LimitManager( |
260 | 240 | application_limits=_application_limits, |
261 | 241 | default_limits=_default_limits, |
262 | static_route_limits={}, | |
263 | dynamic_route_limits={}, | |
264 | static_blueprint_limits={}, | |
265 | dynamic_blueprint_limits={}, | |
242 | decorated_limits={}, | |
243 | blueprint_limits={}, | |
266 | 244 | route_exemptions=self._route_exemptions, |
267 | 245 | blueprint_exemptions=self._blueprint_exemptions, |
268 | 246 | ) |
308 | 286 | self._headers_enabled = bool(config.get(ConfigVars.HEADERS_ENABLED, False)) |
309 | 287 | |
310 | 288 | self._storage_options.update(config.get(ConfigVars.STORAGE_OPTIONS, {})) |
311 | storage_uri_from_config = config.get( | |
312 | ConfigVars.STORAGE_URI, config.get(ConfigVars.STORAGE_URL, None) | |
313 | ) | |
289 | storage_uri_from_config = config.get(ConfigVars.STORAGE_URI, None) | |
314 | 290 | if not storage_uri_from_config: |
315 | 291 | if not self._storage_uri: |
316 | 292 | warnings.warn( |
371 | 347 | self.limit_manager.set_application_limits( |
372 | 348 | [ |
373 | 349 | LimitGroup( |
374 | app_limits, | |
375 | self._key_func, | |
376 | "global", | |
377 | False, | |
378 | None, | |
379 | None, | |
380 | None, | |
381 | None, | |
382 | None, | |
383 | None, | |
384 | self._application_limits_cost, | |
350 | limit_provider=app_limits, | |
351 | key_function=self._key_func, | |
352 | scope="global", | |
353 | shared=True, | |
354 | cost=self._application_limits_cost, | |
385 | 355 | ) |
386 | 356 | ] |
387 | 357 | ) |
397 | 367 | self.limit_manager.set_default_limits( |
398 | 368 | [ |
399 | 369 | LimitGroup( |
400 | conf_limits, | |
401 | self._key_func, | |
402 | None, | |
403 | self._default_limits_per_method, | |
404 | None, | |
405 | None, | |
406 | self._default_limits_exempt_when, | |
407 | None, | |
408 | self._default_limits_deduct_when, | |
409 | None, | |
410 | self._default_limits_cost, | |
370 | limit_provider=conf_limits, | |
371 | key_function=self._key_func, | |
372 | per_method=self._default_limits_per_method, | |
373 | exempt_when=self._default_limits_exempt_when, | |
374 | deduct_when=self._default_limits_deduct_when, | |
375 | cost=self._default_limits_cost, | |
411 | 376 | ) |
412 | 377 | ] |
413 | 378 | ) |
421 | 386 | self.limit_manager.set_default_limits(default_limit_groups) |
422 | 387 | self.__configure_fallbacks(app, self._strategy) |
423 | 388 | |
424 | # purely for backward compatibility as stated in flask documentation | |
425 | if not hasattr(app, "extensions"): | |
426 | app.extensions = {} # pragma: no cover | |
427 | ||
428 | 389 | if self not in app.extensions.setdefault("limiter", set()): |
429 | 390 | if self._auto_check: |
430 | 391 | app.before_request(self._check_request_limit) |
447 | 408 | def limit( |
448 | 409 | self, |
449 | 410 | limit_value: Union[str, Callable[[], str]], |
411 | *, | |
450 | 412 | key_func: Optional[Callable[[], str]] = None, |
451 | 413 | per_method: bool = False, |
452 | 414 | methods: Optional[List[str]] = None, |
458 | 420 | Callable[[RequestLimit], Optional[flask.wrappers.Response]] |
459 | 421 | ] = None, |
460 | 422 | cost: Union[int, Callable[[], int]] = 1, |
423 | scope: Optional[Union[str, Callable[[str], str]]] = None, | |
461 | 424 | ) -> LimitDecorator: |
462 | 425 | """ |
463 | decorator to be used for rate limiting individual routes or blueprints. | |
426 | Decorator to be used for rate limiting individual routes or blueprints. | |
464 | 427 | |
465 | 428 | :param limit_value: rate limit string or a callable that returns a |
466 | 429 | string. :ref:`ratelimit-string` for more details. |
491 | 454 | raised. |
492 | 455 | :param cost: The cost of a hit or a function that |
493 | 456 | takes no parameters and returns the cost as an integer (Default: ``1``). |
457 | :param scope: a string or callable that returns a string | |
458 | for further categorizing the rate limiting scope. This scope is combined | |
459 | with the current endpoint of the request. | |
460 | ||
461 | ||
462 | Changes | |
463 | - .. versionadded:: 2.9.0 The returned object can also be used as a context manager | |
464 | for rate limiting a code block inside a view. For example:: | |
465 | ||
466 | @app.route("/") | |
467 | def route(): | |
468 | try: | |
469 | with limiter.limit("10/second"): | |
470 | # something expensive | |
471 | except RateLimitExceeded: pass | |
494 | 472 | """ |
495 | 473 | |
496 | 474 | return LimitDecorator( |
497 | 475 | self, |
498 | 476 | limit_value, |
499 | 477 | key_func, |
478 | False, | |
479 | scope, | |
500 | 480 | per_method=per_method, |
501 | 481 | methods=methods, |
502 | 482 | error_message=error_message, |
511 | 491 | self, |
512 | 492 | limit_value: Union[str, Callable[[], str]], |
513 | 493 | scope: Union[str, Callable[[str], str]], |
494 | *, | |
514 | 495 | key_func: Optional[Callable[[], str]] = None, |
496 | per_method: bool = False, | |
497 | methods: Optional[List[str]] = None, | |
515 | 498 | error_message: Optional[str] = None, |
516 | 499 | exempt_when: Optional[Callable[[], bool]] = None, |
517 | 500 | override_defaults: bool = True, |
531 | 514 | :param key_func: function/lambda to extract the unique |
532 | 515 | identifier for the rate limit. defaults to remote address of the |
533 | 516 | request. |
517 | :param per_method: whether the limit is sub categorized into the | |
518 | http method of the request. | |
519 | :param methods: if specified, only the methods in this list will | |
520 | be rate limited (default: ``None``). | |
534 | 521 | :param error_message: string (or callable that returns one) to override |
535 | 522 | the error message used in the response. |
536 | 523 | :param function exempt_when: function/lambda used to decide if the rate |
558 | 545 | key_func, |
559 | 546 | True, |
560 | 547 | scope, |
548 | per_method=per_method, | |
549 | methods=methods, | |
561 | 550 | error_message=error_message, |
562 | 551 | exempt_when=exempt_when, |
563 | 552 | override_defaults=override_defaults, |
647 | 636 | if isinstance(obj, flask.Blueprint): |
648 | 637 | self.limit_manager.add_blueprint_exemption(obj.name, flags) |
649 | 638 | elif obj: |
650 | self.limit_manager.add_route_exemption( | |
651 | f"{obj.__module__}.{obj.__name__}", flags | |
652 | ) | |
639 | self.limit_manager.add_route_exemption(get_qualified_name(obj), flags) | |
653 | 640 | else: |
654 | 641 | _R = TypeVar("_R") |
655 | 642 | _WO = TypeVar("_WO", Callable[..., _R], flask.Blueprint) |
680 | 667 | if not self._in_memory_fallback and fallback_limits: |
681 | 668 | self._in_memory_fallback = [ |
682 | 669 | LimitGroup( |
683 | fallback_limits, | |
684 | self._key_func, | |
685 | None, | |
686 | False, | |
687 | None, | |
688 | None, | |
689 | None, | |
690 | None, | |
691 | None, | |
692 | None, | |
693 | 1, | |
670 | limit_provider=fallback_limits, | |
671 | key_function=self._key_func, | |
672 | scope=None, | |
673 | per_method=False, | |
674 | cost=1, | |
694 | 675 | ) |
695 | 676 | ] |
696 | 677 | |
724 | 705 | |
725 | 706 | :raises: RateLimitExceeded |
726 | 707 | """ |
727 | self._check_request_limit(False) | |
708 | self._check_request_limit(in_middleware=False) | |
728 | 709 | |
729 | 710 | def reset(self) -> None: |
730 | 711 | """ |
796 | 777 | return self.context.view_rate_limits |
797 | 778 | |
798 | 779 | def __check_conditional_deductions(self, response: flask.wrappers.Response) -> None: |
799 | ||
800 | 780 | for lim, args in self.context.conditional_deductions.items(): |
801 | 781 | if lim.deduct_when and lim.deduct_when(response): |
802 | self.limiter.hit(lim.limit, *args, cost=lim.cost) | |
782 | try: | |
783 | self.limiter.hit(lim.limit, *args, cost=lim.cost) | |
784 | except Exception as err: | |
785 | if self._swallow_errors: | |
786 | self.logger.exception( | |
787 | "Failed to deduct rate limit. " "Swallowing error" | |
788 | ) | |
789 | else: | |
790 | raise err | |
803 | 791 | |
804 | 792 | def __inject_headers( |
805 | 793 | self, response: flask.wrappers.Response |
867 | 855 | |
868 | 856 | return response |
869 | 857 | |
870 | def __check_all_limits_exempt(self, endpoint: Optional[str]) -> bool: | |
858 | def __check_all_limits_exempt( | |
859 | self, endpoint: Optional[str], callable_name: Optional[str] = None | |
860 | ) -> bool: | |
871 | 861 | return bool( |
872 | 862 | not endpoint |
873 | 863 | or not (self.enabled and self.initialized) |
874 | 864 | or endpoint == "static" |
875 | 865 | or any(fn() for fn in self._request_filters) |
876 | or self.context.rate_limiting_complete | |
866 | or ( | |
867 | self.context.rate_limiting_complete.get(callable_name, False) | |
868 | if callable_name | |
869 | else any(self.context.rate_limiting_complete.values()) | |
870 | ) | |
877 | 871 | ) |
878 | 872 | |
879 | 873 | def __filter_limits( |
880 | 874 | self, |
881 | 875 | endpoint: Optional[str], |
882 | 876 | blueprint: Optional[str], |
877 | callable_name: Optional[str], | |
883 | 878 | in_middleware: bool = False, |
884 | 879 | ) -> List[Limit]: |
885 | view_func = flask.current_app.view_functions.get(endpoint or "", None) | |
886 | name = f"{view_func.__module__}.{view_func.__name__}" if view_func else "" | |
887 | ||
888 | if self.__check_all_limits_exempt(endpoint): | |
880 | ||
881 | if callable_name: | |
882 | name = callable_name | |
883 | else: | |
884 | view_func = flask.current_app.view_functions.get(endpoint or "", None) | |
885 | name = get_qualified_name(view_func) if view_func else "" | |
886 | ||
887 | if self.__check_all_limits_exempt(endpoint, callable_name): | |
889 | 888 | return [] |
890 | 889 | |
891 | marked_for_limiting = name in self._marked_for_limiting | |
890 | marked_for_limiting = ( | |
891 | name in self._marked_for_limiting | |
892 | or self.limit_manager.has_hints(endpoint or "") | |
893 | ) | |
892 | 894 | fallback_limits = [] |
893 | 895 | |
894 | 896 | if self._storage_dead and self._fallback_limiter: |
905 | 907 | self.__check_backend_count = 0 |
906 | 908 | else: |
907 | 909 | fallback_limits = list(itertools.chain(*self._in_memory_fallback)) |
908 | ||
909 | return self.limit_manager.resolve_limits( | |
910 | resolved_limits = self.limit_manager.resolve_limits( | |
910 | 911 | flask.current_app, |
911 | 912 | endpoint, |
912 | 913 | blueprint, |
914 | name, | |
913 | 915 | in_middleware, |
914 | 916 | marked_for_limiting, |
915 | 917 | fallback_limits, |
916 | 918 | ) |
919 | limits = OrderedSet(resolved_limits) - self.context.seen_limits | |
920 | self.context.seen_limits.update(limits) | |
921 | return list(limits) | |
917 | 922 | |
918 | 923 | def __evaluate_limits(self, endpoint: str, limits: List[Limit]) -> None: |
919 | 924 | failed_limits: List[Tuple[Limit, List[str]]] = [] |
920 | 925 | limit_for_header: Optional[RequestLimit] = None |
921 | 926 | view_limits: List[RequestLimit] = [] |
922 | 927 | for lim in sorted(limits, key=lambda x: x.limit): |
923 | limit_scope = lim.scope or endpoint | |
924 | ||
925 | 928 | if lim.is_exempt or lim.method_exempt: |
926 | 929 | continue |
927 | 930 | |
928 | if lim.per_method: | |
929 | limit_scope += f":{flask.request.method}" | |
931 | limit_scope = lim.scope_for(endpoint, flask.request.method) | |
930 | 932 | limit_key = lim.key_func() |
931 | 933 | args = [limit_key, limit_scope] |
932 | 934 | kwargs = {} |
954 | 956 | view_limits.append(RequestLimit(self, lim.limit, args, False)) |
955 | 957 | |
956 | 958 | if not method(lim.limit, *args, **kwargs): |
957 | self.logger.warning( | |
959 | self.logger.info( | |
958 | 960 | "ratelimit %s (%s) exceeded at endpoint: %s", |
959 | 961 | lim.limit, |
960 | 962 | limit_key, |
979 | 981 | if isinstance(cb_response, flask.wrappers.Response): |
980 | 982 | on_breach_response = cb_response |
981 | 983 | except Exception as err: # noqa |
982 | self.logger.warning( | |
983 | "on_breach callback failed with error %s", err | |
984 | ) | |
985 | ||
984 | if self._swallow_errors: | |
985 | self.logger.exception( | |
986 | "on_breach callback failed with error %s", err | |
987 | ) | |
988 | else: | |
989 | raise err | |
986 | 990 | if failed_limits: |
987 | 991 | raise RateLimitExceeded( |
988 | 992 | sorted(failed_limits, key=lambda x: x[0].limit)[0][0], |
989 | 993 | response=on_breach_response, |
990 | 994 | ) |
991 | 995 | |
992 | def _check_request_limit(self, in_middleware: bool = True) -> None: | |
996 | def _check_request_limit( | |
997 | self, callable_name: Optional[str] = None, in_middleware: bool = True | |
998 | ) -> None: | |
993 | 999 | endpoint = flask.request.endpoint or "" |
994 | 1000 | try: |
995 | 1001 | all_limits = self.__filter_limits( |
996 | flask.request.endpoint, flask.request.blueprint, in_middleware | |
1002 | flask.request.endpoint, | |
1003 | flask.request.blueprint, | |
1004 | callable_name, | |
1005 | in_middleware, | |
997 | 1006 | ) |
998 | 1007 | self.__evaluate_limits(endpoint, all_limits) |
999 | 1008 | except Exception as e: |
1006 | 1015 | " in-memory storage" |
1007 | 1016 | ) |
1008 | 1017 | self._storage_dead = True |
1009 | self._check_request_limit(in_middleware) | |
1018 | self.context.seen_limits.clear() | |
1019 | self._check_request_limit( | |
1020 | callable_name=callable_name, in_middleware=in_middleware | |
1021 | ) | |
1010 | 1022 | else: |
1011 | 1023 | if self._swallow_errors: |
1012 | 1024 | self.logger.exception("Failed to rate limit. Swallowing error") |
1046 | 1058 | self.limiter: weakref.ProxyType[Limiter] = weakref.proxy(limiter) |
1047 | 1059 | self.limit_value = limit_value |
1048 | 1060 | self.key_func = key_func or self.limiter._key_func |
1049 | self.scope = scope if shared else None | |
1061 | self.scope = scope | |
1050 | 1062 | self.per_method = per_method |
1051 | self.methods = methods | |
1063 | self.methods = tuple(methods) if methods else None | |
1052 | 1064 | self.error_message = error_message |
1053 | 1065 | self.exempt_when = exempt_when |
1054 | 1066 | self.override_defaults = override_defaults |
1055 | 1067 | self.deduct_when = deduct_when |
1056 | 1068 | self.on_breach = on_breach |
1057 | 1069 | self.cost = cost |
1070 | self.is_static = not callable(self.limit_value) | |
1071 | self.shared = shared | |
1072 | ||
1073 | @property | |
1074 | def limit_group(self) -> LimitGroup: | |
1075 | return LimitGroup( | |
1076 | limit_provider=self.limit_value, | |
1077 | key_function=self.key_func, | |
1078 | scope=self.scope, | |
1079 | per_method=self.per_method, | |
1080 | methods=self.methods, | |
1081 | error_message=self.error_message, | |
1082 | exempt_when=self.exempt_when, | |
1083 | override_defaults=self.override_defaults, | |
1084 | deduct_when=self.deduct_when, | |
1085 | on_breach=self.on_breach, | |
1086 | cost=self.cost, | |
1087 | shared=self.shared, | |
1088 | ) | |
1089 | ||
1090 | def __enter__(self) -> None: | |
1091 | tb = traceback.extract_stack(limit=2) | |
1092 | qualified_location = f"{tb[0].filename}:{tb[0].name}:{tb[0].lineno}" | |
1093 | ||
1094 | # TODO: if use as a context manager becomes interesting/valuable | |
1095 | # a less hacky approach than using the traceback and piggy backing | |
1096 | # on the limit manager's knowledge of decorated limits might be worth it. | |
1097 | self.limiter.limit_manager.add_decorated_limit( | |
1098 | qualified_location, self.limit_group, override=True | |
1099 | ) | |
1100 | ||
1101 | self.limiter.limit_manager.add_endpoint_hint( | |
1102 | flask.request.endpoint, qualified_location | |
1103 | ) | |
1104 | ||
1105 | self.limiter._check_request_limit( | |
1106 | in_middleware=False, callable_name=qualified_location | |
1107 | ) | |
1108 | ||
1109 | def __exit__( | |
1110 | self, | |
1111 | exc_type: Optional[Type[BaseException]], | |
1112 | exc_value: Optional[BaseException], | |
1113 | traceback: Optional[TracebackType], | |
1114 | ) -> None: | |
1115 | ... | |
1058 | 1116 | |
1059 | 1117 | @overload |
1060 | 1118 | def __call__(self, obj: Callable[P, R]) -> Callable[P, R]: |
1067 | 1125 | def __call__( |
1068 | 1126 | self, obj: Union[Callable[P, R], flask.Blueprint] |
1069 | 1127 | ) -> Optional[Callable[P, R]]: |
1070 | is_route = not isinstance(obj, flask.Blueprint) | |
1071 | 1128 | if isinstance(obj, flask.Blueprint): |
1072 | 1129 | name = obj.name |
1073 | 1130 | else: |
1074 | name = f"{obj.__module__}.{obj.__name__}" | |
1075 | ||
1076 | dynamic_limit, static_limits = None, [] | |
1077 | ||
1078 | if callable(self.limit_value): | |
1079 | dynamic_limit = LimitGroup( | |
1080 | self.limit_value, | |
1081 | self.key_func, | |
1082 | self.scope, | |
1083 | self.per_method, | |
1084 | self.methods, | |
1085 | self.error_message, | |
1086 | self.exempt_when, | |
1087 | self.override_defaults, | |
1088 | self.deduct_when, | |
1089 | self.on_breach, | |
1090 | self.cost, | |
1091 | ) | |
1092 | else: | |
1093 | try: | |
1094 | static_limits = list( | |
1095 | LimitGroup( | |
1096 | self.limit_value, | |
1097 | self.key_func, | |
1098 | self.scope, | |
1099 | self.per_method, | |
1100 | self.methods, | |
1101 | self.error_message, | |
1102 | self.exempt_when, | |
1103 | self.override_defaults, | |
1104 | self.deduct_when, | |
1105 | self.on_breach, | |
1106 | self.cost, | |
1107 | ) | |
1108 | ) | |
1109 | except ValueError as e: | |
1110 | self.limiter.logger.error( | |
1111 | "failed to configure %s %s (%s)", | |
1112 | "view function" if is_route else "blueprint", | |
1113 | name, | |
1114 | e, | |
1115 | ) | |
1131 | name = get_qualified_name(obj) | |
1116 | 1132 | |
1117 | 1133 | if isinstance(obj, flask.Blueprint): |
1118 | if dynamic_limit: | |
1119 | self.limiter.limit_manager.add_runtime_blueprint_limits( | |
1120 | name, dynamic_limit | |
1121 | ) | |
1122 | else: | |
1123 | self.limiter.limit_manager.add_static_blueprint_limits( | |
1124 | name, *static_limits | |
1125 | ) | |
1134 | self.limiter.limit_manager.add_blueprint_limit(name, self.limit_group) | |
1126 | 1135 | return None |
1127 | 1136 | else: |
1128 | 1137 | self.limiter._marked_for_limiting.add(name) |
1129 | ||
1130 | if dynamic_limit: | |
1131 | self.limiter.limit_manager.add_runtime_route_limits(name, dynamic_limit) | |
1132 | else: | |
1133 | self.limiter.limit_manager.add_static_route_limits(name, *static_limits) | |
1138 | self.limiter.limit_manager.add_decorated_limit(name, self.limit_group) | |
1134 | 1139 | |
1135 | 1140 | @wraps(obj) |
1136 | 1141 | def __inner(*a: P.args, **k: P.kwargs) -> R: |
1137 | 1142 | if ( |
1138 | 1143 | self.limiter._auto_check |
1139 | and not self.limiter.context.rate_limiting_complete | |
1144 | and not self.limiter.context.rate_limiting_complete.get(name, False) | |
1140 | 1145 | ): |
1141 | self.limiter._check_request_limit(False) | |
1142 | self.limiter.context.rate_limiting_complete = True | |
1146 | if flask.request.endpoint: | |
1147 | view_func = flask.current_app.view_functions.get( | |
1148 | flask.request.endpoint, None | |
1149 | ) | |
1150 | if view_func and not get_qualified_name(view_func) == name: | |
1151 | self.limiter.limit_manager.add_endpoint_hint( | |
1152 | flask.request.endpoint, name | |
1153 | ) | |
1154 | ||
1155 | self.limiter._check_request_limit( | |
1156 | in_middleware=False, callable_name=name | |
1157 | ) | |
1158 | ||
1159 | self.limiter.context.rate_limiting_complete[name] = True | |
1143 | 1160 | return cast( |
1144 | 1161 | R, flask.current_app.ensure_sync(cast(Callable[P, R], obj))(*a, **k) |
1145 | 1162 | ) |
4 | 4 | from typing import Dict, Iterable, List, Optional, Tuple |
5 | 5 | |
6 | 6 | import flask |
7 | from ordered_set import OrderedSet | |
7 | 8 | |
8 | 9 | from .constants import ExemptionScope |
10 | from .util import get_qualified_name | |
9 | 11 | from .wrappers import Limit, LimitGroup |
10 | 12 | |
11 | 13 | |
14 | 16 | self, |
15 | 17 | application_limits: List[LimitGroup], |
16 | 18 | default_limits: List[LimitGroup], |
17 | static_route_limits: Dict[str, List[Limit]], | |
18 | dynamic_route_limits: Dict[str, List[LimitGroup]], | |
19 | static_blueprint_limits: Dict[str, List[Limit]], | |
20 | dynamic_blueprint_limits: Dict[str, List[LimitGroup]], | |
19 | decorated_limits: Dict[str, OrderedSet[LimitGroup]], | |
20 | blueprint_limits: Dict[str, OrderedSet[LimitGroup]], | |
21 | 21 | route_exemptions: Dict[str, ExemptionScope], |
22 | 22 | blueprint_exemptions: Dict[str, ExemptionScope], |
23 | 23 | ) -> None: |
24 | 24 | self._application_limits = application_limits |
25 | 25 | self._default_limits = default_limits |
26 | self._static_route_limits = static_route_limits | |
27 | self._runtime_route_limits = dynamic_route_limits | |
28 | self._static_blueprint_limits = static_blueprint_limits | |
29 | self._runtime_blueprint_limits = dynamic_blueprint_limits | |
26 | self._decorated_limits = decorated_limits | |
27 | self._blueprint_limits = blueprint_limits | |
30 | 28 | self._route_exemptions = route_exemptions |
31 | 29 | self._blueprint_exemptions = blueprint_exemptions |
30 | self._endpoint_hints: Dict[str, OrderedSet[str]] = {} | |
32 | 31 | self._logger = logging.getLogger("flask-limiter") |
33 | 32 | |
34 | 33 | @property |
45 | 44 | def set_default_limits(self, limits: List[LimitGroup]) -> None: |
46 | 45 | self._default_limits = limits |
47 | 46 | |
48 | def add_runtime_route_limits(self, route: str, limit: LimitGroup) -> None: | |
49 | self._runtime_route_limits.setdefault(route, []).append(limit) | |
50 | ||
51 | def add_runtime_blueprint_limits(self, blueprint: str, limit: LimitGroup) -> None: | |
52 | self._runtime_blueprint_limits.setdefault(blueprint, []).append(limit) | |
53 | ||
54 | def add_static_route_limits(self, route: str, *limits: Limit) -> None: | |
55 | self._static_route_limits.setdefault(route, []).extend(limits) | |
56 | ||
57 | def add_static_blueprint_limits(self, blueprint: str, *limits: Limit) -> None: | |
58 | self._static_blueprint_limits.setdefault(blueprint, []).extend(limits) | |
47 | def add_decorated_limit( | |
48 | self, route: str, limit: Optional[LimitGroup], override: bool = False | |
49 | ) -> None: | |
50 | if limit: | |
51 | if not override: | |
52 | self._decorated_limits.setdefault(route, OrderedSet()).add(limit) | |
53 | else: | |
54 | self._decorated_limits[route] = OrderedSet([limit]) | |
55 | ||
56 | def add_blueprint_limit(self, blueprint: str, limit: Optional[LimitGroup]) -> None: | |
57 | if limit: | |
58 | self._blueprint_limits.setdefault(blueprint, OrderedSet()).add(limit) | |
59 | 59 | |
60 | 60 | def add_route_exemption(self, route: str, scope: ExemptionScope) -> None: |
61 | 61 | self._route_exemptions[route] = scope |
62 | 62 | |
63 | 63 | def add_blueprint_exemption(self, blueprint: str, scope: ExemptionScope) -> None: |
64 | 64 | self._blueprint_exemptions[blueprint] = scope |
65 | ||
66 | def add_endpoint_hint(self, endpoint: str, callable: str) -> None: | |
67 | self._endpoint_hints.setdefault(endpoint, OrderedSet()).add(callable) | |
68 | ||
69 | def has_hints(self, endpoint: str) -> bool: | |
70 | return bool(self._endpoint_hints.get(endpoint)) | |
65 | 71 | |
66 | 72 | def resolve_limits( |
67 | 73 | self, |
68 | 74 | app: flask.Flask, |
69 | 75 | endpoint: Optional[str] = None, |
70 | 76 | blueprint: Optional[str] = None, |
77 | callable_name: Optional[str] = None, | |
71 | 78 | in_middleware: bool = False, |
72 | 79 | marked_for_limiting: bool = False, |
73 | 80 | fallback_limits: Optional[List[Limit]] = None, |
74 | 81 | ) -> List[Limit]: |
75 | 82 | before_request_context = in_middleware and marked_for_limiting |
76 | route_limits = [] | |
77 | if not in_middleware and endpoint: | |
78 | route_limits.extend(self.route_limits(app, endpoint)) | |
83 | decorated_limits = [] | |
84 | hinted_limits = [] | |
85 | if endpoint: | |
86 | if not in_middleware: | |
87 | if not callable_name: | |
88 | view_func = app.view_functions.get(endpoint, None) | |
89 | name = get_qualified_name(view_func) if view_func else "" | |
90 | else: | |
91 | name = callable_name | |
92 | decorated_limits.extend(self.decorated_limits(name)) | |
93 | ||
94 | for hint in self._endpoint_hints.get(endpoint, OrderedSet()): | |
95 | hinted_limits.extend(self.decorated_limits(hint)) | |
96 | ||
79 | 97 | if blueprint: |
80 | 98 | if not before_request_context and ( |
81 | not route_limits | |
82 | or all(not limit.override_defaults for limit in route_limits) | |
99 | not decorated_limits | |
100 | or all(not limit.override_defaults for limit in decorated_limits) | |
83 | 101 | ): |
84 | route_limits.extend(self.blueprint_limits(app, blueprint)) | |
102 | decorated_limits.extend(self.blueprint_limits(app, blueprint)) | |
85 | 103 | exemption_scope = self.exemption_scope(app, endpoint, blueprint) |
86 | 104 | all_limits = [] |
87 | 105 | |
94 | 112 | if in_middleware and not (exemption_scope & ExemptionScope.APPLICATION) |
95 | 113 | else [] |
96 | 114 | ) |
97 | all_limits += route_limits | |
98 | explicit_limits_exempt = all(limit.method_exempt for limit in route_limits) | |
115 | all_limits += decorated_limits | |
116 | explicit_limits_exempt = all( | |
117 | limit.method_exempt for limit in decorated_limits | |
118 | ) | |
119 | ||
120 | # all the decorated limits explicitly declared | |
121 | # that they don't override the defaults - so, they should | |
122 | # be included. | |
99 | 123 | combined_defaults = all( |
100 | not limit.override_defaults for limit in route_limits | |
101 | ) | |
102 | ||
103 | if (explicit_limits_exempt or combined_defaults) and not ( | |
104 | before_request_context or exemption_scope & ExemptionScope.DEFAULT | |
105 | ): | |
124 | not limit.override_defaults for limit in decorated_limits | |
125 | ) | |
126 | # previous requests to this endpoint have exercised decorated | |
127 | # rate limits on callables that are not view functions. check | |
128 | # if all of them declared that they don't override defaults | |
129 | # and if so include the default limits. | |
130 | hinted_limits_request_defaults = ( | |
131 | all(not limit.override_defaults for limit in hinted_limits) | |
132 | if hinted_limits | |
133 | else False | |
134 | ) | |
135 | if ( | |
136 | (explicit_limits_exempt or combined_defaults) | |
137 | and ( | |
138 | not ( | |
139 | before_request_context | |
140 | or exemption_scope & ExemptionScope.DEFAULT | |
141 | ) | |
142 | ) | |
143 | ) or hinted_limits_request_defaults: | |
106 | 144 | all_limits += self.default_limits |
107 | 145 | return all_limits |
108 | 146 | |
110 | 148 | self, app: flask.Flask, endpoint: Optional[str], blueprint: Optional[str] |
111 | 149 | ) -> ExemptionScope: |
112 | 150 | view_func = app.view_functions.get(endpoint or "", None) |
113 | name = f"{view_func.__module__}.{view_func.__name__}" if view_func else "" | |
151 | name = get_qualified_name(view_func) if view_func else "" | |
114 | 152 | route_exemption_scope = self._route_exemptions[name] |
115 | 153 | blueprint_instance = app.blueprints.get(blueprint) if blueprint else None |
116 | 154 | |
131 | 169 | blueprint_exemption_scope |= exemption |
132 | 170 | return route_exemption_scope | blueprint_exemption_scope |
133 | 171 | |
134 | def route_limits(self, app: flask.Flask, endpoint: str) -> List[Limit]: | |
135 | view_func = app.view_functions.get(endpoint, None) | |
136 | name = f"{view_func.__module__}.{view_func.__name__}" if view_func else "" | |
137 | ||
172 | def decorated_limits(self, callable_name: str) -> List[Limit]: | |
138 | 173 | limits = [] |
139 | if not self._route_exemptions[name]: | |
140 | for limit in self._static_route_limits.get(name, []): | |
141 | limits.append(limit) | |
142 | ||
143 | if name in self._runtime_route_limits: | |
144 | for group in self._runtime_route_limits[name]: | |
174 | if not self._route_exemptions[callable_name]: | |
175 | if callable_name in self._decorated_limits: | |
176 | for group in self._decorated_limits[callable_name]: | |
145 | 177 | try: |
146 | 178 | for limit in group: |
147 | 179 | limits.append(limit) |
148 | 180 | except ValueError as e: |
149 | 181 | self._logger.error( |
150 | f"failed to load ratelimit for view function {name}: {e}", | |
182 | f"failed to load ratelimit for function {callable_name}: {e}", | |
151 | 183 | ) |
152 | 184 | return limits |
153 | 185 | |
165 | 197 | |
166 | 198 | if not ( |
167 | 199 | self_exemption & ~(ExemptionScope.DEFAULT | ExemptionScope.APPLICATION) |
168 | or ancestor_exemptions | |
169 | 200 | ): |
170 | blueprint_self_dynamic_limits = self._runtime_blueprint_limits.get( | |
171 | blueprint_name, [] | |
201 | blueprint_self_limits = self._blueprint_limits.get( | |
202 | blueprint_name, OrderedSet() | |
172 | 203 | ) |
173 | blueprint_dynamic_limits: Iterable[LimitGroup] = ( | |
204 | blueprint_limits: Iterable[LimitGroup] = ( | |
174 | 205 | itertools.chain( |
175 | 206 | *( |
176 | self._runtime_blueprint_limits.get(member, []) | |
207 | self._blueprint_limits.get(member, []) | |
177 | 208 | for member in blueprint_ancestory.intersection( |
178 | self._runtime_blueprint_limits | |
179 | ) | |
209 | self._blueprint_limits | |
210 | ).difference(ancestor_exemptions) | |
180 | 211 | ) |
181 | 212 | ) |
182 | 213 | if not ( |
183 | blueprint_self_dynamic_limits | |
184 | or all( | |
185 | limit.override_defaults | |
186 | for limit in blueprint_self_dynamic_limits | |
214 | blueprint_self_limits | |
215 | and all( | |
216 | limit.override_defaults for limit in blueprint_self_limits | |
187 | 217 | ) |
188 | 218 | ) |
189 | 219 | and not self._blueprint_exemptions[blueprint_name] |
190 | 220 | & ExemptionScope.ANCESTORS |
191 | else blueprint_self_dynamic_limits | |
221 | else blueprint_self_limits | |
192 | 222 | ) |
193 | if blueprint_dynamic_limits: | |
194 | for limit_group in blueprint_dynamic_limits: | |
223 | if blueprint_limits: | |
224 | for limit_group in blueprint_limits: | |
195 | 225 | try: |
196 | 226 | limits.extend( |
197 | 227 | [ |
207 | 237 | limit.deduct_when, |
208 | 238 | limit.on_breach, |
209 | 239 | limit.cost, |
240 | limit.shared, | |
210 | 241 | ) |
211 | 242 | for limit in limit_group |
212 | 243 | ] |
215 | 246 | self._logger.error( |
216 | 247 | f"failed to load ratelimit for blueprint {blueprint_name}: {e}", |
217 | 248 | ) |
218 | blueprint_self_limits = self._static_blueprint_limits.get( | |
219 | blueprint_name, [] | |
220 | ) | |
221 | if ( | |
222 | not ( | |
223 | blueprint_self_limits | |
224 | and all(limit.override_defaults for limit in blueprint_self_limits) | |
225 | ) | |
226 | and not self._blueprint_exemptions[blueprint_name] | |
227 | & ExemptionScope.ANCESTORS | |
228 | ): | |
229 | for member in blueprint_ancestory.intersection( | |
230 | self._static_blueprint_limits | |
231 | ).difference(ancestor_exemptions): | |
232 | limits.extend(self._static_blueprint_limits[member]) | |
233 | else: | |
234 | limits.extend(blueprint_self_limits) | |
235 | 249 | return limits |
236 | 250 | |
237 | 251 | def _blueprint_exemption_scope( |
0 | 0 | from __future__ import annotations |
1 | ||
2 | from typing import Any, Callable | |
1 | 3 | |
2 | 4 | from flask import request |
3 | 5 | |
9 | 11 | |
10 | 12 | """ |
11 | 13 | return request.remote_addr or "127.0.0.1" |
14 | ||
15 | ||
16 | def get_qualified_name(callable: Callable[..., Any]) -> str: | |
17 | """ | |
18 | Generate the fully qualified name of a callable for use in storing | |
19 | mappings of decorated functions to rate limits | |
20 | ||
21 | The __qualname__ of the callable is appended in case there is a name | |
22 | clash in a module due to locally scoped functions that are decorated. | |
23 | ||
24 | TODO: Ideally __qualname__ should be enough, however view functions | |
25 | generated by class based views do not update that and therefore | |
26 | would not be uniquely identifiable unless __module__ & __name__ | |
27 | are inspected. | |
28 | ||
29 | :meta private: | |
30 | """ | |
31 | return f"{callable.__module__}.{callable.__name__}.{callable.__qualname__}" |
0 | 0 | from __future__ import annotations |
1 | 1 | |
2 | import dataclasses | |
2 | 3 | import typing |
3 | 4 | import weakref |
4 | from typing import Callable, Iterator, List, Optional, Sequence, Tuple, Union | |
5 | from typing import Callable, Iterator, List, Optional, Tuple, Union | |
5 | 6 | |
6 | 7 | from flask import request |
7 | 8 | from flask.wrappers import Response |
64 | 65 | return self.window[1] |
65 | 66 | |
66 | 67 | |
68 | @dataclasses.dataclass(eq=True, unsafe_hash=True) | |
67 | 69 | class Limit: |
68 | 70 | """ |
69 | 71 | simple wrapper to encapsulate limits and their context |
70 | 72 | """ |
71 | 73 | |
72 | def __init__( | |
73 | self, | |
74 | limit: RateLimitItem, | |
75 | key_func: Callable[[], str], | |
76 | scope: Optional[Union[str, Callable[[str], str]]], | |
77 | per_method: bool, | |
78 | methods: Optional[Sequence[str]], | |
79 | error_message: Optional[str], | |
80 | exempt_when: Optional[Callable[[], bool]], | |
81 | override_defaults: Optional[bool], | |
82 | deduct_when: Optional[Callable[[Response], bool]], | |
83 | on_breach: Optional[Callable[[RequestLimit], Optional[Response]]], | |
84 | cost: Union[Callable[[], int], int], | |
85 | ) -> None: | |
86 | self.limit = limit | |
87 | self.key_func = key_func | |
88 | self.__scope = scope | |
89 | self.per_method = per_method | |
90 | self.methods = methods | |
91 | self.error_message = error_message | |
92 | self.exempt_when = exempt_when | |
93 | self.override_defaults = override_defaults | |
94 | self.deduct_when = deduct_when | |
95 | self.on_breach = on_breach | |
96 | self._cost = cost | |
74 | limit: RateLimitItem | |
75 | key_func: Callable[[], str] | |
76 | _scope: Optional[Union[str, Callable[[str], str]]] | |
77 | per_method: bool = False | |
78 | methods: Optional[Tuple[str, ...]] = None | |
79 | error_message: Optional[str] = None | |
80 | exempt_when: Optional[Callable[[], bool]] = None | |
81 | override_defaults: Optional[bool] = False | |
82 | deduct_when: Optional[Callable[[Response], bool]] = None | |
83 | on_breach: Optional[Callable[[RequestLimit], Optional[Response]]] = None | |
84 | _cost: Union[Callable[[], int], int] = 1 | |
85 | shared: bool = False | |
86 | ||
87 | def __post_init__(self) -> None: | |
88 | if self.methods: | |
89 | self.methods = tuple([k.lower() for k in self.methods]) | |
97 | 90 | |
98 | 91 | @property |
99 | 92 | def is_exempt(self) -> bool: |
106 | 99 | @property |
107 | 100 | def scope(self) -> Optional[str]: |
108 | 101 | return ( |
109 | self.__scope(request.endpoint or "") | |
110 | if callable(self.__scope) | |
111 | else self.__scope | |
102 | self._scope(request.endpoint or "") | |
103 | if callable(self._scope) | |
104 | else self._scope | |
112 | 105 | ) |
113 | 106 | |
114 | 107 | @property |
124 | 117 | |
125 | 118 | return self.methods is not None and request.method.lower() not in self.methods |
126 | 119 | |
127 | def args_for(self, endpoint: str, key: str, method: Optional[str]) -> List[str]: | |
128 | scope = self.scope or endpoint | |
120 | def scope_for(self, endpoint: str, method: Optional[str]) -> str: | |
121 | """ | |
122 | Derive final bucket (scope) for this limit given the endpoint | |
123 | and request method. If the limit is shared between multiple | |
124 | routes, the scope does not include the endpoint. | |
125 | """ | |
126 | limit_scope = self.scope | |
127 | if limit_scope: | |
128 | if self.shared: | |
129 | scope = limit_scope | |
130 | else: | |
131 | scope = f"{endpoint}:{limit_scope}" | |
132 | else: | |
133 | scope = endpoint | |
134 | ||
129 | 135 | if self.per_method: |
130 | 136 | assert method |
131 | 137 | scope += f":{method.upper()}" |
132 | args = [key, scope] | |
133 | return args | |
138 | return scope | |
134 | 139 | |
135 | 140 | |
141 | @dataclasses.dataclass(eq=True, unsafe_hash=True) | |
136 | 142 | class LimitGroup: |
137 | 143 | """ |
138 | 144 | represents a group of related limits either from a string or a callable |
139 | 145 | that returns one |
140 | 146 | """ |
141 | 147 | |
142 | def __init__( | |
143 | self, | |
144 | limit_provider: Union[Callable[[], str], str], | |
145 | key_function: Callable[[], str], | |
146 | scope: Optional[Union[str, Callable[[str], str]]], | |
147 | per_method: bool, | |
148 | methods: Optional[Sequence[str]], | |
149 | error_message: Optional[str], | |
150 | exempt_when: Optional[Callable[[], bool]], | |
151 | override_defaults: Optional[bool], | |
152 | deduct_when: Optional[Callable[[Response], bool]], | |
153 | on_breach: Optional[Callable[[RequestLimit], Optional[Response]]], | |
154 | cost: Optional[Union[Callable[[], int], int]], | |
155 | ) -> None: | |
156 | self.__limit_provider = limit_provider | |
157 | self.__scope = scope | |
158 | self.key_function = key_function | |
159 | self.per_method = per_method | |
160 | self.methods = methods and [m.lower() for m in methods] or methods | |
161 | self.error_message = error_message | |
162 | self.exempt_when = exempt_when | |
163 | self.override_defaults = override_defaults | |
164 | self.deduct_when = deduct_when | |
165 | self.on_breach = on_breach | |
166 | self.cost = cost or 1 | |
148 | limit_provider: Union[Callable[[], str], str] | |
149 | key_function: Callable[[], str] | |
150 | scope: Optional[Union[str, Callable[[str], str]]] = None | |
151 | methods: Optional[Tuple[str, ...]] = None | |
152 | error_message: Optional[str] = None | |
153 | exempt_when: Optional[Callable[[], bool]] = None | |
154 | override_defaults: Optional[bool] = False | |
155 | deduct_when: Optional[Callable[[Response], bool]] = None | |
156 | on_breach: Optional[Callable[[RequestLimit], Optional[Response]]] = None | |
157 | per_method: bool = False | |
158 | cost: Optional[Union[Callable[[], int], int]] = None | |
159 | shared: bool = False | |
167 | 160 | |
168 | 161 | def __iter__(self) -> Iterator[Limit]: |
169 | limit_items = parse_many( | |
170 | self.__limit_provider() | |
171 | if callable(self.__limit_provider) | |
172 | else self.__limit_provider | |
162 | limit_str = ( | |
163 | self.limit_provider() | |
164 | if callable(self.limit_provider) | |
165 | else self.limit_provider | |
173 | 166 | ) |
167 | limit_items = parse_many(limit_str) if limit_str else [] | |
174 | 168 | |
175 | 169 | for limit in limit_items: |
176 | 170 | yield Limit( |
177 | 171 | limit, |
178 | 172 | self.key_function, |
179 | self.__scope, | |
173 | self.scope, | |
180 | 174 | self.per_method, |
181 | 175 | self.methods, |
182 | 176 | self.error_message, |
184 | 178 | self.override_defaults, |
185 | 179 | self.deduct_when, |
186 | 180 | self.on_breach, |
187 | self.cost, | |
181 | self.cost or 1, | |
182 | self.shared, | |
188 | 183 | ) |
0 | 0 | -r main.txt |
1 | 1 | enum-tools[sphinx]==0.9.0.post1 |
2 | furo==2022.6.21 | |
2 | furo==2022.12.7 | |
3 | 3 | Sphinx>4,<6 |
4 | 4 | sphinx-autobuild==2021.3.14 |
5 | sphinx-copybutton==0.5.0 | |
5 | sphinx-copybutton==0.5.1 | |
6 | 6 | sphinx-inline-tabs==2022.1.2b11 |
7 | 7 | sphinx-issues==3.0.1 |
8 | sphinxext-opengraph==0.6.3 | |
8 | sphinxext-opengraph==0.7.4 | |
9 | 9 | sphinx-paramlinks==0.5.4 |
10 | 10 | sphinxcontrib-programoutput==0.17 |
11 | 11 |
0 | limits>=2.3 | |
0 | limits>=2.8 | |
1 | 1 | Flask>=2 |
2 | ordered-set>4,<5 | |
2 | 3 | rich>=12,<13 |
3 | typing_extensions | |
4 | typing_extensions>=4 |
10 | 10 | pymongo |
11 | 11 | |
12 | 12 | # For the tests themselves |
13 | coverage | |
13 | coverage<8 | |
14 | 14 | hiro>0.1.6 |
15 | 15 | pytest |
16 | 16 | pytest-cov |
51 | 51 | |
52 | 52 | for k, v in config.items(): |
53 | 53 | app.config.setdefault(k, v) |
54 | limiter_args.setdefault("key_func", get_remote_address) | |
55 | limiter = Limiter(app, **limiter_args) | |
54 | key_func = limiter_args.pop("key_func", get_remote_address) | |
55 | limiter = Limiter(key_func, app=app, **limiter_args) | |
56 | 56 | |
57 | 57 | return app, limiter |
58 | 58 | |
116 | 116 | |
117 | 117 | |
118 | 118 | @pytest.fixture(scope="session") |
119 | def docker_services_project_name(): | |
120 | return "flask-limiter" | |
121 | ||
122 | ||
123 | @pytest.fixture(scope="session") | |
119 | 124 | def docker_compose_files(pytestconfig): |
120 | 125 | return ["docker-compose.yml"] |
0 | 0 | import datetime |
1 | import logging | |
1 | 2 | |
2 | 3 | import hiro |
3 | 4 | from flask import Blueprint, Flask, current_app |
492 | 493 | |
493 | 494 | |
494 | 495 | def test_invalid_decorated_static_limit_blueprint(caplog): |
496 | caplog.set_level(logging.INFO) | |
495 | 497 | app = Flask(__name__) |
496 | limiter = Limiter(app, default_limits=["1/second"], key_func=get_remote_address) | |
498 | limiter = Limiter(get_remote_address, app=app, default_limits=["1/second"]) | |
497 | 499 | bp = Blueprint("bp1", __name__) |
498 | 500 | |
499 | 501 | @bp.route("/t1") |
507 | 509 | with hiro.Timeline().freeze(): |
508 | 510 | assert cli.get("/t1").status_code == 200 |
509 | 511 | assert cli.get("/t1").status_code == 429 |
510 | assert "failed to configure" in caplog.records[0].msg | |
511 | assert "exceeded at endpoint" in caplog.records[1].msg | |
512 | assert "failed to load" in caplog.records[0].msg | |
513 | assert "exceeded at endpoint" in caplog.records[-1].msg | |
512 | 514 | |
513 | 515 | |
514 | 516 | def test_invalid_decorated_dynamic_limits_blueprint(caplog): |
517 | caplog.set_level(logging.INFO) | |
515 | 518 | app = Flask(__name__) |
516 | 519 | app.config.setdefault("X", "2 per sec") |
517 | limiter = Limiter(app, default_limits=["1/second"], key_func=get_remote_address) | |
520 | limiter = Limiter(get_remote_address, app=app, default_limits=["1/second"]) | |
518 | 521 | bp = Blueprint("bp1", __name__) |
519 | 522 | |
520 | 523 | @bp.route("/t1") |
16 | 16 | app = Flask(__name__) |
17 | 17 | app.config.setdefault(ConfigVars.STRATEGY, "fubar") |
18 | 18 | with pytest.raises(ConfigurationError): |
19 | Limiter(app, key_func=get_remote_address) | |
19 | Limiter(get_remote_address, app=app) | |
20 | 20 | |
21 | 21 | |
22 | 22 | def test_invalid_storage_string(): |
23 | 23 | app = Flask(__name__) |
24 | 24 | app.config.setdefault(ConfigVars.STORAGE_URI, "fubar://localhost:1234") |
25 | 25 | with pytest.raises(ConfigurationError): |
26 | Limiter(app, key_func=get_remote_address) | |
26 | Limiter(get_remote_address, app=app) | |
27 | 27 | |
28 | 28 | |
29 | 29 | def test_constructor_arguments_over_config(redis_connection): |
30 | 30 | app = Flask(__name__) |
31 | 31 | app.config.setdefault(ConfigVars.STRATEGY, "fixed-window-elastic-expiry") |
32 | limiter = Limiter(strategy="moving-window", key_func=get_remote_address) | |
32 | limiter = Limiter(get_remote_address, strategy="moving-window") | |
33 | 33 | limiter.init_app(app) |
34 | 34 | app.config.setdefault(ConfigVars.STORAGE_URI, "redis://localhost:46379") |
35 | 35 | app.config.setdefault(ConfigVars.APPLICATION_LIMITS, "1/minute") |
36 | 36 | assert type(limiter._limiter) == MovingWindowRateLimiter |
37 | limiter = Limiter( | |
38 | storage_uri="memcached://localhost:31211", key_func=get_remote_address | |
39 | ) | |
37 | limiter = Limiter(get_remote_address, storage_uri="memcached://localhost:31211") | |
40 | 38 | limiter.init_app(app) |
41 | 39 | assert type(limiter._storage) == MemcachedStorage |
42 | 40 | |
47 | 45 | app.config.setdefault(ConfigVars.HEADER_REMAINING, "XX-Remaining") |
48 | 46 | app.config.setdefault(ConfigVars.HEADER_RESET, "XX-Reset") |
49 | 47 | limiter = Limiter( |
50 | key_func=get_remote_address, headers_enabled=True, default_limits=["1/second"] | |
48 | get_remote_address, headers_enabled=True, default_limits=["1/second"] | |
51 | 49 | ) |
52 | 50 | limiter.init_app(app) |
53 | 51 | |
65 | 63 | def test_header_names_constructor(): |
66 | 64 | app = Flask(__name__) |
67 | 65 | limiter = Limiter( |
68 | key_func=get_remote_address, | |
66 | get_remote_address, | |
69 | 67 | headers_enabled=True, |
70 | 68 | default_limits=["1/second"], |
71 | 69 | header_name_mapping={ |
92 | 90 | app.config.setdefault(ConfigVars.ENABLED, False) |
93 | 91 | app.config.setdefault(ConfigVars.STORAGE_URI, "fubar://") |
94 | 92 | |
95 | limiter = Limiter(app, key_func=get_remote_address, default_limits=["1/hour"]) | |
93 | limiter = Limiter(get_remote_address, app=app, default_limits=["1/hour"]) | |
96 | 94 | |
97 | 95 | @app.route("/") |
98 | 96 | def root(): |
113 | 111 | |
114 | 112 | def test_uninitialized_limiter(): |
115 | 113 | app = Flask(__name__) |
116 | limiter = Limiter(key_func=get_remote_address, default_limits=["1/hour"]) | |
114 | limiter = Limiter(get_remote_address, default_limits=["1/hour"]) | |
117 | 115 | |
118 | 116 | @app.route("/") |
119 | 117 | @limiter.limit("2/hour") |
0 | import hiro | |
1 | ||
2 | from flask_limiter import RateLimitExceeded | |
3 | ||
4 | ||
5 | def test_static_limit(extension_factory): | |
6 | app, limiter = extension_factory() | |
7 | ||
8 | @app.route("/t1") | |
9 | def t1(): | |
10 | with limiter.limit("1/second"): | |
11 | resp = "ok" | |
12 | try: | |
13 | with limiter.limit("1/day"): | |
14 | resp += "maybe" | |
15 | except RateLimitExceeded: | |
16 | pass | |
17 | finally: | |
18 | return resp | |
19 | ||
20 | with hiro.Timeline().freeze() as timeline: | |
21 | with app.test_client() as cli: | |
22 | response = cli.get("/t1") | |
23 | assert 200 == response.status_code | |
24 | assert "okmaybe" == response.text | |
25 | assert 429 == cli.get("/t1").status_code | |
26 | timeline.forward(1) | |
27 | response = cli.get("/t1") | |
28 | assert 200 == response.status_code | |
29 | assert "ok" == response.text | |
30 | ||
31 | ||
32 | def test_dynamic_limits(extension_factory): | |
33 | app, limiter = extension_factory() | |
34 | ||
35 | @app.route("/t1") | |
36 | def t1(): | |
37 | with limiter.limit(lambda: "1/second"): | |
38 | return "test" | |
39 | ||
40 | with hiro.Timeline().freeze(): | |
41 | with app.test_client() as cli: | |
42 | assert 200 == cli.get("/t1").status_code | |
43 | assert 429 == cli.get("/t1").status_code | |
44 | ||
45 | ||
46 | def test_scoped_context_manager(extension_factory): | |
47 | app, limiter = extension_factory() | |
48 | ||
49 | @app.route("/t1/<int:param>") | |
50 | def t1(param: int): | |
51 | with limiter.limit("1/second", scope=param): | |
52 | return "p1" | |
53 | ||
54 | with hiro.Timeline().freeze(): | |
55 | with app.test_client() as cli: | |
56 | assert 200 == cli.get("/t1/1").status_code | |
57 | assert 429 == cli.get("/t1/1").status_code | |
58 | assert 200 == cli.get("/t1/2").status_code | |
59 | assert 429 == cli.get("/t1/2").status_code |
0 | 0 | import asyncio |
1 | import logging | |
1 | 2 | from functools import wraps |
2 | 3 | from unittest import mock |
3 | 4 | |
6 | 7 | from werkzeug.exceptions import BadRequest |
7 | 8 | |
8 | 9 | from flask_limiter import ExemptionScope, Limiter |
10 | from flask_limiter.util import get_remote_address | |
9 | 11 | |
10 | 12 | |
11 | 13 | def get_ip_from_header(): |
17 | 19 | |
18 | 20 | @app.route("/t1") |
19 | 21 | @limiter.limit( |
20 | "100 per minute", lambda: "test" | |
22 | "100 per minute", key_func=lambda: "test" | |
21 | 23 | ) # effectively becomes a limit for all users |
22 | 24 | @limiter.limit("50/minute") # per ip as per default key_func |
23 | 25 | def t1(): |
70 | 72 | assert cli.get("/t3").status_code == 429 |
71 | 73 | # 2/minute for application is now taken up |
72 | 74 | assert cli.get("/t4").status_code == 429 |
75 | ||
76 | ||
77 | def test_decorated_limit_with_scope(extension_factory): | |
78 | app, limiter = extension_factory() | |
79 | ||
80 | @app.route("/t/<path:path>") | |
81 | @limiter.limit("1/second", scope=lambda _: request.view_args["path"]) | |
82 | def t(path): | |
83 | return "test" | |
84 | ||
85 | with hiro.Timeline(): | |
86 | with app.test_client() as cli: | |
87 | assert cli.get("/t/1").status_code == 200 | |
88 | assert cli.get("/t/1").status_code == 429 | |
89 | assert cli.get("/t/2").status_code == 200 | |
90 | assert cli.get("/t/2").status_code == 429 | |
73 | 91 | |
74 | 92 | |
75 | 93 | def test_decorated_limit_with_conditional_deduction(extension_factory): |
291 | 309 | |
292 | 310 | |
293 | 311 | def test_invalid_decorated_dynamic_limits(caplog): |
312 | caplog.set_level(logging.INFO) | |
294 | 313 | app = Flask(__name__) |
295 | 314 | app.config.setdefault("X", "2 per sec") |
296 | limiter = Limiter(app, default_limits=["1/second"], key_func=get_ip_from_header) | |
315 | limiter = Limiter(get_ip_from_header, app=app, default_limits=["1/second"]) | |
297 | 316 | |
298 | 317 | @app.route("/t1") |
299 | 318 | @limiter.limit(lambda: current_app.config.get("X")) |
309 | 328 | assert "failed to load ratelimit" in caplog.records[0].msg |
310 | 329 | assert "failed to load ratelimit" in caplog.records[1].msg |
311 | 330 | assert "exceeded at endpoint" in caplog.records[2].msg |
312 | assert caplog.records[2].levelname == "WARNING" | |
331 | assert caplog.records[2].levelname == "INFO" | |
332 | ||
333 | ||
334 | def test_decorated_limit_empty_exempt(caplog): | |
335 | app = Flask(__name__) | |
336 | limiter = Limiter(get_remote_address, app=app) | |
337 | ||
338 | @app.route("/t1") | |
339 | @limiter.limit(lambda: "") | |
340 | def t1(): | |
341 | return "42" | |
342 | ||
343 | with app.test_client() as cli: | |
344 | with hiro.Timeline().freeze(): | |
345 | assert cli.get("/t1").status_code == 200 | |
346 | assert cli.get("/t1").status_code == 200 | |
347 | ||
348 | assert not caplog.records | |
313 | 349 | |
314 | 350 | |
315 | 351 | def test_invalid_decorated_static_limits(caplog): |
352 | caplog.set_level(logging.INFO) | |
316 | 353 | app = Flask(__name__) |
317 | limiter = Limiter(app, default_limits=["1/second"], key_func=get_ip_from_header) | |
354 | limiter = Limiter(get_ip_from_header, app=app, default_limits=["1/second"]) | |
318 | 355 | |
319 | 356 | @app.route("/t1") |
320 | 357 | @limiter.limit("2/sec") |
325 | 362 | with hiro.Timeline().freeze(): |
326 | 363 | assert cli.get("/t1").status_code == 200 |
327 | 364 | assert cli.get("/t1").status_code == 429 |
328 | assert "failed to configure" in caplog.records[0].msg | |
329 | assert "exceeded at endpoint" in caplog.records[1].msg | |
365 | assert "failed to load" in caplog.records[0].msg | |
366 | assert "exceeded at endpoint" in caplog.records[-1].msg | |
330 | 367 | |
331 | 368 | |
332 | 369 | def test_named_shared_limit(extension_factory): |
396 | 433 | def test_conditional_limits(): |
397 | 434 | """Test that the conditional activation of the limits work.""" |
398 | 435 | app = Flask(__name__) |
399 | limiter = Limiter(app, key_func=get_ip_from_header) | |
436 | limiter = Limiter(get_ip_from_header, app=app) | |
400 | 437 | |
401 | 438 | @app.route("/limited") |
402 | 439 | @limiter.limit("1 per day") |
433 | 470 | def test_conditional_shared_limits(): |
434 | 471 | """Test that conditional shared limits work.""" |
435 | 472 | app = Flask(__name__) |
436 | limiter = Limiter(app, key_func=get_ip_from_header) | |
473 | limiter = Limiter(get_ip_from_header, app=app) | |
437 | 474 | |
438 | 475 | @app.route("/limited") |
439 | 476 | @limiter.shared_limit("1 per day", "test_scope") |
470 | 507 | |
471 | 508 | app = Flask(__name__) |
472 | 509 | limiter = Limiter( |
473 | app, | |
510 | get_ip_from_header, | |
511 | app=app, | |
474 | 512 | default_limits=["1/minute"], |
475 | 513 | headers_enabled=True, |
476 | key_func=get_ip_from_header, | |
477 | 514 | ) |
478 | 515 | |
479 | 516 | @app.route("/") |
614 | 651 | assert cli.get("/t2").status_code == 200 |
615 | 652 | |
616 | 653 | |
617 | def test_on_breach_callback(extension_factory, caplog): | |
618 | app, limiter = extension_factory() | |
654 | def test_on_breach_callback_swallow_errors(extension_factory, caplog): | |
655 | app, limiter = extension_factory(swallow_errors=True) | |
619 | 656 | |
620 | 657 | callbacks = [] |
621 | 658 | |
649 | 686 | assert cli.get("/fail").status_code == 429 |
650 | 687 | |
651 | 688 | assert len(callbacks) == 1 |
652 | assert ( | |
653 | caplog.records[-1].message | |
654 | == "on_breach callback failed with error division by zero" | |
655 | ) | |
689 | ||
690 | log = caplog.records[-1] | |
691 | assert log.message == "on_breach callback failed with error division by zero" | |
692 | assert log.levelname == "ERROR" | |
656 | 693 | |
657 | 694 | |
658 | 695 | def test_on_breach_callback_custom_response(extension_factory): |
669 | 706 | f"default custom response {request_limit.limit} @ {request.path}", 429 |
670 | 707 | ) |
671 | 708 | |
709 | def on_breach_invalid(): | |
710 | ... | |
711 | ||
712 | def on_breach_fail(request_limit): | |
713 | 1 / 0 | |
714 | ||
672 | 715 | app, limiter = extension_factory(on_breach=default_on_breach_with_response) |
673 | 716 | |
674 | 717 | @app.route("/") |
676 | 719 | def root(): |
677 | 720 | return "root" |
678 | 721 | |
679 | @app.route("/other") | |
722 | @app.route("/t1") | |
680 | 723 | @limiter.limit("1/second") |
681 | def other(): | |
682 | return "other" | |
683 | ||
684 | @app.route("/fail") | |
724 | def t1(): | |
725 | return "t1" | |
726 | ||
727 | @app.route("/t2") | |
685 | 728 | @limiter.limit("1/second", on_breach=on_breach_with_response) |
686 | def fail(): | |
687 | return "fail" | |
729 | def t2(): | |
730 | return "t2" | |
731 | ||
732 | @app.route("/t3") | |
733 | @limiter.limit("1/second", on_breach=on_breach_invalid) | |
734 | def t3(): | |
735 | return "t3" | |
736 | ||
737 | @app.route("/t4") | |
738 | @limiter.limit("1/second", on_breach=on_breach_fail) | |
739 | def t4(): | |
740 | return "t4" | |
688 | 741 | |
689 | 742 | with app.test_client() as cli: |
690 | 743 | assert cli.get("/").status_code == 200 |
691 | 744 | resp = cli.get("/") |
692 | 745 | assert resp.status_code == 429 |
693 | 746 | assert resp.text == "default custom response 1 per 1 second @ /" |
694 | assert cli.get("/other").status_code == 200 | |
695 | resp = cli.get("/other") | |
747 | assert cli.get("/t1").status_code == 200 | |
748 | resp = cli.get("/t1") | |
696 | 749 | assert resp.status_code == 429 |
697 | assert resp.text == "default custom response 1 per 1 second @ /other" | |
698 | assert cli.get("/fail").status_code == 200 | |
699 | resp = cli.get("/fail") | |
750 | assert resp.text == "default custom response 1 per 1 second @ /t1" | |
751 | assert cli.get("/t2").status_code == 200 | |
752 | resp = cli.get("/t2") | |
700 | 753 | assert resp.status_code == 429 |
701 | assert resp.text == "custom response 1 per 1 second @ /fail" | |
754 | assert resp.text == "custom response 1 per 1 second @ /t2" | |
755 | resp = cli.get("/t3") | |
756 | assert resp.status_code == 200 | |
757 | resp = cli.get("/t3") | |
758 | assert resp.status_code == 500 | |
759 | resp = cli.get("/t4") | |
760 | assert resp.status_code == 200 | |
761 | resp = cli.get("/t4") | |
762 | assert resp.status_code == 500 | |
702 | 763 | |
703 | 764 | |
704 | 765 | def test_limit_multiple_cost(extension_factory): |
771 | 832 | assert 200 == cli.get("/t1").status_code |
772 | 833 | assert 200 == cli.get("/t2").status_code |
773 | 834 | assert 429 == cli.get("/t2").status_code |
835 | ||
836 | ||
837 | def test_non_route_decoration_static_limits_override_defaults(extension_factory): | |
838 | app, limiter = extension_factory(default_limits=["1/second"]) | |
839 | ||
840 | @limiter.limit("2/second") | |
841 | def limited(): | |
842 | return "limited" | |
843 | ||
844 | @app.route("/t1") | |
845 | def route1(): | |
846 | return "t1" | |
847 | ||
848 | @app.route("/t2") | |
849 | @limiter.limit("2/second") | |
850 | def route2(): | |
851 | return "t2" | |
852 | ||
853 | @app.route("/t3") | |
854 | def route3(): | |
855 | return limited() | |
856 | ||
857 | @app.route("/t4") | |
858 | def route4(): | |
859 | @limiter.limit("2/day", override_defaults=False) | |
860 | def __inner(): | |
861 | return "inner" | |
862 | ||
863 | return __inner() | |
864 | ||
865 | @app.route("/t5/<int:param>") | |
866 | def route5(param: int): | |
867 | @limiter.limit("2/day", override_defaults=False) | |
868 | def __inner1(): | |
869 | return "inner1" | |
870 | ||
871 | @limiter.limit("3/day") | |
872 | def __inner2(): | |
873 | return "inner2" | |
874 | ||
875 | return __inner1() if param < 10 else __inner2() | |
876 | ||
877 | with hiro.Timeline().freeze() as timeline: | |
878 | with app.test_client() as cli: | |
879 | assert 200 == cli.get("/t1").status_code | |
880 | assert 429 == cli.get("/t1").status_code | |
881 | for i in range(2): | |
882 | assert 200 == cli.get("/t2").status_code | |
883 | assert 429 == cli.get("/t2").status_code | |
884 | for i in range(2): | |
885 | assert 200 == cli.get("/t3").status_code, i | |
886 | assert 429 == cli.get("/t3").status_code | |
887 | assert 200 == cli.get("/t4").status_code | |
888 | assert 429 == cli.get("/t4").status_code | |
889 | timeline.forward(1) | |
890 | assert 200 == cli.get("/t4").status_code | |
891 | timeline.forward(1) | |
892 | assert 429 == cli.get("/t4").status_code | |
893 | assert 200 == cli.get("/t5/1").status_code | |
894 | assert 429 == cli.get("/t5/1").status_code | |
895 | timeline.forward(1) | |
896 | assert 200 == cli.get("/t5/1").status_code | |
897 | timeline.forward(1) | |
898 | assert 429 == cli.get("/t5/1").status_code | |
899 | timeline.forward(60 * 60 * 24) | |
900 | assert 200 == cli.get("/t5/11").status_code | |
901 | assert 200 == cli.get("/t5/11").status_code | |
902 | assert 200 == cli.get("/t5/11").status_code | |
903 | assert 429 == cli.get("/t5/11").status_code | |
904 | ||
905 | ||
906 | def test_non_route_decoration_static_limits(extension_factory): | |
907 | app, limiter = extension_factory() | |
908 | ||
909 | @limiter.limit("1/second") | |
910 | def limited(): | |
911 | return "limited" | |
912 | ||
913 | @app.route("/t1") | |
914 | def route1(): | |
915 | return limited() | |
916 | ||
917 | with hiro.Timeline().freeze(): | |
918 | with app.test_client() as cli: | |
919 | assert 200 == cli.get("/t1").status_code | |
920 | assert 429 == cli.get("/t1").status_code | |
921 | ||
922 | ||
923 | def test_non_route_decoration_dynamic_limits(extension_factory): | |
924 | app, limiter = extension_factory() | |
925 | ||
926 | def dynamic_limit_provider(): | |
927 | return "1/second" | |
928 | ||
929 | @limiter.limit(dynamic_limit_provider) | |
930 | def limited(): | |
931 | return "limited" | |
932 | ||
933 | @app.route("/t1") | |
934 | def route1(): | |
935 | return limited() | |
936 | ||
937 | with hiro.Timeline().freeze(): | |
938 | with app.test_client() as cli: | |
939 | assert 200 == cli.get("/t1").status_code | |
940 | assert 429 == cli.get("/t1").status_code | |
941 | ||
942 | ||
943 | def test_non_route_decoration_multiple_sequential_limits_per_request(extension_factory): | |
944 | app, limiter = extension_factory() | |
945 | ||
946 | @limiter.limit("10/second") | |
947 | def l1(): | |
948 | return "l1" | |
949 | ||
950 | @limiter.limit("1/second") | |
951 | def l2(): | |
952 | return "l2" | |
953 | ||
954 | @app.route("/t1") | |
955 | def route1(): | |
956 | return l1() + l2() | |
957 | ||
958 | with hiro.Timeline().freeze(): | |
959 | with app.test_client() as cli: | |
960 | assert 200 == cli.get("/t1").status_code | |
961 | assert 429 == cli.get("/t1").status_code | |
962 | ||
963 | ||
964 | def test_inner_function_decoration(extension_factory): | |
965 | app, limiter = extension_factory() | |
966 | ||
967 | @app.route("/t1") | |
968 | def route1(): | |
969 | @limiter.limit("5/second") | |
970 | def l1(): | |
971 | return "l1" | |
972 | ||
973 | return l1() | |
974 | ||
975 | @app.route("/t2") | |
976 | def route2(): | |
977 | @limiter.limit("1/second") | |
978 | def l1(): | |
979 | return "l1" | |
980 | ||
981 | return l1() | |
982 | ||
983 | with hiro.Timeline().freeze(): | |
984 | with app.test_client() as cli: | |
985 | assert 200 == cli.get("/t1").status_code | |
986 | assert 200 == cli.get("/t2").status_code | |
987 | for _ in range(4): | |
988 | assert 200 == cli.get("/t1").status_code | |
989 | assert 429 == cli.get("/t1").status_code | |
990 | assert 429 == cli.get("/t2").status_code |
107 | 107 | assert "ok" in cli.get("/").data.decode() |
108 | 108 | |
109 | 109 | |
110 | def test_swallow_error_conditional_deduction(extension_factory): | |
111 | def conditional_deduct(_): | |
112 | return True | |
113 | ||
114 | app, limiter = extension_factory( | |
115 | { | |
116 | ConfigVars.DEFAULT_LIMITS: "1 per day", | |
117 | ConfigVars.SWALLOW_ERRORS: True, | |
118 | ConfigVars.DEFAULT_LIMITS_DEDUCT_WHEN: conditional_deduct, | |
119 | } | |
120 | ) | |
121 | ||
122 | @app.route("/") | |
123 | def null(): | |
124 | return "ok" | |
125 | ||
126 | with app.test_client() as cli: | |
127 | with patch("limits.strategies.FixedWindowRateLimiter.hit") as hit: | |
128 | ||
129 | def raiser(*a, **k): | |
130 | raise Exception | |
131 | ||
132 | hit.side_effect = raiser | |
133 | assert "ok" in cli.get("/").data.decode() | |
134 | ||
135 | ||
110 | 136 | def test_no_swallow_error(extension_factory): |
111 | 137 | app, limiter = extension_factory( |
112 | 138 | {ConfigVars.DEFAULT_LIMITS: "1 per day", ConfigVars.HEADERS_ENABLED: True} |
136 | 162 | get_window_stats.side_effect = raiser |
137 | 163 | assert 500 == cli.get("/").status_code |
138 | 164 | assert "underlying" == cli.get("/").data.decode() |
165 | ||
166 | ||
167 | def test_no_swallow_error_conditional_deduction(extension_factory): | |
168 | def conditional_deduct(_): | |
169 | return True | |
170 | ||
171 | app, limiter = extension_factory( | |
172 | { | |
173 | ConfigVars.DEFAULT_LIMITS: "1 per day", | |
174 | ConfigVars.SWALLOW_ERRORS: False, | |
175 | ConfigVars.DEFAULT_LIMITS_DEDUCT_WHEN: conditional_deduct, | |
176 | } | |
177 | ) | |
178 | ||
179 | @app.route("/") | |
180 | def null(): | |
181 | return "ok" | |
182 | ||
183 | with app.test_client() as cli: | |
184 | with patch("limits.strategies.FixedWindowRateLimiter.hit") as hit: | |
185 | ||
186 | def raiser(*a, **k): | |
187 | raise Exception | |
188 | ||
189 | hit.side_effect = raiser | |
190 | assert 500 == cli.get("/").status_code | |
139 | 191 | |
140 | 192 | |
141 | 193 | def test_fallback_to_memory_config(redis_connection, extension_factory): |
189 | 189 | app, limiter = extension_factory() |
190 | 190 | |
191 | 191 | @app.route("/t1") |
192 | @limiter.limit("100 per minute", lambda: "test") | |
192 | @limiter.limit("100 per minute", key_func=lambda: "test") | |
193 | 193 | def t1(): |
194 | 194 | return "test" |
195 | 195 | |
206 | 206 | |
207 | 207 | |
208 | 208 | def test_logging(caplog): |
209 | app = Flask(__name__) | |
210 | limiter = Limiter(app, key_func=get_remote_address) | |
209 | caplog.set_level(logging.INFO) | |
210 | app = Flask(__name__) | |
211 | limiter = Limiter(get_remote_address, app=app) | |
211 | 212 | |
212 | 213 | @app.route("/t1") |
213 | 214 | @limiter.limit("1/minute") |
218 | 219 | assert 200 == cli.get("/t1").status_code |
219 | 220 | assert 429 == cli.get("/t1").status_code |
220 | 221 | assert len(caplog.records) == 1 |
221 | assert caplog.records[0].levelname == "WARNING" | |
222 | ||
223 | ||
224 | def test_reuse_logging(): | |
222 | assert caplog.records[0].levelname == "INFO" | |
223 | ||
224 | ||
225 | def test_reuse_logging(caplog): | |
226 | caplog.set_level(logging.INFO) | |
225 | 227 | app = Flask(__name__) |
226 | 228 | app_handler = mock.Mock() |
227 | 229 | app_handler.level = logging.INFO |
228 | 230 | app.logger.addHandler(app_handler) |
229 | limiter = Limiter(app, key_func=get_remote_address) | |
231 | limiter = Limiter(get_remote_address, app=app) | |
230 | 232 | |
231 | 233 | for handler in app.logger.handlers: |
232 | 234 | limiter.logger.addHandler(handler) |
270 | 272 | app1 = Flask(__name__) |
271 | 273 | app2 = Flask(__name__) |
272 | 274 | |
273 | limiter = Limiter(default_limits=["1/second"], key_func=get_remote_address) | |
275 | limiter = Limiter(get_remote_address, default_limits=["1/second"]) | |
274 | 276 | limiter.init_app(app1) |
275 | 277 | limiter.init_app(app2) |
276 | 278 | |
321 | 323 | def test_headers_no_breach(): |
322 | 324 | app = Flask(__name__) |
323 | 325 | limiter = Limiter( |
324 | app, | |
326 | get_remote_address, | |
327 | app=app, | |
325 | 328 | default_limits=["10/minute"], |
326 | 329 | headers_enabled=True, |
327 | key_func=get_remote_address, | |
328 | 330 | ) |
329 | 331 | |
330 | 332 | @app.route("/t1") |
354 | 356 | def test_headers_breach(): |
355 | 357 | app = Flask(__name__) |
356 | 358 | limiter = Limiter( |
357 | app, | |
359 | get_remote_address, | |
360 | app=app, | |
358 | 361 | default_limits=["10/minute"], |
359 | 362 | headers_enabled=True, |
360 | key_func=get_remote_address, | |
361 | 363 | ) |
362 | 364 | |
363 | 365 | @app.route("/t1") |
383 | 385 | def test_retry_after(): |
384 | 386 | app = Flask(__name__) |
385 | 387 | _ = Limiter( |
386 | app, | |
388 | get_remote_address, | |
389 | app=app, | |
387 | 390 | default_limits=["1/minute"], |
388 | 391 | headers_enabled=True, |
389 | key_func=get_remote_address, | |
390 | 392 | ) |
391 | 393 | |
392 | 394 | @app.route("/t1") |
406 | 408 | def test_retry_after_exists_seconds(): |
407 | 409 | app = Flask(__name__) |
408 | 410 | _ = Limiter( |
409 | app, | |
411 | get_remote_address, | |
412 | app=app, | |
410 | 413 | default_limits=["1/minute"], |
411 | 414 | headers_enabled=True, |
412 | key_func=get_remote_address, | |
413 | 415 | ) |
414 | 416 | |
415 | 417 | @app.route("/t1") |
426 | 428 | def test_retry_after_exists_rfc1123(): |
427 | 429 | app = Flask(__name__) |
428 | 430 | _ = Limiter( |
429 | app, | |
431 | get_remote_address, | |
432 | app=app, | |
430 | 433 | default_limits=["1/minute"], |
431 | 434 | headers_enabled=True, |
432 | key_func=get_remote_address, | |
433 | 435 | ) |
434 | 436 | |
435 | 437 | @app.route("/t1") |
449 | 451 | app.config.setdefault(ConfigVars.HEADER_REMAINING, "X-Remaining") |
450 | 452 | app.config.setdefault(ConfigVars.HEADER_RESET, "X-Reset") |
451 | 453 | limiter = Limiter( |
452 | app, | |
454 | get_remote_address, | |
455 | app=app, | |
453 | 456 | default_limits=["10/minute"], |
454 | 457 | headers_enabled=True, |
455 | key_func=get_remote_address, | |
456 | 458 | ) |
457 | 459 | |
458 | 460 | @app.route("/t1") |
687 | 689 | |
688 | 690 | def test_second_instance_bypassed_by_shared_g(): |
689 | 691 | app = Flask(__name__) |
690 | limiter1 = Limiter(app, key_func=get_remote_address) | |
691 | ||
692 | limiter2 = Limiter(app, key_func=get_remote_address) | |
692 | limiter1 = Limiter(get_remote_address, app=app) | |
693 | ||
694 | limiter2 = Limiter(get_remote_address, app=app) | |
693 | 695 | |
694 | 696 | @app.route("/test1") |
695 | 697 | @limiter2.limit("1/second") |
723 | 725 | |
724 | 726 | def test_independent_instances_by_key_prefix(): |
725 | 727 | app = Flask(__name__) |
726 | limiter1 = Limiter(app, key_prefix="lmt1", key_func=get_remote_address) | |
727 | ||
728 | limiter2 = Limiter(app, key_prefix="lmt2", key_func=get_remote_address) | |
728 | limiter1 = Limiter(get_remote_address, key_prefix="lmt1", app=app) | |
729 | ||
730 | limiter2 = Limiter(get_remote_address, key_prefix="lmt2", app=app) | |
729 | 731 | |
730 | 732 | @app.route("/test1") |
731 | 733 | @limiter2.limit("1/second") |
765 | 767 | |
766 | 768 | def test_multiple_limiters_default_limits(): |
767 | 769 | app = Flask(__name__) |
770 | Limiter(get_remote_address, key_prefix="lmt1", app=app, default_limits=["1/second"]) | |
768 | 771 | Limiter( |
769 | app, key_prefix="lmt1", default_limits=["1/second"], key_func=get_remote_address | |
770 | ) | |
771 | Limiter( | |
772 | app, | |
772 | get_remote_address, | |
773 | 773 | key_prefix="lmt2", |
774 | 774 | default_limits=["10/minute"], |
775 | key_func=get_remote_address, | |
775 | app=app, | |
776 | 776 | ) |
777 | 777 | |
778 | 778 | @app.route("/test1") |