Merge pull request #355 from CartoDB/346-user-rate-limits

Service rate limits
This commit is contained in:
Javier Goizueta 2017-03-28 17:48:46 +02:00 committed by GitHub
commit f0b0a9e7f2
31 changed files with 1229 additions and 132 deletions

View File

@ -43,7 +43,6 @@ Steps to deploy a new Data Services API version :
```
# in dataservices-api repo root path:
cd dataservices-api
cd client && sudo make install
cd -
cd server/extension && sudo make install
@ -53,7 +52,7 @@ Steps to deploy a new Data Services API version :
```
# in dataservices-api repo root path:
cd server/lib/python/cartodb_services && pip install -r requirements.txt upgrade
cd server/lib/python/cartodb_services && pip install -r requirements.txt && sudo pip install . --upgrade
```
- install extensions in user database
@ -66,7 +65,6 @@ Steps to deploy a new Data Services API version :
create extension cdb_dataservices_client;
```
### Server configuration
Configuration for the different services must be stored in the server database using `CDB_Conf_SetConf()`.

View File

@ -421,3 +421,29 @@
params:
- { name: service, type: TEXT }
- { name: input_size, type: NUMERIC }
- name: cdb_service_get_rate_limit
return_type: json
params:
- { name: service, type: "text" }
- name: cdb_service_set_user_rate_limit
superuser: true
return_type: void
params:
- { name: service, type: "text" }
- { name: rate_limit, type: json }
- name: cdb_service_set_org_rate_limit
superuser: true
return_type: void
params:
- { name: service, type: "text" }
- { name: rate_limit, type: json }
- name: cdb_service_set_server_rate_limit
superuser: true
return_type: void
params:
- { name: service, type: "text" }
- { name: rate_limit, type: json }

View File

@ -44,16 +44,29 @@ class SqlTemplateRenderer
@function_signature['geocoder_config_key']
end
def params
@function_signature['params'].reject(&:empty?).map { |p| "#{p['name']}"}
def parameters_info(with_user_org)
parameters = []
if with_user_org
parameters << { 'name' => 'username', 'type' => 'text' }
parameters << { 'name' => 'orgname', 'type' => 'text' }
end
parameters + @function_signature['params'].reject(&:empty?)
end
def params_with_type
@function_signature['params'].reject(&:empty?).map { |p| "#{p['name']} #{p['type']}" }
def user_org_declaration()
"username text;\n orgname text;" unless superuser_function?
end
def params_with_type_and_default
parameters = @function_signature['params'].reject(&:empty?).map do |p|
def params(with_user_org = superuser_function?)
parameters_info(with_user_org).map { |p| p['name'].to_s }
end
def params_with_type(with_user_org = superuser_function?)
parameters_info(with_user_org).map { |p| "#{p['name']} #{p['type']}" }
end
def params_with_type_and_default(with_user_org = superuser_function?)
parameters = parameters_info(with_user_org).map do |p|
if not p['default'].nil?
"#{p['name']} #{p['type']} DEFAULT #{p['default']}"
else
@ -62,6 +75,49 @@ class SqlTemplateRenderer
end
return parameters
end
def superuser_function?
!!@function_signature['superuser']
end
def void_return_type?
return_type.downcase == 'void'
end
def return_declaration
"ret #{return_type};" unless void_return_type? || multi_row
end
def return_statement(&block)
if block
erb_out = block.binding.eval('_erbout')
if multi_row
erb_out << 'RETURN QUERY SELECT * FROM '
elsif multi_field
erb_out << 'SELECT * FROM '
elsif void_return_type?
erb_out << 'PERFORM '
else
erb_out << 'SELECT '
end
yield
if multi_row || void_return_type?
erb_out << ';'
else
erb_out << ' INTO ret;'
end
if !multi_row && !void_return_type?
erb_out << ' RETURN ret;'
end
else
if !multi_row && !void_return_type?
' RETURN ret;'
end
end
end
end

View File

@ -7,9 +7,8 @@
CREATE OR REPLACE FUNCTION <%= DATASERVICES_CLIENT_SCHEMA %>.<%= name %> (<%= params_with_type_and_default.join(' ,') %>)
RETURNS <%= return_type %> AS $$
DECLARE
<% if not multi_row %>ret <%= return_type %>;<% end %>
username text;
orgname text;
<%= return_declaration if not multi_row %>
<%= user_org_declaration %>
BEGIN
IF session_user = 'publicuser' OR session_user ~ 'cartodb_publicuser_*' THEN
RAISE EXCEPTION 'The api_key must be provided';
@ -19,15 +18,7 @@ BEGIN
IF username IS NULL OR username = '' OR username = '""' THEN
RAISE EXCEPTION 'Username is a mandatory argument, check it out';
END IF;
<% if multi_row %>
RETURN QUERY
SELECT * FROM <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>(<%= ['username', 'orgname'].concat(params).join(', ') %>);
<% elsif multi_field %>
SELECT * FROM <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>(<%= ['username', 'orgname'].concat(params).join(', ') %>) INTO ret;
RETURN ret;
<% else %>
SELECT <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>(<%= ['username', 'orgname'].concat(params).join(', ') %>) INTO ret;
RETURN ret;
<% end %>
<% return_statement do %><%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>(<%= params(_with_user_org=true).join(', ') %>)<% end %>
END;
$$ LANGUAGE 'plpgsql' SECURITY DEFINER;

View File

@ -5,9 +5,8 @@
CREATE OR REPLACE FUNCTION <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>_exception_safe (<%= params_with_type_and_default.join(' ,') %>)
RETURNS <%= return_type %> AS $$
DECLARE
<% if not multi_row %>ret <%= return_type %>;<% end %>
username text;
orgname text;
<%= return_declaration %>
<%= user_org_declaration %>
_returned_sqlstate TEXT;
_message_text TEXT;
_pg_exception_context TEXT;
@ -21,41 +20,16 @@ BEGIN
RAISE EXCEPTION 'Username is a mandatory argument, check it out';
END IF;
<% if multi_row %>
BEGIN
RETURN QUERY
SELECT * FROM <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>(<%= ['username', 'orgname'].concat(params).join(', ') %>);
EXCEPTION
WHEN OTHERS THEN
BEGIN
<% return_statement do %><%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>(<%= params(_with_user_org=true).join(', ') %>)<% end %>
EXCEPTION
WHEN OTHERS THEN
GET STACKED DIAGNOSTICS _returned_sqlstate = RETURNED_SQLSTATE,
_message_text = MESSAGE_TEXT,
_pg_exception_context = PG_EXCEPTION_CONTEXT;
RAISE WARNING USING ERRCODE = _returned_sqlstate, MESSAGE = _message_text, DETAIL = _pg_exception_context;
END;
<% elsif multi_field %>
BEGIN
SELECT * FROM <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>(<%= ['username', 'orgname'].concat(params).join(', ') %>) INTO ret;
RETURN ret;
EXCEPTION
WHEN OTHERS THEN
GET STACKED DIAGNOSTICS _returned_sqlstate = RETURNED_SQLSTATE,
_message_text = MESSAGE_TEXT,
_pg_exception_context = PG_EXCEPTION_CONTEXT;
RAISE WARNING USING ERRCODE = _returned_sqlstate, MESSAGE = _message_text, DETAIL = _pg_exception_context;
RETURN ret;
END;
<% else %>
BEGIN
SELECT <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>(<%= ['username', 'orgname'].concat(params).join(', ') %>) INTO ret;
RETURN ret;
EXCEPTION
WHEN OTHERS THEN
GET STACKED DIAGNOSTICS _returned_sqlstate = RETURNED_SQLSTATE,
_message_text = MESSAGE_TEXT,
_pg_exception_context = PG_EXCEPTION_CONTEXT;
RAISE WARNING USING ERRCODE = _returned_sqlstate, MESSAGE = _message_text, DETAIL = _pg_exception_context;
RETURN ret;
END;
<% end %>
<%= return_statement %>
END;
END;
$$ LANGUAGE 'plpgsql' SECURITY DEFINER;

