Skip to content

Commit a03d3cc

Browse files
authored
Add support for request_delay in client and server (#2237)
1 parent 6e9d171 commit a03d3cc

7 files changed

Lines changed: 88 additions & 5 deletions

File tree

openapi/index_openapi.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2004,6 +2004,19 @@
20042004
"title": "Response Message",
20052005
"description": "response string from the server"
20062006
},
2007+
"request_delay": {
2008+
"anyOf": [
2009+
{
2010+
"type": "number",
2011+
"minimum": 0.0
2012+
},
2013+
{
2014+
"type": "null"
2015+
}
2016+
],
2017+
"title": "Request Delay",
2018+
"description": "A non-negative float giving time in seconds that the client is suggested to wait before issuing a subsequent request.\nImplementation note: the functionality of this field overlaps to some degree with features provided by the HTTP error `429 Too Many Requests` and the `Retry-After` HTTP header.\nImplementations are suggested to provide consistent handling of request overload through both mechanisms."
2019+
},
20072020
"implementation": {
20082021
"anyOf": [
20092022
{

openapi/openapi.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3852,6 +3852,19 @@
38523852
"title": "Response Message",
38533853
"description": "response string from the server"
38543854
},
3855+
"request_delay": {
3856+
"anyOf": [
3857+
{
3858+
"type": "number",
3859+
"minimum": 0.0
3860+
},
3861+
{
3862+
"type": "null"
3863+
}
3864+
],
3865+
"title": "Request Delay",
3866+
"description": "A non-negative float giving time in seconds that the client is suggested to wait before issuing a subsequent request.\nImplementation note: the functionality of this field overlaps to some degree with features provided by the HTTP error `429 Too Many Requests` and the `Retry-After` HTTP header.\nImplementations are suggested to provide consistent handling of request overload through both mechanisms."
3867+
},
38553868
"implementation": {
38563869
"anyOf": [
38573870
{

optimade/client/client.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,8 @@ async def _get_one_async(
954954
if override_url:
955955
next_url = override_url
956956

957+
request_delay: float | None = None
958+
957959
results = QueryResults()
958960
try:
959961
async with self._http_client(headers=self.headers) as client: # type: ignore[union-attr,call-arg,misc]
@@ -968,13 +970,18 @@ async def _get_one_async(
968970
next_url, follow_redirects=True, timeout=self.http_timeout
969971
)
970972
page_results, next_url = self._handle_response(r, _task)
973+
request_delay = page_results["meta"].get("request_delay", None)
974+
# Don't wait any longer than 5 seconds
975+
if request_delay:
976+
request_delay = min(request_delay, 5)
977+
971978
except RecoverableHTTPError:
972979
attempts += 1
973980
if attempts > self.max_attempts:
974981
raise RuntimeError(
975982
f"Exceeded maximum number of retries for {next_url}"
976983
)
977-
await asyncio.sleep(1)
984+
await asyncio.sleep(request_delay or 1)
978985
continue
979986

980987
results.update(page_results)
@@ -1023,6 +1030,8 @@ def _get_one(
10231030
if override_url:
10241031
next_url = override_url
10251032

1033+
request_delay: float | None = None
1034+
10261035
results = QueryResults()
10271036
try:
10281037
with self._http_client() as client: # type: ignore[misc]
@@ -1041,13 +1050,19 @@ def _get_one(
10411050
)
10421051
r = client.get(next_url, timeout=timeout)
10431052
page_results, next_url = self._handle_response(r, _task)
1053+
1054+
request_delay = page_results["meta"].get("request_delay", None)
1055+
# Don't wait any longer than 5 seconds
1056+
if request_delay:
1057+
request_delay = min(request_delay, 5)
1058+
10441059
except RecoverableHTTPError:
10451060
attempts += 1
10461061
if attempts > self.max_attempts:
10471062
raise RuntimeError(
10481063
f"Exceeded maximum number of retries for {next_url}"
10491064
)
1050-
time.sleep(1)
1065+
time.sleep(request_delay or 1)
10511066
continue
10521067

10531068
results.update(page_results)

optimade/models/optimade_json.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
from enum import Enum
55
from typing import Annotated, Any, Literal, Optional
66

7-
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
7+
from pydantic import (
8+
BaseModel,
9+
ConfigDict,
10+
EmailStr,
11+
Field,
12+
NonNegativeFloat,
13+
model_validator,
14+
)
815

916
from optimade.models import jsonapi
1017
from optimade.models.types import SemanticVersion
@@ -360,6 +367,15 @@ class ResponseMeta(jsonapi.Meta):
360367
str | None, StrictField(description="response string from the server")
361368
] = None
362369

370+
request_delay: Annotated[
371+
NonNegativeFloat | None,
372+
StrictField(
373+
description="""A non-negative float giving time in seconds that the client is suggested to wait before issuing a subsequent request.
374+
Implementation note: the functionality of this field overlaps to some degree with features provided by the HTTP error `429 Too Many Requests` and the `Retry-After` HTTP header.
375+
Implementations are suggested to provide consistent handling of request overload through both mechanisms."""
376+
),
377+
] = None
378+
363379
implementation: Annotated[
364380
Implementation | None,
365381
StrictField(description="a dictionary describing the server implementation"),

optimade/server/config.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
from typing import Annotated, Any, Literal
77

88
import yaml
9-
from pydantic import AnyHttpUrl, Field, field_validator, model_validator
9+
from pydantic import (
10+
AnyHttpUrl,
11+
Field,
12+
NonNegativeFloat,
13+
field_validator,
14+
model_validator,
15+
)
1016
from pydantic.fields import FieldInfo
1117
from pydantic_settings import (
1218
BaseSettings,
@@ -435,6 +441,12 @@ class ServerConfig(BaseSettings):
435441
),
436442
),
437443
] = True
444+
request_delay: Annotated[
445+
NonNegativeFloat | None,
446+
Field(
447+
description="The value to use for the `meta->request_delay` field, which indicates to clients how long they should leave between success queries."
448+
),
449+
] = None
438450

439451
validate_api_response: Annotated[
440452
bool | None,
@@ -455,6 +467,15 @@ def check_jsonl_path(cls, value: Any) -> Path | None:
455467

456468
return value
457469

470+
@field_validator("request_delay", mode="before")
471+
@classmethod
472+
def check_request_delay(cls, value: Any) -> Path | None:
473+
"""Make sure the request delay is not adversarially large."""
474+
if value and value > 5:
475+
raise ValueError("Request delay must be less than 5 seconds.")
476+
477+
return value
478+
458479
@field_validator("implementation", mode="before")
459480
@classmethod
460481
def set_implementation_version(cls, value: Any) -> dict[str, Any]:

optimade/server/routers/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ def meta_values(
6969
if schema is None:
7070
schema = CONFIG.schema_url if not CONFIG.is_index else CONFIG.index_schema_url
7171

72+
if CONFIG.request_delay:
73+
# Double-guard against the server setting an adversarially large request delay
74+
kwargs["request_delay"] = min(CONFIG.request_delay, 10.0)
75+
7276
return ResponseMeta(
7377
query=ResponseMetaQuery(representation=f"{url_path}?{url.query}"),
7478
api_version=__api_version__,

tests/test_config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@
3838
"chemsys": "nelements"
3939
}
4040
},
41-
"license": "CC-BY-4.0"
41+
"license": "CC-BY-4.0",
42+
"request_delay": 0.1
4243
}

0 commit comments

Comments
 (0)