batchwork-ai 0.1.1__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/PKG-INFO +42 -2
  2. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/README.md +38 -1
  3. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/pyproject.toml +8 -1
  4. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/__init__.py +7 -1
  5. batchwork_ai-0.2.1/src/batchwork/_bounded_json.py +27 -0
  6. batchwork_ai-0.2.1/src/batchwork/_limits.py +18 -0
  7. batchwork_ai-0.2.1/src/batchwork/_provider_failure.py +119 -0
  8. batchwork_ai-0.2.1/src/batchwork/_text_validation.py +423 -0
  9. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/body.py +132 -30
  10. batchwork_ai-0.2.1/src/batchwork/cli/__init__.py +62 -0
  11. batchwork_ai-0.2.1/src/batchwork/cli/_commands.py +1966 -0
  12. batchwork_ai-0.2.1/src/batchwork/cli/_config.py +351 -0
  13. batchwork_ai-0.2.1/src/batchwork/cli/_contract.py +833 -0
  14. batchwork_ai-0.2.1/src/batchwork/cli/_failures.py +350 -0
  15. batchwork_ai-0.2.1/src/batchwork/cli/_human.py +363 -0
  16. batchwork_ai-0.2.1/src/batchwork/cli/_input.py +630 -0
  17. batchwork_ai-0.2.1/src/batchwork/cli/_lifecycle.py +971 -0
  18. batchwork_ai-0.2.1/src/batchwork/cli/_materialize.py +661 -0
  19. batchwork_ai-0.2.1/src/batchwork/cli/_output.py +74 -0
  20. batchwork_ai-0.2.1/src/batchwork/cli/_registry.py +796 -0
  21. batchwork_ai-0.2.1/src/batchwork/cli/_state.py +24 -0
  22. batchwork_ai-0.2.1/src/batchwork/cli/_submit_text.py +940 -0
  23. batchwork_ai-0.2.1/src/batchwork/cli/_volume.py +37 -0
  24. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/client.py +237 -43
  25. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/errors.py +20 -0
  26. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/job.py +23 -2
  27. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/media.py +122 -21
  28. batchwork_ai-0.2.1/src/batchwork/providers/_image.py +282 -0
  29. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/adapter.py +2 -1
  30. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/anthropic.py +11 -3
  31. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/google.py +12 -3
  32. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/mistral.py +11 -3
  33. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/openai_compatible.py +65 -33
  34. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/shared.py +196 -74
  35. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/xai.py +3 -2
  36. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/types.py +15 -5
  37. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/LICENSE +0 -0
  38. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/_anthropic_serialization.py +0 -0
  39. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/_compatible_media.py +0 -0
  40. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/_google_schema.py +0 -0
  41. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/_google_serialization.py +0 -0
  42. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/_network.py +0 -0
  43. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/_serialization.py +0 -0
  44. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/_typing.py +0 -0
  45. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/__init__.py +0 -0
  46. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/groq.py +0 -0
  47. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/ids.py +0 -0
  48. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/openai.py +0 -0
  49. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/providers/together.py +0 -0
  50. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/py.typed +0 -0
  51. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/server/__init__.py +0 -0
  52. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/server/models.py +0 -0
  53. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/server/poller.py +0 -0
  54. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/server/signing.py +0 -0
  55. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/server/transport.py +0 -0
  56. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/stores/__init__.py +0 -0
  57. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/stores/base.py +0 -0
  58. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/stores/memory.py +0 -0
  59. {batchwork_ai-0.1.1 → batchwork_ai-0.2.1}/src/batchwork/stores/redis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchwork-ai
3
- Version: 0.1.1
3
+ Version: 0.2.1
4
4
  Summary: Unified async batch API for AI providers
5
5
  Keywords: ai,anthropic,batch,gemini,llm,openai
6
6
  Author: Ajanraj
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Typing :: Typed
19
+ Requires-Dist: click>=8.4,<9
19
20
  Requires-Dist: httpcore>=1,<2
20
21
  Requires-Dist: httpx>=0.28,<1
21
22
  Requires-Dist: pydantic>=2.11,<3
@@ -24,18 +25,23 @@ Maintainer: Ajanraj
24
25
  Maintainer-email: Ajanraj <hey@ajanraj.com>
25
26
  Requires-Python: >=3.11