View File

@ -1,9 +1,9 @@
CREATE OR REPLACE FUNCTION <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %> (<%= ['username text', 'organization_name text'].concat(params_with_type_and_default).join(', ') %>)
CREATE OR REPLACE FUNCTION <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %> (<%= params_with_type_and_default(_with_user_org=true).join(', ') %>)
RETURNS <%= return_type %> AS $$
CONNECT <%= DATASERVICES_CLIENT_SCHEMA %>._server_conn_str();
<% if multi_field %>
SELECT * FROM <%= DATASERVICES_SERVER_SCHEMA %>.<%= name %> (<%= ['username', 'organization_name'].concat(params).join(', ') %>);
SELECT * FROM <%= DATASERVICES_SERVER_SCHEMA %>.<%= name %> (<%= params(_with_user_org=true).join(', ') %>);
<% else %>
SELECT <%= DATASERVICES_SERVER_SCHEMA %>.<%= name %> (<%= ['username', 'organization_name'].concat(params).join(', ') %>);
SELECT <%= DATASERVICES_SERVER_SCHEMA %>.<%= name %> (<%= params(_with_user_org=true).join(', ') %>);
<% end %>
$$ LANGUAGE plproxy;

View File

@ -1,2 +1,4 @@
<% unless superuser_function? %>
GRANT EXECUTE ON FUNCTION <%= DATASERVICES_CLIENT_SCHEMA %>.<%= name %>(<%= params_with_type.join(', ') %>) TO publicuser;
GRANT EXECUTE ON FUNCTION <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>_exception_safe(<%= params_with_type.join(', ') %>) TO publicuser;
GRANT EXECUTE ON FUNCTION <%= DATASERVICES_CLIENT_SCHEMA %>._<%= name %>_exception_safe(<%= params_with_type.join(', ') %> ) TO publicuser;
<% end %>

195
doc/rate_limits.md Normal file
View File

@ -0,0 +1,195 @@
# Rate limits
Services can be rate-limited. (currently only gecoding is limited)
The rate limits configuration can be established at server, organization or user levels, the latter having precedence over the earlier.
The default configuration (a null or empty configuration) doesn't impose any limits.
The configuration consist of a JSON object with two attributes:
* `period`: the rate-limiting period, in seconds.
* `limit`: the maximum number of request in the established period.
If a service request exceeds the configured rate limits
(i.e. if more than `limit` calls are performe in a fixed interval of
duration `period` seconds) the call will fail with an "Rate limit exceeded" error.
## Server-side interface
There's a server-side SQL interface to query or change the configuration.
### cdb_dataservices_server.cdb_service_get_rate_limit(username, orgname, service)
This function returns the rate limit configuration for a given user and service.
#### Returns
The result is a JSON object with the configuration (`period` and `limit` attributes as explained above).
### cdb_dataservices_server.cdb_service_set_user_rate_limit(username, orgname, service, rate_limit)
This function sets the rate limit configuration for the user. This overrides any other configuration.
The configuration is provided as a JSON literal. To remove the user-level configuration `NULL` should be passed as the `rate_limit`.
#### Returns
This functions doesn't return any value.
### cdb_dataservices_server.cdb_service_set_org_rate_limit(username, orgname, service, rate_limit)
This function sets the rate limit configuration for the organization.
This overrides server level configuration and is overriden by user configuration if present.
The configuration is provided as a JSON literal. To remove the organization-level configuration `NULL` should be passed as the `rate_limit`.
#### Returns
This functions doesn't return any value.
### cdb_dataservices_server.cdb_service_set_server_rate_limit(username, orgname, service, rate_limit)
This function sets the default rate limit configuration for all users accesing the dataservices server. This is overriden by organization of user configuration.
The configuration is provided as a JSON literal. To remove the organization-level configuration `NULL` should be passed as the `rate_limit`.
#### Returns
This functions doesn't return any value.
## Client-side interface
For convenience there's also a client-side interface (in the client dataservices-api extension), consisting
of public functions to get the current configuration and privileged functions to change it.
### Public functions
These functions are accesible to non-privileged roles, and should only be executed
using the role corresponding to a CARTO user, since that will determine the
user and organization to which the rate limits configuration applies.
### cdb_dataservices_client.cdb_service_get_rate_limit(service)
This function returns the rate limit configuration in effect for the specified service
and the user corresponding to the role which makes the calls. The effective configuration
may come from any of the configuration levels (server/organization/user); only the
existing configuration with most precedence is returned.
#### Returns
The result is a JSON object with the configuration (`period` and `limit` attributes as explained above).
#### Example:
```
SELECT cdb_dataservices_client.cdb_service_get_rate_limit('geocoding');
cdb_service_get_rate_limit
---------------------------------
{"limit": 1000, "period": 86400}
(1 row)
```
### Privileged (superuser) functions
Thes functions are not accessible by regular user roles, and the user and organization names must be provided as parameters.
### cdb_dataservices_client.cdb_service_set_user_rate_limit(username, orgname, service, rate_limit)
This function sets the rate limit configuration for the user. This overrides any other configuration.
The configuration is provided as a JSON literal. To remove the user-level configuration `NULL` should be passed as the `rate_limit`.
#### Returns
This functions doesn't return any value.
#### Example
This will configure the geocoding service rate limit for user `myusername`, a non-organization user.
The limit will be set at 1000 requests per day. Since the user doesn't belong to any organization,
`NULL` will be passed to the organization argument; otherwise the name of the user's organization should
be provided.
```
SELECT cdb_dataservices_client.cdb_service_set_user_rate_limit(
'myusername',
NULL,
'geocoding',
'{"limit":1000,"period":86400}'
);
cdb_service_set_user_rate_limit
---------------------------------
(1 row)
```
### cdb_dataservices_client.cdb_service_set_org_rate_limit(username, orgname, service, rate_limit)
This function sets the rate limit configuration for the organization.
This overrides server level configuration and is overriden by user configuration if present.
The configuration is provided as a JSON literal. To remove the organization-level configuration `NULL` should be passed as the `rate_limit`.
#### Returns
This functions doesn't return any value.
#### Example
This will configure the geocoding service rate limit for the `myorg` organization.
The limit will be set at 100 requests per hour.
Note that even we're setting the default configuration for the whole organization,
the name of a user of the organization must be provided for technical reasons.
```
SELECT cdb_dataservices_client.cdb_service_set_org_rate_limit(
'myorgadmin',
'myorg',
'geocoding',
'{"limit":100,"period":3600}'
);
cdb_service_set_org_rate_limit
---------------------------------
(1 row)
```
### cdb_dataservices_client.cdb_service_set_server_rate_limit(username, orgname, service, rate_limit)
This function sets the default rate limit configuration for all users accesing the dataservices server. This is overriden by organization of user configuration.
The configuration is provided as a JSON literal. To remove the organization-level configuration `NULL` should be passed as the `rate_limit`.
#### Returns
This functions doesn't return any value.
#### Example
This will configure the default geocoding service rate limit for all users
accesing the data-services server.
The limit will be set at 10000 requests per month.
Note that even we're setting the default configuration for the server,
the name of a user and the name of the corresponding organization (or NULL)
must be provided for technical reasons.
```
SELECT cdb_dataservices_client.cdb_service_set_server_rate_limit(
'myorgadmin',
'myorg',
'geocoding',
'{"limit":10000,"period":108000}'
);
cdb_service_set_server_rate_limit
---------------------------------
(1 row)
```

View File

