batchwork-ai 0.1.1__tar.gz → 0.2.0__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.
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/PKG-INFO +42 -2
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/README.md +38 -1
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/pyproject.toml +8 -1
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/__init__.py +7 -1
- batchwork_ai-0.2.0/src/batchwork/_bounded_json.py +27 -0
- batchwork_ai-0.2.0/src/batchwork/_limits.py +18 -0
- batchwork_ai-0.2.0/src/batchwork/_provider_failure.py +119 -0
- batchwork_ai-0.2.0/src/batchwork/_text_validation.py +423 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/body.py +132 -30
- batchwork_ai-0.2.0/src/batchwork/cli/__init__.py +62 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_commands.py +1966 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_config.py +351 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_contract.py +833 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_failures.py +350 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_human.py +363 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_input.py +630 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_lifecycle.py +971 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_materialize.py +661 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_output.py +74 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_registry.py +796 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_state.py +24 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_submit_text.py +940 -0
- batchwork_ai-0.2.0/src/batchwork/cli/_volume.py +37 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/client.py +237 -43
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/errors.py +20 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/job.py +23 -2
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/media.py +122 -21
- batchwork_ai-0.2.0/src/batchwork/providers/_image.py +282 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/adapter.py +2 -1
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/anthropic.py +11 -3
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/google.py +12 -3
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/mistral.py +11 -3
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/openai_compatible.py +65 -33
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/shared.py +191 -74
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/xai.py +3 -2
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/types.py +15 -5
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/LICENSE +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/_anthropic_serialization.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/_compatible_media.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/_google_schema.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/_google_serialization.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/_network.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/_serialization.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/_typing.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/__init__.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/groq.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/ids.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/openai.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/providers/together.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/py.typed +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/server/__init__.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/server/models.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/server/poller.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/server/signing.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/server/transport.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/stores/__init__.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/stores/base.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/src/batchwork/stores/memory.py +0 -0
- {batchwork_ai-0.1.1 → batchwork_ai-0.2.0}/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.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
-
|
|
35
|
+

|
|
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
|
-
|
|
3
|
+

|
|
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.
|
|
7
|
+
version = "0.2.0"
|
|
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__ = "
|
|
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)
|