26
27
  Project-URL: Documentation, https://batchwork.ajanraj.com
28
+ Project-URL: Issues, https://github.com/ajanraj/batchwork-ai/issues
29
+ Project-URL: Repository, https://github.com/ajanraj/batchwork-ai
27
30
  Provides-Extra: redis
28
31
  Description-Content-Type: text/markdown
29
32
 
30
33
  # Batchwork
31
34
 
32
- Unified async batch API for OpenAI, Anthropic, Google Gemini, Groq, Mistral, Together AI, and xAI. Access provider-native batch pricingoften up to 50% lower than standard synchronous requests—through one typed interface while Batchwork handles provider-specific serialization, submission, polling, and result normalization. Pricing and eligibility remain provider- and model-specific. Built for Python by Ajanraj.
35
+ ![Batchwork — one typed API for provider-native AI batch jobs](docs/public/og-image.png)
36
+
37
+ Unified async batch API for OpenAI, Anthropic, Google Gemini, Groq, Mistral, Together AI, and xAI. Providers price eligible batch requests up to 50% below standard synchronous calls, and Batchwork gives you that pricing through one typed Python interface and one CLI. It handles provider-specific serialization, submission, polling, and result parsing so you do not have to. Pricing and eligibility remain provider- and model-specific.
33
38
 
34
39
  - One typed API for provider-native batch jobs
35
40
  - Text, embedding, and image workloads
36
41
  - Normalized jobs, results, usage, and errors
37
42
  - Messages, tools, structured content, and remote media
38
43
  - Polling, persistent stores, and signed webhooks
44
+ - A `batchwork` CLI and Agent Skill for terminals, scripts, and coding agents
39
45
  - No provider SDK or JavaScript runtime dependencies
40
46
 