@ -16,6 +16,24 @@ RETURNS JSON AS $$
SELECT VALUE FROM cartodb.cdb_conf WHERE key = input_key;
$$ LANGUAGE SQL STABLE SECURITY DEFINER;
CREATE OR REPLACE
FUNCTION cdb_dataservices_server.CDB_Conf_SetConf(key text, value JSON)
RETURNS void AS $$
BEGIN
PERFORM cartodb.CDB_Conf_RemoveConf(key);
EXECUTE 'INSERT INTO cartodb.CDB_CONF (KEY, VALUE) VALUES ($1, $2);' USING key, value;
END
$$ LANGUAGE PLPGSQL VOLATILE;
CREATE OR REPLACE
FUNCTION cdb_dataservices_server.CDB_Conf_RemoveConf(key text)
RETURNS void AS $$
BEGIN
EXECUTE 'DELETE FROM cartodb.CDB_CONF WHERE KEY = $1;' USING key;
END
$$ LANGUAGE PLPGSQL VOLATILE;
CREATE OR REPLACE FUNCTION cdb_dataservices_server._get_geocoder_config(username text, orgname text, provider text DEFAULT NULL)
RETURNS boolean AS $$
cache_key = "user_geocoder_config_{0}".format(username)

View File

@ -72,23 +72,15 @@ $$ LANGUAGE plpythonu;
CREATE OR REPLACE FUNCTION cdb_dataservices_server._cdb_here_geocode_street_point(username TEXT, orgname TEXT, searchtext TEXT, city TEXT DEFAULT NULL, state_province TEXT DEFAULT NULL, country TEXT DEFAULT NULL)
RETURNS Geometry AS $$
from cartodb_services.tools import LegacyServiceManager
from cartodb_services.here import HereMapsGeocoder
from cartodb_services.metrics import QuotaService
from cartodb_services.tools import Logger,LoggerConfig
redis_conn = GD["redis_connection_{0}".format(username)]['redis_metrics_connection']
user_geocoder_config = GD["user_geocoder_config_{0}".format(username)]
plpy.execute("SELECT cdb_dataservices_server._get_logger_config()")
logger_config = GD["logger_config"]
logger = Logger(logger_config)
# -- Check the quota
quota_service = QuotaService(user_geocoder_config, redis_conn)
if not quota_service.check_user_quota():
raise Exception('You have reached the limit of your quota')
service_manager = LegacyServiceManager('geocoder', username, orgname, GD)
service_manager.assert_within_limits()
try:
geocoder = HereMapsGeocoder(user_geocoder_config.heremaps_app_id, user_geocoder_config.heremaps_app_code, logger, user_geocoder_config.heremaps_service_params)
geocoder = HereMapsGeocoder(service_manager.config.heremaps_app_id, service_manager.config.heremaps_app_code, service_manager.logger, service_manager.config.heremaps_service_params)
coordinates = geocoder.geocode(searchtext=searchtext, city=city, state=state_province, country=country)
if coordinates:
quota_service.increment_success_service_use()
@ -96,46 +88,41 @@ RETURNS Geometry AS $$
point = plpy.execute(plan, [coordinates[0], coordinates[1]], 1)[0]
return point['st_setsrid']
else:
quota_service.increment_empty_service_use()
service_manager.quota_service.increment_empty_service_use()
return None
except BaseException as e:
import sys
quota_service.increment_failed_service_use()
service_manager.quota_service.increment_failed_service_use()
logger.error('Error trying to geocode street point using here maps', sys.exc_info(), data={"username": username, "orgname": orgname})
raise Exception('Error trying to geocode street point using here maps')
finally:
quota_service.increment_total_service_use()
service_manager.quota_service.increment_total_service_use()
$$ LANGUAGE plpythonu;
CREATE OR REPLACE FUNCTION cdb_dataservices_server._cdb_google_geocode_street_point(username TEXT, orgname TEXT, searchtext TEXT, city TEXT DEFAULT NULL, state_province TEXT DEFAULT NULL, country TEXT DEFAULT NULL)
RETURNS Geometry AS $$
from cartodb_services.tools import LegacyServiceManager
from cartodb_services.google import GoogleMapsGeocoder
from cartodb_services.metrics import QuotaService
from cartodb_services.tools import Logger,LoggerConfig
redis_conn = GD["redis_connection_{0}".format(username)]['redis_metrics_connection']
user_geocoder_config = GD["user_geocoder_config_{0}".format(username)]
plpy.execute("SELECT cdb_dataservices_server._get_logger_config()")
logger_config = GD["logger_config"]
logger = Logger(logger_config)
quota_service = QuotaService(user_geocoder_config, redis_conn)
service_manager = LegacyServiceManager('geocoder', username, orgname, GD)
service_manager.assert_within_limits(quota=False)
try:
geocoder = GoogleMapsGeocoder(user_geocoder_config.google_client_id, user_geocoder_config.google_api_key, logger)
geocoder = GoogleMapsGeocoder(service_manager.config.google_client_id, service_manager.config.google_api_key, service_manager.logger)
coordinates = geocoder.geocode(searchtext=searchtext, city=city, state=state_province, country=country)
if coordinates:
quota_service.increment_success_service_use()
service_manager.quota_service.increment_success_service_use()
plan = plpy.prepare("SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326); ", ["double precision", "double precision"])
point = plpy.execute(plan, [coordinates[0], coordinates[1]], 1)[0]
return point['st_setsrid']
else:
quota_service.increment_empty_service_use()
service_manager.quota_service.increment_empty_service_use()
return None
except BaseException as e:
import sys
quota_service.increment_failed_service_use()
logger.error('Error trying to geocode street point using google maps', sys.exc_info(), data={"username": username, "orgname": orgname})
service_manager.quota_service.increment_failed_service_use()
service_manager.logger.error('Error trying to geocode street point using google maps', sys.exc_info(), data={"username": username, "orgname": orgname})
raise Exception('Error trying to geocode street point using google maps')
finally:
quota_service.increment_total_service_use()
@ -143,38 +130,19 @@ $$ LANGUAGE plpythonu;
CREATE OR REPLACE FUNCTION cdb_dataservices_server._cdb_mapzen_geocode_street_point(username TEXT, orgname TEXT, searchtext TEXT, city TEXT DEFAULT NULL, state_province TEXT DEFAULT NULL, country TEXT DEFAULT NULL)
RETURNS Geometry AS $$
import cartodb_services
cartodb_services.init(plpy, GD)
from cartodb_services.tools import ServiceManager
from cartodb_services.mapzen import MapzenGeocoder
from cartodb_services.mapzen.types import country_to_iso3
from cartodb_services.metrics import QuotaService
from cartodb_services.tools import Logger
from cartodb_services.refactor.tools.logger import LoggerConfigBuilder
from cartodb_services.refactor.service.mapzen_geocoder_config import MapzenGeocoderConfigBuilder
from cartodb_services.refactor.core.environment import ServerEnvironmentBuilder
from cartodb_services.refactor.backend.server_config import ServerConfigBackendFactory
from cartodb_services.refactor.backend.user_config import UserConfigBackendFactory
from cartodb_services.refactor.backend.org_config import OrgConfigBackendFactory
from cartodb_services.refactor.backend.redis_metrics_connection import RedisMetricsConnectionFactory
server_config_backend = ServerConfigBackendFactory().get()
environment = ServerEnvironmentBuilder(server_config_backend).get()
user_config_backend = UserConfigBackendFactory(username, environment, server_config_backend).get()
org_config_backend = OrgConfigBackendFactory(orgname, environment, server_config_backend).get()
import cartodb_services
cartodb_services.init(plpy, GD)
logger_config = LoggerConfigBuilder(environment, server_config_backend).get()
logger = Logger(logger_config)
mapzen_geocoder_config = MapzenGeocoderConfigBuilder(server_config_backend, user_config_backend, org_config_backend, username, orgname).get()
redis_metrics_connection = RedisMetricsConnectionFactory(environment, server_config_backend).get()
quota_service = QuotaService(mapzen_geocoder_config, redis_metrics_connection)
if not quota_service.check_user_quota():
raise Exception('You have reached the limit of your quota')
service_manager = ServiceManager('geocoder', MapzenGeocoderConfigBuilder, username, orgname)
service_manager.assert_within_limits()
try:
geocoder = MapzenGeocoder(mapzen_geocoder_config.mapzen_api_key, logger, mapzen_geocoder_config.service_params)
geocoder = MapzenGeocoder(service_manager.config.mapzen_api_key, service_manager.logger, service_manager.config.service_params)
country_iso3 = None
if country:
country_iso3 = country_to_iso3(country)
@ -182,18 +150,18 @@ RETURNS Geometry AS $$
state_province=state_province,
country=country_iso3, search_type='address')
if coordinates:
quota_service.increment_success_service_use()
service_manager.quota_service.increment_success_service_use()
plan = plpy.prepare("SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326); ", ["double precision", "double precision"])
point = plpy.execute(plan, [coordinates[0], coordinates[1]], 1)[0]
return point['st_setsrid']
else:
quota_service.increment_empty_service_use()
service_manager.quota_service.increment_empty_service_use()
return None
except BaseException as e:
import sys
quota_service.increment_failed_service_use()
logger.error('Error trying to geocode street point using mapzen', sys.exc_info(), data={"username": username, "orgname": orgname})
service_manager.quota_service.increment_failed_service_use()
service_manager.logger.error('Error trying to geocode street point using mapzen', sys.exc_info(), data={"username": username, "orgname": orgname})
raise Exception('Error trying to geocode street point using mapzen')
finally:
quota_service.increment_total_service_use()
service_manager.quota_service.increment_total_service_use()
$$ LANGUAGE plpythonu;

View File

@ -0,0 +1,91 @@
CREATE OR REPLACE FUNCTION cdb_dataservices_server.cdb_service_get_rate_limit(
username TEXT,
orgname TEXT,
service TEXT)
RETURNS JSON AS $$
import json
from cartodb_services.config import ServiceConfiguration, RateLimitsConfigBuilder
import cartodb_services
cartodb_services.init(plpy, GD)
service_config = ServiceConfiguration(service, username, orgname)
rate_limit_config = RateLimitsConfigBuilder(service_config.server, service_config.user, service_config.org, service=service, username=username, orgname=orgname).get()
if rate_limit_config.is_limited():
return json.dumps({'limit': rate_limit_config.limit, 'period': rate_limit_config.period})
else:
return None
$$ LANGUAGE plpythonu;
CREATE OR REPLACE FUNCTION cdb_dataservices_server.cdb_service_set_user_rate_limit(
username TEXT,
orgname TEXT,
service TEXT,
rate_limit_json JSON)
RETURNS VOID AS $$
import json
from cartodb_services.config import RateLimitsConfig, RateLimitsConfigSetter
import cartodb_services
cartodb_services.init(plpy, GD)
config_setter = RateLimitsConfigSetter(service=service, username=username, orgname=orgname)
if rate_limit_json:
rate_limit = json.loads(rate_limit_json)
limit = rate_limit.get('limit', None)
period = rate_limit.get('period', None)
else:
limit = None
period = None
config = RateLimitsConfig(service=service, username=username, limit=limit, period=period)
config_setter.set_user_rate_limits(config)
$$ LANGUAGE plpythonu;
CREATE OR REPLACE FUNCTION cdb_dataservices_server.cdb_service_set_org_rate_limit(
username TEXT,
orgname TEXT,
service TEXT,
rate_limit_json JSON)
RETURNS VOID AS $$
import json
from cartodb_services.config import RateLimitsConfig, RateLimitsConfigSetter
import cartodb_services
cartodb_services.init(plpy, GD)
config_setter = RateLimitsConfigSetter(service=service, username=username, orgname=orgname)
if rate_limit_json:
rate_limit = json.loads(rate_limit_json)
limit = rate_limit.get('limit', None)
period = rate_limit.get('period', None)
else:
limit = None
period = None
config = RateLimitsConfig(service=service, username=username, limit=limit, period=period)
config_setter.set_org_rate_limits(config)
$$ LANGUAGE plpythonu;
CREATE OR REPLACE FUNCTION cdb_dataservices_server.cdb_service_set_server_rate_limit(
username TEXT,
orgname TEXT,
service TEXT,
rate_limit_json JSON)
RETURNS VOID AS $$
import json
from cartodb_services.config import RateLimitsConfig, RateLimitsConfigSetter
import cartodb_services
cartodb_services.init(plpy, GD)
config_setter = RateLimitsConfigSetter(service=service, username=username, orgname=orgname)
if rate_limit_json:
rate_limit = json.loads(rate_limit_json)
limit = rate_limit.get('limit', None)
period = rate_limit.get('period', None)
else:
limit = None
period = None
config = RateLimitsConfig(service=service, username=username, limit=limit, period=period)
config_setter.set_server_rate_limits(config)
$$ LANGUAGE plpythonu;

View File

@ -0,0 +1,44 @@
SELECT exists(SELECT *
FROM pg_proc p
INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid)
WHERE ns.nspname = 'cdb_dataservices_server'
AND proname = 'cdb_service_get_rate_limit'
AND oidvectortypes(p.proargtypes) = 'text, text, text');
exists
--------
t
(1 row)
SELECT exists(SELECT *
FROM pg_proc p
INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid)
WHERE ns.nspname = 'cdb_dataservices_server'
AND proname = 'cdb_service_set_user_rate_limit'
AND oidvectortypes(p.proargtypes) = 'text, text, text, json');
exists
--------
t
(1 row)
SELECT exists(SELECT *
FROM pg_proc p
INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid)
WHERE ns.nspname = 'cdb_dataservices_server'
AND proname = 'cdb_service_set_org_rate_limit'
AND oidvectortypes(p.proargtypes) = 'text, text, text, json');
exists
--------
t
(1 row)
SELECT exists(SELECT *
FROM pg_proc p
INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid)
WHERE ns.nspname = 'cdb_dataservices_server'
AND proname = 'cdb_service_set_server_rate_limit'
AND oidvectortypes(p.proargtypes) = 'text, text, text, json');
exists
--------
t
(1 row)

View File

@ -0,0 +1,27 @@
SELECT exists(SELECT *
FROM pg_proc p
INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid)
WHERE ns.nspname = 'cdb_dataservices_server'
AND proname = 'cdb_service_get_rate_limit'
AND oidvectortypes(p.proargtypes) = 'text, text, text');
SELECT exists(SELECT *
FROM pg_proc p
INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid)
WHERE ns.nspname = 'cdb_dataservices_server'
AND proname = 'cdb_service_set_user_rate_limit'
AND oidvectortypes(p.proargtypes) = 'text, text, text, json');
SELECT exists(SELECT *
FROM pg_proc p
INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid)
WHERE ns.nspname = 'cdb_dataservices_server'
AND proname = 'cdb_service_set_org_rate_limit'
AND oidvectortypes(p.proargtypes) = 'text, text, text, json');
SELECT exists(SELECT *
FROM pg_proc p
INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid)
WHERE ns.nspname = 'cdb_dataservices_server'
AND proname = 'cdb_service_set_server_rate_limit'
AND oidvectortypes(p.proargtypes) = 'text, text, text, json');

View File

@ -0,0 +1,3 @@
from service_configuration import ServiceConfiguration
from rate_limits import RateLimitsConfig, RateLimitsConfigBuilder, RateLimitsConfigSetter
from legacy_rate_limits import RateLimitsConfigLegacyBuilder

View File