41
47
  📖 **Full documentation: [batchwork.ajanraj.com](https://batchwork.ajanraj.com)**
@@ -64,6 +70,40 @@ export OPENAI_API_KEY="..."
64
70
 
65
71
  See [Configuration](https://batchwork.ajanraj.com/docs/configuration) for every provider credential, endpoint override, model format, and batch limit.
66
72
 
73
+ ### Command-line tool
74
+
75
+ The same package ships a `batchwork` command, so you can submit and manage batches without writing Python. Install it with `uv tool`:
76
+
77
+ ```bash
78
+ uv tool install batchwork-ai
79
+ batchwork --version
80
+ ```
81
+
82
+ Human quick start:
83
+
84
+ ```bash
85
+ batchwork submit text prompts.txt --model openai/gpt-5
86
+ batchwork list
87
+ batchwork status BW_RECORD_ID
88
+ batchwork results BW_RECORD_ID
89
+ ```
90
+
91
+ Agent and automation quick start:
92
+
93
+ ```bash
94
+ batchwork --jsonl --quiet run text requests.jsonl --model openai/gpt-5
95
+ ```
96
+
97
+ Machine output uses schema version 1. Global controls such as `--jsonl`, `--profile`, and `--quiet` precede the command. Preserve the first emitted job identity, capture stdout/stderr/exit status separately, and never retry an acceptance-ambiguous submission automatically. Image files are written only when `--output-dir` is explicit.
98
+
99
+ If you use a coding agent, the `batchwork-ai` Agent Skill teaches it the same contract, including when to ask before spending money and how to resume interrupted jobs:
100
+
101
+ ```bash
102
+ npx skills add ajanraj/batchwork-ai@batchwork-ai
103
+ ```
104
+
105
+ See the [CLI guide](https://batchwork.ajanraj.com/docs/cli), [configuration and registry reference](https://batchwork.ajanraj.com/docs/reference/cli-configuration-registry), [machine schema](https://batchwork.ajanraj.com/docs/reference/cli-machine-schema), and [exit catalog](https://batchwork.ajanraj.com/docs/guides/cli-exits).
106
+
67
107
  ## Quickstart
68
108
 
69
109
  ```python
@@ -1,12 +1,15 @@
1
1
  # Batchwork
2
2
 
3
- Unified async batch API for OpenAI, Anthropic, Google Gemini, Groq, Mistral, Together AI, and xAI. Access provider-native batch pricingoften up to 50% lower than standard synchronous requests—through one typed interface while Batchwork handles provider-specific serialization, submission, polling, and result normalization. Pricing and eligibility remain provider- and model-specific. Built for Python by Ajanraj.
3
+ ![Batchwork — one typed API for provider-native AI batch jobs](docs/public/og-image.png)
4
+
5
+ Unified async batch API for OpenAI, Anthropic, Google Gemini, Groq, Mistral, Together AI, and xAI. Providers price eligible batch requests up to 50% below standard synchronous calls, and Batchwork gives you that pricing through one typed Python interface and one CLI. It handles provider-specific serialization, submission, polling, and result parsing so you do not have to. Pricing and eligibility remain provider- and model-specific.
4
6
 
5
7
  - One typed API for provider-native batch jobs
6
8
  - Text, embedding, and image workloads
7
9
  - Normalized jobs, results, usage, and errors
8
10
  - Messages, tools, structured content, and remote media
9
11
  - Polling, persistent stores, and signed webhooks
12
+ - A `batchwork` CLI and Agent Skill for terminals, scripts, and coding agents
10
13
  - No provider SDK or JavaScript runtime dependencies
11
14
 
12
15
  📖 **Full documentation: [batchwork.ajanraj.com](https://batchwork.ajanraj.com)**
@@ -35,6 +38,40 @@ export OPENAI_API_KEY="..."
35
38
 
36
39
  See [Configuration](https://batchwork.ajanraj.com/docs/configuration) for every provider credential, endpoint override, model format, and batch limit.
37
40
 
41
+ ### Command-line tool
42
+
43
+ The same package ships a `batchwork` command, so you can submit and manage batches without writing Python. Install it with `uv tool`:
44
+
45
+ ```bash
46
+ uv tool install batchwork-ai
47
+ batchwork --version
48
+ ```
49
+
50
+ Human quick start:
51
+
52
+ ```bash
53
+ batchwork submit text prompts.txt --model openai/gpt-5
54
+ batchwork list
55
+ batchwork status BW_RECORD_ID
56
+ batchwork results BW_RECORD_ID
57
+ ```
58
+
59
+ Agent and automation quick start:
60
+
61
+ ```bash
62
+ batchwork --jsonl --quiet run text requests.jsonl --model openai/gpt-5
63
+ ```
64
+
65
+ Machine output uses schema version 1. Global controls such as `--jsonl`, `--profile`, and `--quiet` precede the command. Preserve the first emitted job identity, capture stdout/stderr/exit status separately, and never retry an acceptance-ambiguous submission automatically. Image files are written only when `--output-dir` is explicit.
66
+
67
+ If you use a coding agent, the `batchwork-ai` Agent Skill teaches it the same contract, including when to ask before spending money and how to resume interrupted jobs:
68
+
69
+ ```bash
70
+ npx skills add ajanraj/batchwork-ai@batchwork-ai
71
+ ```
72
+
73
+ See the [CLI guide](https://batchwork.ajanraj.com/docs/cli), [configuration and registry reference](https://batchwork.ajanraj.com/docs/reference/cli-configuration-registry), [machine schema](https://batchwork.ajanraj.com/docs/reference/cli-machine-schema), and [exit catalog](https://batchwork.ajanraj.com/docs/guides/cli-exits).
74
+
38
75
  ## Quickstart
39
76
 
40
77
  ```python
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "batchwork-ai"
7
- version = "0.1.1"
7
+ version = "0.2.1"
8
8
  description = "Unified async batch API for AI providers"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -25,19 +25,26 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  ]
27
27
  dependencies = [
28
+ "click>=8.4,<9",
28
29
  "httpcore>=1,<2",
29
30
  "httpx>=0.28,<1",
30
31
  "pydantic>=2.11,<3",
31
32
  ]
32
33
 
34
+ [project.scripts]
35
+ batchwork = "batchwork.cli:main"
36
+
33
37
  [project.optional-dependencies]
34
38
  redis = ["upstash-redis>=1.4,<2"]
35
39
 
36
40
  [project.urls]
37
41
  Documentation = "https://batchwork.ajanraj.com"
42
+ Issues = "https://github.com/ajanraj/batchwork-ai/issues"
43
+ Repository = "https://github.com/ajanraj/batchwork-ai"
38
44
 
39
45
  [dependency-groups]
40
46
  dev = [
47
+ "jsonschema>=4.25,<5",
41
48
  "pytest>=8.4,<9",
42
49
  "pytest-asyncio>=1,<2",
43
50
  "pytest-cov>=6.2,<7",
@@ -1,5 +1,7 @@
1
1
  """Unified async batch API for AI providers."""
2
2
 
3
+ from importlib.metadata import version
4
+
3
5
  from .client import Batchwork
4
6
  from .errors import (
5
7
  BatchClosedError,
@@ -50,7 +52,9 @@ from .types import (
50
52
  AssistantContentPart,
51
53
  AssistantMessage,
52
54
  BatchDefaults,
55
+ BatchEmbeddingDefaults,
53
56
  BatchEmbeddingRequest,
57
+ BatchEmbeddingRequestSettings,
54
58
  BatchImage,
55
59
  BatchImageDefaults,
56
60
  BatchImageRequest,
@@ -129,7 +133,7 @@ from .types import (
129
133
  utc_datetime,
130
134
  )
131
135
 
132
- __version__ = "0.1.1"
136
+ __version__ = version("batchwork-ai")
133
137
 
134
138
  __all__ = [
135
139
  "TERMINAL_STATUSES",
@@ -138,7 +142,9 @@ __all__ = [
138
142
  "AtomicWebhookReplayStore",
139
143
  "BatchClosedError",
140
144
  "BatchDefaults",
145
+ "BatchEmbeddingDefaults",
141
146
  "BatchEmbeddingRequest",
147
+ "BatchEmbeddingRequestSettings",
142
148
  "BatchImage",
143
149
  "BatchImageDefaults",
144
150
  "BatchImageRequest",
@@ -0,0 +1,27 @@
1
+ """Incremental compact JSON encoding with a strict byte ceiling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+
8
+
9
+ class JsonSizeExceeded(ValueError):
10
+ """Raised as soon as compact JSON crosses its byte ceiling."""
11
+
12
+ def __init__(self, maximum: int, known_size: int | None = None) -> None:
13
+ super().__init__()
14
+ self.maximum = maximum
15
+ self.known_size = known_size
16
+
17
+
18
+ def encode_bounded_json(value: Mapping[str, object], maximum: int) -> bytes:
19
+ """Encode compact JSON, stopping at the first encoded chunk over ``maximum``."""
20
+ chunks = iter(json.JSONEncoder(ensure_ascii=False, separators=(",", ":")).iterencode(value))
21
+ encoded = bytearray()
22
+ for chunk in chunks:
23
+ part = chunk.encode()
24
+ if len(encoded) + len(part) > maximum:
25
+ raise JsonSizeExceeded(maximum, len(encoded) + len(part))
26
+ encoded.extend(part)
27
+ return bytes(encoded)
@@ -0,0 +1,18 @@
1
+ """Fixed package and CLI v1 safety boundaries."""
2
+
3
+ MAX_REQUESTS = 50_000
4
+ MAX_REQUEST_BYTES = 20 * 1024 * 1024
5
+ MAX_UPLOAD_BYTES = 200 * 1024 * 1024
6
+
7
+ MAX_SOURCE_BYTES = 256 * 1024 * 1024
8
+ MAX_RECORD_BYTES = 24 * 1024 * 1024
9
+ MAX_PROVIDER_OPTIONS_BYTES = 1024 * 1024
10
+ MAX_DECODED_MEDIA_BYTES = 20 * 1024 * 1024
11
+ MAX_AGGREGATE_MEDIA_BYTES = 200 * 1024 * 1024
12
+ MAX_PROVIDER_RESPONSE_BYTES = 320 * 1024 * 1024
13
+ MAX_RESULT_RECORD_BYTES = 320 * 1024 * 1024
14
+ MAX_AGGREGATE_RESULTS_BYTES = 1024 * 1024 * 1024
15
+
16
+ LARGE_BATCH_REQUESTS = 10_000
17
+ LARGE_BATCH_UPLOAD_BYTES = 50 * 1024 * 1024
18
+ LARGE_BATCH_IMAGES = 100
@@ -0,0 +1,119 @@
1
+ """Safe, structured failures raised by provider HTTP helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from email.utils import parsedate_to_datetime
8
+ from enum import StrEnum
9
+ from typing import Final
10
+
11
+ import httpx
12
+
13
+ from .errors import BatchworkError
14
+
15
+ _REQUEST_ID_HEADERS: Final = (
16
+ "x-request-id",
17
+ "request-id",
18
+ "anthropic-request-id",
19
+ "x-goog-request-id",
20
+ "x-amzn-requestid",
21
+ "x-amz-request-id",
22
+ )
23
+ _MAX_REQUEST_ID_LENGTH: Final = 256
24
+ _MAX_RETRY_AFTER_SECONDS: Final = 3600
25
+ _RETRYABLE_CLIENT_STATUSES: Final = frozenset({408, 425, 429})
26
+
27
+
28
+ class ProviderFailureKind(StrEnum):
29
+ """Failure categories safe for caller policy decisions."""
30
+
31
+ AUTHENTICATION = "authentication"
32
+ AUTHORIZATION = "authorization"
33
+ NOT_FOUND = "not_found"
34
+ REJECTED = "rejected"
35
+ UNAVAILABLE = "unavailable"
36
+ TRANSPORT = "transport"
37
+ PROTOCOL = "protocol"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ProviderFailure:
42
+ """Provider failure metadata with no request, response body, or headers."""
43
+
44
+ kind: ProviderFailureKind
45
+ status_code: int | None = None
46
+ request_id: str | None = None
47
+ retry_after_seconds: int | None = None
48
+
49
+
50
+ class ProviderFailureError(BatchworkError):
51
+ """A compatibility-preserving ``BatchworkError`` with safe failure metadata."""
52
+
53
+ def __init__(self, message: str, failure: ProviderFailure) -> None:
54
+ super().__init__(message)
55
+ self.failure = failure
56
+
57
+
58
+ def http_failure(response: httpx.Response) -> ProviderFailure:
59
+ """Classify a non-success response without retaining it."""
60
+ status_code = response.status_code
61
+ if status_code == 401:
62
+ kind = ProviderFailureKind.AUTHENTICATION
63
+ elif status_code == 403:
64
+ kind = ProviderFailureKind.AUTHORIZATION
65
+ elif status_code == 404:
66
+ kind = ProviderFailureKind.NOT_FOUND
67
+ elif status_code in _RETRYABLE_CLIENT_STATUSES or status_code >= 500:
68
+ kind = ProviderFailureKind.UNAVAILABLE
69
+ else:
70
+ kind = ProviderFailureKind.REJECTED
71
+ return ProviderFailure(
72
+ kind=kind,
73
+ status_code=status_code,
74
+ request_id=_request_id(response),
75
+ retry_after_seconds=_retry_after_seconds(response),
76
+ )
77
+
78
+
79
+ def transport_failure() -> ProviderFailure:
80
+ return ProviderFailure(kind=ProviderFailureKind.TRANSPORT)
81
+
82
+
83
+ def protocol_failure(response: httpx.Response) -> ProviderFailure:
84
+ return ProviderFailure(
85
+ kind=ProviderFailureKind.PROTOCOL,
86
+ status_code=response.status_code,
87
+ request_id=_request_id(response),
88
+ )
89
+
90
+
91
+ def _request_id(response: httpx.Response) -> str | None:
92
+ for name in _REQUEST_ID_HEADERS:
93
+ value = response.headers.get(name)
94
+ if value is not None and _safe_request_id(value):
95
+ return value
96
+ return None
97
+
98
+
99
+ def _safe_request_id(value: str) -> bool:
100
+ return 0 < len(value) <= _MAX_REQUEST_ID_LENGTH and value.isascii() and value.isprintable()
101
+
102
+
103
+ def _retry_after_seconds(response: httpx.Response) -> int | None:
104
+ value = response.headers.get("retry-after")
105
+ if value is None:
106
+ return None
107
+ try:
108
+ seconds = int(value)
109
+ except ValueError:
110
+ try:
111
+ retry_at = parsedate_to_datetime(value)
112
+ except (TypeError, ValueError):
113
+ return None
114
+ if retry_at.tzinfo is None:
115
+ retry_at = retry_at.replace(tzinfo=UTC)
116
+ seconds = max(0, int((retry_at - datetime.now(UTC)).total_seconds()))
117
+ if seconds < 0:
118
+ return None
119
+ return min(seconds, _MAX_RETRY_AFTER_SECONDS)