@ -0,0 +1,46 @@
import json
from rate_limits import RateLimitsConfig
class RateLimitsConfigLegacyBuilder(object):
"""
Build a RateLimitsConfig object using the *legacy* configuration classes
"""
def __init__(self, redis_connection, db_conn, service, username, orgname):
self._service = service
self._username = username
self._orgname = orgname
self._redis_connection = redis_connection
self._db_conn = db_conn
def get(self):
rate_limit = self.__get_rate_limit()
return RateLimitsConfig(self._service,
self._username,
rate_limit.get('limit', None),
rate_limit.get('period', None))
def __get_rate_limit(self):
rate_limit = {}
rate_limit_key = "{0}_rate_limit".format(self._service)
user_key = "rails:users:{0}".format(self._username)
rate_limit_json = self.__get_redis_config(user_key, rate_limit_key)
if not rate_limit_json and self._orgname:
org_key = "rails:orgs:{0}".format(self._orgname)
rate_limit_json = self.__get_redis_config(org_key, rate_limit_key)
if rate_limit_json:
rate_limit = json.loads(rate_limit_json)
else:
conf_key = 'rate_limits'
sql = "SELECT cartodb.CDB_Conf_GetConf('{0}') as conf".format(conf_key)
try:
conf = self._db_conn.execute(sql, 1)[0]['conf']
except Exception:
conf = None
if conf:
rate_limit = json.loads(conf).get(self._service)
return rate_limit or {}
def __get_redis_config(self, basekey, param):
config = self._redis_connection.hgetall(basekey)
return config and config.get(param)

View File

@ -0,0 +1,113 @@
import json
from service_configuration import ServiceConfiguration
class RateLimitsConfig(object):
"""
Value object that represents the configuration needed to rate-limit services
"""
def __init__(self,
service,
username,
limit,
period):
self._service = service
self._username = username
self._limit = limit and int(limit)
self._period = period and int(period)
def __eq__(self, other):
return self.__dict__ == other.__dict__
# service this limit applies to
@property
def service(self):
return self._service
# user this limit applies to
@property
def username(self):
return self._username
# rate period in seconds
@property
def period(self):
return self._period
# rate limit in seconds
@property
def limit(self):
return self._limit
def is_limited(self):
return self._limit and self._limit > 0 and self._period and self._period > 0
class RateLimitsConfigBuilder(object):
"""
Build a rate limits configuration obtaining the parameters
from the user/org/server configuration.
"""
def __init__(self, server_conf, user_conf, org_conf, service, username, orgname):
self._server_conf = server_conf
self._user_conf = user_conf
self._org_conf = org_conf
self._service = service
self._username = username
self._orgname = orgname
def get(self):
# Order of precedence is user_conf, org_conf, server_conf
rate_limit_key = "{0}_rate_limit".format(self._service)
rate_limit_json = self._user_conf.get(rate_limit_key, None) or self._org_conf.get(rate_limit_key, None)
if (rate_limit_json):
rate_limit = rate_limit_json and json.loads(rate_limit_json)
else:
rate_limit = self._server_conf.get('rate_limits', {}).get(self._service, {})
return RateLimitsConfig(self._service,
self._username,
rate_limit.get('limit', None),
rate_limit.get('period', None))
class RateLimitsConfigSetter(object):
def __init__(self, service, username, orgname=None):
self._service = service
self._service_config = ServiceConfiguration(service, username, orgname)
def set_user_rate_limits(self, rate_limits_config):
# Note we allow copying a config from another user/service, so we
# ignore rate_limits:config.service and rate_limits:config.username
rate_limit_key = "{0}_rate_limit".format(self._service)
if rate_limits_config.is_limited():
rate_limit = {'limit': rate_limits_config.limit, 'period': rate_limits_config.period}
rate_limit_json = json.dumps(rate_limit)
self._service_config.user.set(rate_limit_key, rate_limit_json)
else:
self._service_config.user.remove(rate_limit_key)
def set_org_rate_limits(self, rate_limits_config):
rate_limit_key = "{0}_rate_limit".format(self._service)
if rate_limits_config.is_limited():
rate_limit = {'limit': rate_limits_config.limit, 'period': rate_limits_config.period}
rate_limit_json = json.dumps(rate_limit)
self._service_config.org.set(rate_limit_key, rate_limit_json)
else:
self._service_config.org.remove(rate_limit_key)
def set_server_rate_limits(self, rate_limits_config):
rate_limits = self._service_config.server.get('rate_limits', {})
if rate_limits_config.is_limited():
rate_limits[self._service] = {'limit': rate_limits_config.limit, 'period': rate_limits_config.period}
else:
rate_limits.pop(self._service, None)
if rate_limits:
self._service_config.server.set('rate_limits', rate_limits)
else:
self._service_config.server.remove('rate_limits')

View File

@ -0,0 +1,36 @@
from cartodb_services.refactor.core.environment import ServerEnvironmentBuilder
from cartodb_services.refactor.backend.server_config import ServerConfigBackendFactory
from cartodb_services.refactor.backend.user_config import UserConfigBackendFactory
from cartodb_services.refactor.backend.org_config import OrgConfigBackendFactory
class ServiceConfiguration(object):
"""
This class instantiates configuration backend objects for all the configuration levels of a service:
* environment
* server
* organization
* user
The configuration backends allow retrieval and modification of configuration parameters.
"""
def __init__(self, service, username, orgname):
self._server_config_backend = ServerConfigBackendFactory().get()
self._environment = ServerEnvironmentBuilder(self._server_config_backend ).get()
self._user_config_backend = UserConfigBackendFactory(username, self._environment, self._server_config_backend ).get()
self._org_config_backend = OrgConfigBackendFactory(orgname, self._environment, self._server_config_backend ).get()
@property
def environment(self):
return self._environment
@property
def server(self):
return self._server_config_backend
@property
def user(self):
return self._user_config_backend
@property
def org(self):
return self._org_config_backend

View File

@ -6,6 +6,6 @@ class ConfigBackendInterface(object):
__metaclass__ = abc.ABCMeta
@abc.abstractmethod
def get(self, key):
def get(self, key, default=None):
"""Return a value based on the key supplied from some storage"""
pass

View File

@ -5,8 +5,8 @@ class InMemoryConfigStorage(ConfigBackendInterface):
def __init__(self, config_hash={}):
self._config_hash = config_hash
def get(self, key):
def get(self, key, default=None):
try:
return self._config_hash[key]
except KeyError:
return None
return default

View File

@ -2,5 +2,5 @@ from ..core.interfaces import ConfigBackendInterface
class NullConfigStorage(ConfigBackendInterface):
def get(self, key):
return None
def get(self, key, default=None):
return default

View File

@ -9,11 +9,19 @@ class RedisConfigStorage(ConfigBackendInterface):
self._config_key = config_key
self._data = None
def get(self, key):
def get(self, key, default=KeyError):
if not self._data:
self._data = self._connection.hgetall(self._config_key)
return self._data[key]
if (default == KeyError):
return self._data[key]
else:
return self._data.get(key, default)
def set(self, key, value):
self._connection.hset(self._config_key, key, value)
def remove(self, key):
self._connection.hdel(self._config_key, key)
class RedisUserConfigStorageBuilder(object):
def __init__(self, redis_connection, username):

View File

@ -4,11 +4,28 @@ from ..core.interfaces import ConfigBackendInterface
class InDbServerConfigStorage(ConfigBackendInterface):
def get(self, key):
def get(self, key, default=None):
sql = "SELECT cdb_dataservices_server.cdb_conf_getconf('{0}') as conf".format(key)
rows = cartodb_services.plpy.execute(sql, 1)
json_output = rows[0]['conf']
if json_output:
json_output = None
try:
json_output = rows[0]['conf']
except (IndexError, KeyError):
pass
if (json_output):
return json.loads(json_output)
else:
return None
if (default == KeyError):
raise KeyError
else:
return default
def set(self, key, config):
json_config = json.dumps(config)
quoted_config = cartodb_services.plpy.quote_nullable(json_config)
sql = "SELECT cdb_dataservices_server.cdb_conf_setconf('{0}', {1})".format(key, quoted_config)
cartodb_services.plpy.execute(sql)
def remove(self, key):
sql = "SELECT cdb_dataservices_server.cdb_conf_removeconf('{0}')".format(key)
cartodb_services.plpy.execute(sql)

View File

@ -2,3 +2,6 @@ from redis_tools import RedisConnection, RedisDBConfig
from coordinates import Coordinate
from polyline import PolyLine
from log import Logger, LoggerConfig
from rate_limiter import RateLimiter
from service_manager import ServiceManager, RateLimitExceeded
from legacy_service_manager import LegacyServiceManager

View File

@ -0,0 +1,23 @@
from cartodb_services.metrics import QuotaService
from cartodb_services.tools import Logger,LoggerConfig
from cartodb_services.tools import RateLimiter
from cartodb_services.config import RateLimitsConfigLegacyBuilder
from cartodb_services.tools.service_manager import ServiceManagerBase
import plpy
class LegacyServiceManager(ServiceManagerBase):
"""
This service manager relies on cached configuration (in gd) stored in *legacy* configuration objects
It's intended for use by the *legacy* configuration objects (in use prior to the configuration refactor).
"""
def __init__(self, service, username, orgname, gd):
redis_conn = gd["redis_connection_{0}".format(username)]['redis_metrics_connection']
self.config = gd["user_{0}_config_{1}".format(service, username)]
logger_config = gd["logger_config"]
self.logger = Logger(logger_config)
self.quota_service = QuotaService(self.config, redis_conn)
rate_limit_config = RateLimitsConfigLegacyBuilder(redis_conn, plpy, service=service, username=username, orgname=orgname).get()
self.rate_limiter = RateLimiter(rate_limit_config, redis_conn)

View File

@ -0,0 +1,18 @@
from rratelimit import Limiter
class RateLimiter:
def __init__(self, rate_limits_config, redis_connection):
self._config = rate_limits_config
self._limiter = None
if (self._config.is_limited()):
self._limiter = Limiter(redis_connection,
action=self._config.service,
limit=self._config.limit,
period=self._config.period)
def check(self):
ok = True
if (self._limiter):
ok = self._limiter.checked_insert(self._config.username)
return ok

View File

@ -0,0 +1,70 @@
from cartodb_services.metrics import QuotaService
from cartodb_services.tools import Logger
from cartodb_services.tools import RateLimiter
from cartodb_services.refactor.tools.logger import LoggerConfigBuilder
from cartodb_services.refactor.backend.redis_metrics_connection import RedisMetricsConnectionFactory
from cartodb_services.config import ServiceConfiguration, RateLimitsConfigBuilder
class RateLimitExceeded(Exception):
def __str__(self):
return repr('Rate limit exceeded')
class ServiceManagerBase:
"""
A Service manager collects the configuration needed to use a service,
including thir-party services parameters.
This abstract class serves as the base for concrete service manager classes;
derived class must provide and initialize attributes for ``config``,
``quota_service``, ``logger`` and ``rate_limiter`` (which can be None
for no limits).
It provides an `assert_within_limits` method to check quota and rate limits
which raises exceptions when limits are exceeded.
It exposes properties containing:
* ``config`` : a configuration object containing the configuration parameters for
a given service and provider.
* ``quota_service`` a QuotaService object to for quota accounting
* ``logger``
"""
def assert_within_limits(self, quota=True, rate=True):
if rate and not self.rate_limiter.check():
raise RateLimitExceeded()
if quota and not self.quota_service.check_user_quota():
raise Exception('You have reached the limit of your quota')
@property
def config(self):
return self.config
@property
def quota_service(self):
return self.quota_service
@property
def logger(self):
return self.logger
class ServiceManager(ServiceManagerBase):
"""
This service manager delegates the configuration parameter details,
and the policies about configuration precedence to a configuration-builder class.
It uses the refactored configuration classes.
"""
def __init__(self, service, config_builder, username, orgname):
service_config = ServiceConfiguration(service, username, orgname)
logger_config = LoggerConfigBuilder(service_config.environment, service_config.server).get()
self.logger = Logger(logger_config)
self.config = config_builder(service_config.server, service_config.user, service_config.org, username, orgname).get()
rate_limit_config = RateLimitsConfigBuilder(service_config.server, service_config.user, service_config.org, service=service, username=username, orgname=orgname).get()
redis_metrics_connection = RedisMetricsConnectionFactory(service_config.environment, service_config.server).get()
self.rate_limiter = RateLimiter(rate_limit_config, redis_metrics_connection)
self.quota_service = QuotaService(self.config, redis_metrics_connection)

View File

@ -4,7 +4,8 @@ python-dateutil==2.2
googlemaps==2.4.2
rollbar==0.13.2
# Dependency for googlemaps package
requests<=2.9.1
requests==2.9.1
rratelimit==0.0.4
# Test
mock==1.3.0

View File

@ -16,7 +16,7 @@ class MockPlPy:
def __init__(self):
self._reset()
def _reset(self):
def _reset(self, log_executed_queries=False):
self.infos = []
self.notices = []
self.debugs = []
@ -28,11 +28,30 @@ class MockPlPy:
self.results = []
self.prepares = []
self.results = {}
self._log_executed_queries = log_executed_queries
self._logged_queries = []
def _define_result(self, query, result):
pattern = re.compile(query, re.IGNORECASE | re.MULTILINE)
self.results[pattern] = result
def _executed_queries(self):
if self._log_executed_queries:
return self._logged_queries
else:
raise Exception('Executed queries logging is not active')
def _has_executed_query(self, query):
pattern = re.compile(re.escape(query))
for executed_query in self._executed_queries():
if pattern.search(executed_query):
return True
return False
def _start_logging_executed_queries(self):
self._logged_queries = []
self._log_executed_queries = True
def notice(self, msg):
self.notices.append(msg)
@ -47,7 +66,15 @@ class MockPlPy:
return MockCursor(data)
def execute(self, query, rows=1):
if self._log_executed_queries:
self._logged_queries.append(query)
for pattern, result in self.results.iteritems():
if pattern.search(query):
return result
return []
def quote_nullable(self, value):
if value is None:
return 'NULL'
else:
return "'{0}'".format(value)

View File

@ -0,0 +1,124 @@
from test_helper import *
from unittest import TestCase
from mock import Mock, MagicMock, patch
from nose.tools import assert_raises, assert_not_equal, assert_equal
from datetime import datetime, date
from mockredis import MockRedis
import cartodb_services
from cartodb_services.tools import ServiceManager, LegacyServiceManager
from cartodb_services.metrics import GeocoderConfig
from cartodb_services.refactor.service.mapzen_geocoder_config import MapzenGeocoderConfigBuilder
from cartodb_services.refactor.backend.redis_metrics_connection import RedisConnectionBuilder
from cartodb_services.tools import RateLimitExceeded
from cartodb_services.refactor.storage.redis_config import *
from cartodb_services.refactor.storage.mem_config import InMemoryConfigStorage
from cartodb_services.refactor.backend.server_config import ServerConfigBackendFactory
from cartodb_services.config import RateLimitsConfig, RateLimitsConfigBuilder, RateLimitsConfigSetter
class TestRateLimitsConfig(TestCase):
def setUp(self):
plpy_mock_config()
cartodb_services.init(plpy_mock, _GD={})
self.username = 'test_user'
self.orgname = 'test_org'
self.redis_conn = MockRedis()
build_redis_user_config(self.redis_conn, self.username, 'geocoding')
build_redis_org_config(self.redis_conn, self.orgname, 'geocoding', provider='mapzen')
self.environment = 'production'
plpy_mock._define_result("CDB_Conf_GetConf\('server_conf'\)", [{'conf': '{"environment": "production"}'}])
plpy_mock._define_result("CDB_Conf_GetConf\('redis_metadata_config'\)", [{'conf': '{"redis_host":"localhost","redis_port":"6379"}'}])
plpy_mock._define_result("CDB_Conf_GetConf\('redis_metrics_config'\)", [{'conf': '{"redis_host":"localhost","redis_port":"6379"}'}])
basic_server_conf = {"server_conf": {"environment": "testing"},
"mapzen_conf":
{"geocoder":
{"api_key": "search-xxxxxxx", "monthly_quota": 1500000, "service":{"base_url":"http://base"}}
}, "logger_conf": {}}
self.empty_server_config = InMemoryConfigStorage(basic_server_conf)
self.empty_redis_config = InMemoryConfigStorage({})
self.user_config = RedisUserConfigStorageBuilder(self.redis_conn, self.username).get()
self.org_config = RedisOrgConfigStorageBuilder(self.redis_conn, self.orgname).get()
self.server_config = ServerConfigBackendFactory().get()
def test_server_config(self):
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
# Write server level configuration
config = RateLimitsConfig(service='geocoder', username=self.username, limit=1234, period=86400)
config_setter = RateLimitsConfigSetter(service='geocoder', username=self.username, orgname=self.orgname)
plpy_mock._start_logging_executed_queries()
config_setter.set_server_rate_limits(config)
assert plpy_mock._has_executed_query('cdb_conf_setconf(\'rate_limits\', \'{"geocoder": {"limit": 1234, "period": 86400}}\')')
# Re-read configuration
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [{'conf': '{"geocoder": {"limit": 1234, "period": 86400}}'}])
read_config = RateLimitsConfigBuilder(
server_conf=self.server_config,
user_conf=self.empty_redis_config,
org_conf=self.empty_redis_config,
service='geocoder',
username=self.username,
orgname=self.orgname
).get()
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [])
assert_equal(read_config, config)
def test_server_org_config(self):
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
server_config = RateLimitsConfig(service='geocoder', username=self.username, limit=1234, period=86400)
org_config = RateLimitsConfig(service='geocoder', username=self.username, limit=1235, period=86400)
config_setter = RateLimitsConfigSetter(service='geocoder', username=self.username, orgname=self.orgname)
# Write server level configuration
config_setter.set_server_rate_limits(server_config)
# Override with org level configuration
config_setter.set_org_rate_limits(org_config)
# Re-read configuration
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [{'conf': '{"geocoder": {"limit": 1234, "period": 86400}}'}])
read_config = RateLimitsConfigBuilder(
server_conf=self.server_config,
user_conf=self.empty_redis_config,
org_conf=self.org_config,
service='geocoder',
username=self.username,
orgname=self.orgname
).get()
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [])
assert_equal(read_config, org_config)
def test_server_org_user_config(self):
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
server_config = RateLimitsConfig(service='geocoder', username=self.username, limit=1234, period=86400)
org_config = RateLimitsConfig(service='geocoder', username=self.username, limit=1235, period=86400)
user_config = RateLimitsConfig(service='geocoder', username=self.username, limit=1236, period=86400)
config_setter = RateLimitsConfigSetter(service='geocoder', username=self.username, orgname=self.orgname)
# Write server level configuration
config_setter.set_server_rate_limits(server_config)
# Override with org level configuration
config_setter.set_org_rate_limits(org_config)
# Override with user level configuration
config_setter.set_user_rate_limits(user_config)
# Re-read configuration
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [{'conf': '{"geocoder": {"limit": 1234, "period": 86400}}'}])
read_config = RateLimitsConfigBuilder(
server_conf=self.server_config,
user_conf=self.user_config,
org_conf=self.org_config,
service='geocoder',
username=self.username,
orgname=self.orgname
).get()
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [])
assert_equal(read_config, user_config)

View File

@ -0,0 +1,218 @@
from test_helper import *
from unittest import TestCase
from mock import Mock, MagicMock, patch
from nose.tools import assert_raises, assert_not_equal, assert_equal
from datetime import datetime, date
from cartodb_services.tools import ServiceManager, LegacyServiceManager
from mockredis import MockRedis
import cartodb_services
from cartodb_services.metrics import GeocoderConfig
from cartodb_services.refactor.service.mapzen_geocoder_config import MapzenGeocoderConfigBuilder
from cartodb_services.refactor.backend.redis_metrics_connection import RedisConnectionBuilder
from cartodb_services.tools import RateLimitExceeded
LUA_AVAILABLE_FOR_MOCKREDIS = False
class MockRedisWithVersionInfo(MockRedis):
def info(self):
return {'redis_version': '3.0.2'}
class TestServiceManager(TestCase):
def setUp(self):
plpy_mock_config()
cartodb_services.init(plpy_mock, _GD={})
self.username = 'test_user'
self.orgname = 'test_org'
self.redis_conn = MockRedisWithVersionInfo()
build_redis_user_config(self.redis_conn, self.username, 'geocoding')
build_redis_org_config(self.redis_conn, self.orgname, 'geocoding', provider='mapzen')
self.environment = 'production'
plpy_mock._define_result("CDB_Conf_GetConf\('server_conf'\)", [{'conf': '{"environment": "production"}'}])
plpy_mock._define_result("CDB_Conf_GetConf\('redis_metadata_config'\)", [{'conf': '{"redis_host":"localhost","redis_port":"6379"}'}])
plpy_mock._define_result("CDB_Conf_GetConf\('redis_metrics_config'\)", [{'conf': '{"redis_host":"localhost","redis_port":"6379"}'}])
def check_rate_limit(self, service_manager, n, active=True):
if LUA_AVAILABLE_FOR_MOCKREDIS:
for _ in xrange(n):
service_manager.assert_within_limits()
if active:
with assert_raises(RateLimitExceeded):
service_manager.assert_within_limits()
else:
service_manager.assert_within_limits()
else:
# rratelimit doesn't work with MockRedis because it needs Lua support
# so, we'll simply perform some sanity check on the configuration of the rate limiter
if active:
assert_equal(service_manager.rate_limiter._config.is_limited(), True)
assert_equal(service_manager.rate_limiter._config.limit, n)
else:
assert not service_manager.rate_limiter._config.is_limited()
def test_legacy_service_manager(self):
config = GeocoderConfig(self.redis_conn, plpy_mock, self.username, self.orgname, 'mapzen')
config_cache = {
'redis_connection_test_user' : { 'redis_metrics_connection': self.redis_conn },
'user_geocoder_config_test_user' : config,
'logger_config' : Mock(min_log_level='debug', log_file_path=None, rollbar_api_key=None, environment=self.environment)
}
service_manager = LegacyServiceManager('geocoder', self.username, self.orgname, config_cache)
service_manager.assert_within_limits()
assert_equal(service_manager.config.service_type, 'geocoder_mapzen')
def test_service_manager(self):
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
service_manager = ServiceManager('geocoder', MapzenGeocoderConfigBuilder, self.username, self.orgname)
service_manager.assert_within_limits()
assert_equal(service_manager.config.service_type, 'geocoder_mapzen')
def test_no_rate_limit_by_default(self):
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
service_manager = ServiceManager('geocoder', MapzenGeocoderConfigBuilder, self.username, self.orgname)
self.check_rate_limit(service_manager, 3, False)
def test_no_legacy_rate_limit_by_default(self):
config = GeocoderConfig(self.redis_conn, plpy_mock, self.username, self.orgname, 'mapzen')
config_cache = {
'redis_connection_test_user' : { 'redis_metrics_connection': self.redis_conn },
'user_geocoder_config_test_user' : config,
'logger_config' : Mock(min_log_level='debug', log_file_path=None, rollbar_api_key=None, environment=self.environment)
}
service_manager = LegacyServiceManager('geocoder', self.username, self.orgname, config_cache)
self.check_rate_limit(service_manager, 3, False)
def test_legacy_server_rate_limit(self):
rate_limits = '{"geocoder":{"limit":"3","period":3600}}'
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [{'conf': rate_limits}])
config = GeocoderConfig(self.redis_conn, plpy_mock, self.username, self.orgname, 'mapzen')
config_cache = {
'redis_connection_test_user' : { 'redis_metrics_connection': self.redis_conn },
'user_geocoder_config_test_user' : config,
'logger_config' : Mock(min_log_level='debug', log_file_path=None, rollbar_api_key=None, environment=self.environment)
}
service_manager = LegacyServiceManager('geocoder', self.username, self.orgname, config_cache)
self.check_rate_limit(service_manager, 3)
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [])
def test_server_rate_limit(self):
rate_limits = '{"geocoder":{"limit":"3","period":3600}}'
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [{'conf': rate_limits}])
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
service_manager = ServiceManager('geocoder', MapzenGeocoderConfigBuilder, self.username, self.orgname)
self.check_rate_limit(service_manager, 3)
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [])
def test_user_rate_limit(self):
user_redis_name = "rails:users:{0}".format(self.username)
rate_limits = '{"limit":"3","period":3600}'
self.redis_conn.hset(user_redis_name, 'geocoder_rate_limit', rate_limits)
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
service_manager = ServiceManager('geocoder', MapzenGeocoderConfigBuilder, self.username, self.orgname)
self.check_rate_limit(service_manager, 3)
self.redis_conn.hdel(user_redis_name, 'geocoder_rate_limit')
def test_legacy_user_rate_limit(self):
user_redis_name = "rails:users:{0}".format(self.username)
rate_limits = '{"limit":"3","period":3600}'
self.redis_conn.hset(user_redis_name, 'geocoder_rate_limit', rate_limits)
config = GeocoderConfig(self.redis_conn, plpy_mock, self.username, self.orgname, 'mapzen')
config_cache = {
'redis_connection_test_user' : { 'redis_metrics_connection': self.redis_conn },
'user_geocoder_config_test_user' : config,
'logger_config' : Mock(min_log_level='debug', log_file_path=None, rollbar_api_key=None, environment=self.environment)
}
service_manager = LegacyServiceManager('geocoder', self.username, self.orgname, config_cache)
self.check_rate_limit(service_manager, 3)
self.redis_conn.hdel(user_redis_name, 'geocoder_rate_limit')
def test_org_rate_limit(self):
org_redis_name = "rails:orgs:{0}".format(self.orgname)
rate_limits = '{"limit":"3","period":3600}'
self.redis_conn.hset(org_redis_name, 'geocoder_rate_limit', rate_limits)
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
service_manager = ServiceManager('geocoder', MapzenGeocoderConfigBuilder, self.username, self.orgname)
self.check_rate_limit(service_manager, 3)
self.redis_conn.hdel(org_redis_name, 'geocoder_rate_limit')
def test_legacy_org_rate_limit(self):
org_redis_name = "rails:orgs:{0}".format(self.orgname)
rate_limits = '{"limit":"3","period":3600}'
self.redis_conn.hset(org_redis_name, 'geocoder_rate_limit', rate_limits)
config = GeocoderConfig(self.redis_conn, plpy_mock, self.username, self.orgname, 'mapzen')
config_cache = {
'redis_connection_test_user' : { 'redis_metrics_connection': self.redis_conn },
'user_geocoder_config_test_user' : config,
'logger_config' : Mock(min_log_level='debug', log_file_path=None, rollbar_api_key=None, environment=self.environment)
}
service_manager = LegacyServiceManager('geocoder', self.username, self.orgname, config_cache)
self.check_rate_limit(service_manager, 3)
self.redis_conn.hdel(org_redis_name, 'geocoder_rate_limit')
def test_user_rate_limit_precedence_over_org(self):
org_redis_name = "rails:orgs:{0}".format(self.orgname)
org_rate_limits = '{"limit":"1000","period":3600}'
self.redis_conn.hset(org_redis_name, 'geocoder_rate_limit', org_rate_limits)
user_redis_name = "rails:users:{0}".format(self.username)
user_rate_limits = '{"limit":"3","period":3600}'
self.redis_conn.hset(user_redis_name, 'geocoder_rate_limit', user_rate_limits)
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
service_manager = ServiceManager('geocoder', MapzenGeocoderConfigBuilder, self.username, self.orgname)
self.check_rate_limit(service_manager, 3)
self.redis_conn.hdel(org_redis_name, 'geocoder_rate_limit')
self.redis_conn.hdel(user_redis_name, 'geocoder_rate_limit')
def test_org_rate_limit_precedence_over_server(self):
server_rate_limits = '{"geocoder":{"limit":"1000","period":3600}}'
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [{'conf': server_rate_limits}])
org_redis_name = "rails:orgs:{0}".format(self.orgname)
org_rate_limits = '{"limit":"3","period":3600}'
self.redis_conn.hset(org_redis_name, 'geocoder_rate_limit', org_rate_limits)
with patch.object(RedisConnectionBuilder,'get') as get_fn:
get_fn.return_value = self.redis_conn
service_manager = ServiceManager('geocoder', MapzenGeocoderConfigBuilder, self.username, self.orgname)
self.check_rate_limit(service_manager, 3)
self.redis_conn.hdel(org_redis_name, 'geocoder_rate_limit')
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [])
def test_legacy_user_rate_limit_precedence_over_org(self):
org_redis_name = "rails:orgs:{0}".format(self.orgname)
org_rate_limits = '{"limit":"1000","period":3600}'
self.redis_conn.hset(org_redis_name, 'geocoder_rate_limit', org_rate_limits)
user_redis_name = "rails:users:{0}".format(self.username)
user_rate_limits = '{"limit":"3","period":3600}'
self.redis_conn.hset(user_redis_name, 'geocoder_rate_limit', user_rate_limits)
config = GeocoderConfig(self.redis_conn, plpy_mock, self.username, self.orgname, 'mapzen')
config_cache = {
'redis_connection_test_user' : { 'redis_metrics_connection': self.redis_conn },
'user_geocoder_config_test_user' : config,
'logger_config' : Mock(min_log_level='debug', log_file_path=None, rollbar_api_key=None, environment=self.environment)
}
service_manager = LegacyServiceManager('geocoder', self.username, self.orgname, config_cache)
self.check_rate_limit(service_manager, 3)
self.redis_conn.hdel(org_redis_name, 'geocoder_rate_limit')
self.redis_conn.hdel(user_redis_name, 'geocoder_rate_limit')
def test_legacy_org_rate_limit_precedence_over_server(self):
server_rate_limits = '{"geocoder":{"limit":"1000","period":3600}}'
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [{'conf': server_rate_limits}])
org_redis_name = "rails:orgs:{0}".format(self.orgname)
org_rate_limits = '{"limit":"3","period":3600}'
self.redis_conn.hset(org_redis_name, 'geocoder_rate_limit', org_rate_limits)
config = GeocoderConfig(self.redis_conn, plpy_mock, self.username, self.orgname, 'mapzen')
config_cache = {
'redis_connection_test_user' : { 'redis_metrics_connection': self.redis_conn },
'user_geocoder_config_test_user' : config,
'logger_config' : Mock(min_log_level='debug', log_file_path=None, rollbar_api_key=None, environment=self.environment)
}
service_manager = LegacyServiceManager('geocoder', self.username, self.orgname, config_cache)
self.check_rate_limit(service_manager, 3)
self.redis_conn.hdel(org_redis_name, 'geocoder_rate_limit')
plpy_mock._define_result("CDB_Conf_GetConf\('rate_limits'\)", [])