python-fotoware-client 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,13 @@
1
+ """Constants for FotoWare API client."""
2
+
3
+ # FotoWare API Accept headers for different content types
4
+ ACCEPT_JSON = {"Accept": "application/json"}
5
+ ACCEPT_FULL_DESCRIPTOR = {"Accept": "application/vnd.fotoware.full-api-descriptor+json"}
6
+ ACCEPT_COLLECTIONLIST = {"Accept": "application/vnd.fotoware.collectionlist+json"}
7
+ ACCEPT_COLLECTIONINFO = {"Accept": "application/vnd.fotoware.collectioninfo+json"}
8
+ ACCEPT_ASSETLIST = {"Accept": "application/vnd.fotoware.assetlist+json"}
9
+ ACCEPT_ASSET = {"Accept": "application/vnd.fotoware.asset+json"}
10
+ ACCEPT_DOWNLOAD = {"Accept": "application/vnd.fotoware.download+json"}
11
+
12
+ # Request timeouts (in seconds)
13
+ DEFAULT_TIMEOUT = 30
@@ -0,0 +1,59 @@
1
+ """Where a FotoWare client's credentials come from, when the caller does not pass them.
2
+
3
+ The environment first: FOTOWARE_TENANT_URL, FOTOWARE_CLIENT_ID and
4
+ FOTOWARE_CLIENT_SECRET. Where it lacks them, the credentials file,
5
+ ``$XDG_CONFIG_HOME/fotoware/credentials`` (``~/.config/fotoware/credentials``),
6
+ one ``NAME=value`` per line. The file is refused when others may read it.
7
+
8
+ Never from a command line: a secret there lands in the process list and the
9
+ shell history.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+
17
+ from fotoware_client.exceptions import ConfigurationError
18
+
19
+ ENV_TENANT_URL = "FOTOWARE_TENANT_URL"
20
+ ENV_CLIENT_ID = "FOTOWARE_CLIENT_ID"
21
+ ENV_CLIENT_SECRET = "FOTOWARE_CLIENT_SECRET"
22
+ _NAMES = (ENV_TENANT_URL, ENV_CLIENT_ID, ENV_CLIENT_SECRET)
23
+
24
+
25
+ def credentials_file() -> Path:
26
+ """The credentials file's place."""
27
+ config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
28
+ return Path(config_home) / "fotoware" / "credentials"
29
+
30
+
31
+ def load_credentials() -> tuple[str, str, str]:
32
+ """Tenant URL, client id and client secret.
33
+
34
+ Raises:
35
+ ConfigurationError: If neither the environment nor the file has all
36
+ three, or the file may be read by others
37
+ """
38
+ values = {name: os.environ.get(name, "") for name in _NAMES}
39
+ if not all(values.values()):
40
+ values = _read_file(credentials_file())
41
+ return values[ENV_TENANT_URL], values[ENV_CLIENT_ID], values[ENV_CLIENT_SECRET]
42
+
43
+
44
+ def _read_file(path: Path) -> dict[str, str]:
45
+ if not path.is_file():
46
+ raise ConfigurationError(
47
+ f"No FotoWare credentials: set {', '.join(_NAMES)} or create {path}"
48
+ )
49
+ if path.stat().st_mode & 0o077:
50
+ raise ConfigurationError(f"{path} may be read by others; run: chmod 600 {path}")
51
+ values: dict[str, str] = {}
52
+ for line in path.read_text(encoding="utf-8").splitlines():
53
+ key, sep, value = line.strip().partition("=")
54
+ if sep and not key.startswith("#"):
55
+ values[key.strip()] = value.strip().strip('"')
56
+ missing = [name for name in _NAMES if not values.get(name)]
57
+ if missing:
58
+ raise ConfigurationError(f"{path} lacks {', '.join(missing)}")
59
+ return values
@@ -0,0 +1,110 @@
1
+ """Exception hierarchy for FotoWare client.
2
+
3
+ All exceptions inherit from FotoWareError for easy catching.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+
11
+ class FotoWareError(Exception):
12
+ """Base exception for all FotoWare client errors."""
13
+
14
+ def __init__(self, message: str, *, context: dict[str, Any] | None = None) -> None:
15
+ """Initialize error with message and optional context.
16
+
17
+ Args:
18
+ message: Human-readable error message
19
+ context: Optional dict with additional error context
20
+ """
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.context = context or {}
24
+
25
+ def __str__(self) -> str:
26
+ if self.context:
27
+ ctx_str = ", ".join(f"{k}={v}" for k, v in self.context.items())
28
+ return f"{self.message} ({ctx_str})"
29
+ return self.message
30
+
31
+
32
+ class AuthenticationError(FotoWareError):
33
+ """OAuth authentication failed."""
34
+
35
+ pass
36
+
37
+
38
+ class RateLimitError(FotoWareError):
39
+ """API rate limit exceeded (HTTP 429)."""
40
+
41
+ pass
42
+
43
+
44
+ class NotFoundError(FotoWareError):
45
+ """Resource not found (HTTP 404)."""
46
+
47
+ pass
48
+
49
+
50
+ class APIError(FotoWareError):
51
+ """HTTP error from FotoWare API."""
52
+
53
+ def __init__(
54
+ self,
55
+ message: str,
56
+ *,
57
+ status_code: int | None = None,
58
+ response_text: str | None = None,
59
+ context: dict[str, Any] | None = None,
60
+ ) -> None:
61
+ """Initialize API error with HTTP details.
62
+
63
+ Args:
64
+ message: Human-readable error message
65
+ status_code: HTTP status code
66
+ response_text: Response body text
67
+ context: Optional dict with additional error context
68
+ """
69
+ ctx = context or {}
70
+ if status_code:
71
+ ctx["status_code"] = status_code
72
+ if response_text:
73
+ ctx["response_text"] = response_text[:200] # Truncate to 200 chars
74
+ super().__init__(message, context=ctx)
75
+ self.status_code = status_code
76
+ self.response_text = response_text
77
+
78
+
79
+ class ConfigurationError(FotoWareError):
80
+ """Invalid client configuration."""
81
+
82
+ pass
83
+
84
+
85
+ class RetryExhaustedError(FotoWareError):
86
+ """All retry attempts failed."""
87
+
88
+ def __init__(
89
+ self,
90
+ message: str,
91
+ *,
92
+ attempts: int,
93
+ last_error: Exception | None = None,
94
+ context: dict[str, Any] | None = None,
95
+ ) -> None:
96
+ """Initialize retry exhausted error.
97
+
98
+ Args:
99
+ message: Human-readable error message
100
+ attempts: Number of attempts made
101
+ last_error: The last exception that occurred
102
+ context: Optional dict with additional error context
103
+ """
104
+ ctx = context or {}
105
+ ctx["attempts"] = attempts
106
+ if last_error:
107
+ ctx["last_error"] = str(last_error)
108
+ super().__init__(message, context=ctx)
109
+ self.attempts = attempts
110
+ self.last_error = last_error
@@ -0,0 +1,133 @@
1
+ """What a FotoWare search may ask, and what it cost.
2
+
3
+ Measured on 2026-10-23 against a FotoWeb 8.1.1223.0 tenant. Check these again
4
+ when the server version changes; an installation that differs passes its own
5
+ values in fotoware_client.ServerSettings.
6
+
7
+ - **The answers came in pages of 25**, and ``pageSize`` was ignored. FotoWare
8
+ guarantees no page size ("do not assume the number of elements per page"),
9
+ so the client counts nothing and follows ``paging.next`` until it ends.
10
+ - **The cost is per answer, not per asset.** An answer with hits took about
11
+ 0.4 s one after another and about 1 s at 15 in parallel, whether it held 1
12
+ asset or 25. An empty answer took 130-220 ms.
13
+ - **The search has a size limit, and over it FotoWare answers wrongly.** The
14
+ limit applies to FotoWare's internal translation of the query, about 2,000
15
+ characters ("search query string is limited to 2000 characters" in its
16
+ documentation). Over it the answer is HTTP 500 or, worse, HTTP 200 with an
17
+ empty list, which looks exactly like "no hits". Nothing in the answer tells
18
+ them apart, so the only remedy is not to send such a query:
19
+ :func:`estimate_query_size` estimates it and the client refuses a query over
20
+ :data:`QUERY_SIZE_BUDGET` with :class:`QueryTooLargeError`.
21
+
22
+ The estimate: every term costs the length of its value plus
23
+ :data:`TERM_OVERHEAD`. With 13-digit EANs 40 terms worked and 42 did not, with
24
+ 2-digit values 52 and 56; both edges sit at about 2,000 by this count. Other
25
+ query forms (wildcards, ranges, full text) were not measured.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ import threading
32
+ from dataclasses import dataclass, field
33
+
34
+ from fotoware_client.exceptions import FotoWareError
35
+
36
+ TERM_OVERHEAD = 33
37
+ """What a term adds to the translated query besides its value (measured default)."""
38
+
39
+ QUERY_SIZE_LIMIT = 2000
40
+ """Where FotoWare starts to answer wrongly, by :func:`estimate_query_size`."""
41
+
42
+ QUERY_SIZE_BUDGET = 1500
43
+ """The largest estimate the client sends, kept well below the limit."""
44
+
45
+ _OPERATORS = frozenset({"AND", "OR", "NOT"})
46
+ _TOKEN = re.compile(r"[^\s()]+")
47
+
48
+
49
+ def estimate_query_size(query: str, term_overhead: int = TERM_OVERHEAD) -> int:
50
+ """The size FotoWare's translation of *query* will have, estimated.
51
+
52
+ Each term counts the length of its value (after ``field:``, if it has one)
53
+ plus *term_overhead*. Operators, brackets and spaces count nothing.
54
+ """
55
+ size = 0
56
+ for token in _TOKEN.findall(query):
57
+ if token in _OPERATORS:
58
+ continue
59
+ _, _, value = token.rpartition(":")
60
+ size += len(value) + term_overhead
61
+ return size
62
+
63
+
64
+ class QueryTooLargeError(FotoWareError):
65
+ """A search the client refuses to send, because FotoWare would answer it wrongly."""
66
+
67
+ def __init__(self, *, estimated_size: int, budget: int) -> None:
68
+ super().__init__(
69
+ "Search query too large for FotoWare: an answer to it may be empty or a 500 "
70
+ "although assets match. Split it into smaller queries.",
71
+ context={"estimated_size": estimated_size, "budget": budget},
72
+ )
73
+ self.estimated_size = estimated_size
74
+ self.budget = budget
75
+
76
+
77
+ def check_query_size(
78
+ query: str, budget: int = QUERY_SIZE_BUDGET, term_overhead: int = TERM_OVERHEAD
79
+ ) -> None:
80
+ """Raise :class:`QueryTooLargeError` when *query* is estimated over *budget*."""
81
+ size = estimate_query_size(query, term_overhead)
82
+ if size > budget:
83
+ raise QueryTooLargeError(estimated_size=size, budget=budget)
84
+
85
+
86
+ @dataclass
87
+ class RequestCounters:
88
+ """What the client has asked of FotoWare so far. Thread-safe.
89
+
90
+ ``requests`` counts calls, ``retries`` the repeats on top of them, so the
91
+ HTTP requests sent are their sum. ``search_pages`` and ``empty_search_pages``
92
+ are the search answers among the requests; an empty one is cheap, a full or
93
+ partial one costs the same.
94
+ """
95
+
96
+ requests: int = 0
97
+ retries: int = 0
98
+ errors: int = 0
99
+ search_pages: int = 0
100
+ empty_search_pages: int = 0
101
+ retries_by_status: dict[str, int] = field(default_factory=dict)
102
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
103
+
104
+ def count_request(self) -> None:
105
+ with self._lock:
106
+ self.requests += 1
107
+
108
+ def count_error(self) -> None:
109
+ with self._lock:
110
+ self.errors += 1
111
+
112
+ def count_search_page(self, *, empty: bool) -> None:
113
+ with self._lock:
114
+ self.search_pages += 1
115
+ if empty:
116
+ self.empty_search_pages += 1
117
+
118
+ def count_retry(self, status: int | None, error: str | None, delay: float) -> None:
119
+ key = str(status) if status is not None else (error or "network")
120
+ with self._lock:
121
+ self.retries += 1
122
+ self.retries_by_status[key] = self.retries_by_status.get(key, 0) + 1
123
+
124
+ def snapshot(self) -> dict[str, object]:
125
+ with self._lock:
126
+ return {
127
+ "requests": self.requests,
128
+ "retries": self.retries,
129
+ "errors": self.errors,
130
+ "search_pages": self.search_pages,
131
+ "empty_search_pages": self.empty_search_pages,
132
+ "retries_by_status": dict(self.retries_by_status),
133
+ }
@@ -0,0 +1,190 @@
1
+ """Data models for FotoWare API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Archive:
11
+ """Represents a FotoWare archive.
12
+
13
+ Attributes:
14
+ href: Full URL to the archive
15
+ name: Human-readable archive name
16
+ data: Complete raw JSON data from API
17
+ """
18
+
19
+ href: str
20
+ name: str
21
+ data: dict[str, Any] = field(repr=False)
22
+
23
+ @classmethod
24
+ def from_json(cls, data: dict[str, Any]) -> Archive:
25
+ """Create Archive from API JSON response.
26
+
27
+ Args:
28
+ data: Raw JSON data from FotoWare API
29
+
30
+ Returns:
31
+ Archive instance
32
+
33
+ Raises:
34
+ KeyError: If required fields are missing
35
+ """
36
+ return cls(
37
+ href=data["href"],
38
+ name=data.get("name", data.get("displayName", "Unknown")),
39
+ data=data,
40
+ )
41
+
42
+
43
+ @dataclass
44
+ class Asset:
45
+ """Represents a FotoWare asset (image, document, etc.).
46
+
47
+ Attributes:
48
+ href: Full URL to the asset
49
+ filename: Asset filename
50
+ fields: Metadata fields (dict mapping field IDs to values)
51
+ mimetype: MIME type (e.g., 'image/jpeg', 'application/pdf')
52
+ data: Complete raw JSON data from API
53
+ """
54
+
55
+ href: str
56
+ filename: str
57
+ fields: dict[str, Any]
58
+ mimetype: str | None
59
+ data: dict[str, Any] = field(repr=False)
60
+
61
+ @classmethod
62
+ def from_json(cls, data: dict[str, Any]) -> Asset:
63
+ """Create Asset from API JSON response.
64
+
65
+ Args:
66
+ data: Raw JSON data from FotoWare API
67
+
68
+ Returns:
69
+ Asset instance
70
+
71
+ Raises:
72
+ KeyError: If required fields are missing
73
+ """
74
+ # Extract filename from metadata
75
+ filename = data.get("filename", "unknown")
76
+
77
+ # Extract and flatten metadata fields
78
+ fields: dict[str, Any] = {}
79
+ metadata = data.get("metadata", {})
80
+
81
+ # Handle both direct field values and nested structures
82
+ for field_id, field_data in metadata.items():
83
+ if isinstance(field_data, dict):
84
+ # Extract value from nested structure
85
+ value = field_data.get("value")
86
+ if value is not None:
87
+ fields[field_id] = value
88
+ else:
89
+ # Direct value
90
+ fields[field_id] = field_data
91
+
92
+ # Get MIME type
93
+ mimetype = data.get("mimetype") or data.get("attributes", {}).get("mimetype")
94
+
95
+ return cls(
96
+ href=data["href"],
97
+ filename=filename,
98
+ fields=fields,
99
+ mimetype=mimetype,
100
+ data=data,
101
+ )
102
+
103
+ def get_field(self, field_id: str, default: Any = None) -> Any:
104
+ """Get metadata field value by field ID.
105
+
106
+ Args:
107
+ field_id: FotoWare field ID (e.g., '631' for EAN)
108
+ default: Default value if field not found
109
+
110
+ Returns:
111
+ Field value or default
112
+ """
113
+ return self.fields.get(field_id, default)
114
+
115
+ def get_field_values(self, field_id: str) -> list[Any]:
116
+ """Get metadata field values as list (handles multi-value fields).
117
+
118
+ Args:
119
+ field_id: FotoWare field ID
120
+
121
+ Returns:
122
+ List of values (empty list if field not found)
123
+ """
124
+ value = self.fields.get(field_id)
125
+ if value is None:
126
+ return []
127
+ if isinstance(value, list):
128
+ return value
129
+ return [value]
130
+
131
+ @property
132
+ def doctype(self) -> str | None:
133
+ """Get asset doctype (e.g., 'image', 'document', 'graphic').
134
+
135
+ Returns:
136
+ Doctype string or None if not present
137
+ """
138
+ return self.data.get("doctype")
139
+
140
+
141
+ @dataclass
142
+ class SearchResult:
143
+ """Represents paginated search results from FotoWare.
144
+
145
+ Attributes:
146
+ assets: List of Asset instances
147
+ total_count: Total number of matching assets
148
+ next_href: URL to fetch next page (None if last page)
149
+ data: Complete raw JSON data from API
150
+ """
151
+
152
+ assets: list[Asset]
153
+ total_count: int
154
+ next_href: str | None
155
+ data: dict[str, Any] = field(repr=False)
156
+
157
+ @classmethod
158
+ def from_json(cls, data: dict[str, Any]) -> SearchResult:
159
+ """Create SearchResult from API JSON response.
160
+
161
+ Args:
162
+ data: Raw JSON data from FotoWare API
163
+
164
+ Returns:
165
+ SearchResult instance
166
+ """
167
+ # Parse assets from data
168
+ assets = []
169
+ for asset_data in data.get("data", []):
170
+ try:
171
+ assets.append(Asset.from_json(asset_data))
172
+ except KeyError, TypeError:
173
+ # Skip malformed assets
174
+ continue
175
+
176
+ # Get total count
177
+ paging = data.get("paging") or {}
178
+ total_count = paging.get("totalAssets", len(assets))
179
+
180
+ # Get next page link from paging object
181
+ next_href = paging.get("next") or None
182
+ if next_href == "": # Empty string means no next page
183
+ next_href = None
184
+
185
+ return cls(
186
+ assets=assets,
187
+ total_count=total_count,
188
+ next_href=next_href,
189
+ data=data,
190
+ )
File without changes
@@ -0,0 +1,166 @@
1
+ """Retry policy for FotoWare requests, run by urllib3.
2
+
3
+ :class:`RetryConfig` says what is retried and how long to wait; urllib3's
4
+ ``Retry``, mounted on the client's session, does the retrying. That is the
5
+ standard for ``requests``, and it honours ``Retry-After``. :class:`ObservedRetry`
6
+ adds a callback per retry, because a retry that succeeds is otherwise invisible
7
+ to the caller and to the cost counters.
8
+
9
+ **500 is not retried.** Measured on FotoWeb 8.1.1223.0 (2026-10-23): a 500
10
+ never appeared below the query size limit and always reappeared above it, so a
11
+ repeat only costs time. 429 and 502/503/504 are transient and are retried.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from types import TracebackType
20
+ from typing import Any, Self
21
+
22
+ from urllib3.connectionpool import ConnectionPool
23
+ from urllib3.exceptions import InvalidHeader
24
+ from urllib3.response import BaseHTTPResponse
25
+ from urllib3.util.retry import Retry
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RetryConfig:
32
+ """What is retried, and how long to wait in between.
33
+
34
+ Attributes:
35
+ max_attempts: Total attempts, the first one included. 1 means no retry.
36
+ base_delay: Wait before the first retry, in seconds; doubled for each
37
+ further one.
38
+ max_delay: Longest wait between two attempts, in seconds.
39
+ retry_on_status: Status codes retried. 500 deliberately not, see above.
40
+ max_retry_after: Longest ``Retry-After`` honoured, in seconds; a longer
41
+ one is cut to this.
42
+ """
43
+
44
+ max_attempts: int = 3
45
+ base_delay: float = 1.0
46
+ max_delay: float = 30.0
47
+ retry_on_status: frozenset[int] = field(default_factory=lambda: frozenset({429, 502, 503, 504}))
48
+ max_retry_after: float = 60.0
49
+
50
+ def __post_init__(self) -> None:
51
+ if self.max_attempts < 1:
52
+ raise ValueError(f"max_attempts must be >= 1, got {self.max_attempts}")
53
+ if self.base_delay < 0 or self.max_delay < 0 or self.max_retry_after < 0:
54
+ raise ValueError("delays must be >= 0")
55
+
56
+ def delay(self, retry_number: int) -> float:
57
+ """The wait before retry *retry_number* (1 for the first)."""
58
+ return float(min(self.max_delay, self.base_delay * 2 ** (retry_number - 1)))
59
+
60
+
61
+ STANDARD_RETRY = RetryConfig()
62
+ NO_RETRY = RetryConfig(max_attempts=1)
63
+
64
+ # Called once per retry, before the wait: the status that caused it (None for a
65
+ # network error), the error's type name (None for a status), and the wait in
66
+ # seconds.
67
+ RetryListener = Callable[[int | None, str | None, float], None]
68
+
69
+
70
+ class ObservedRetry(Retry):
71
+ """urllib3's ``Retry`` with a :class:`RetryConfig`'s waits and a callback.
72
+
73
+ A ``Retry-After`` the server sends takes precedence over the backoff, up to
74
+ ``config.max_retry_after``; one that cannot be read is logged and the
75
+ backoff used instead. Only ``config.retry_on_status`` is retried: urllib3
76
+ would also retry a 413, 429 or 503 whenever it carries a ``Retry-After``,
77
+ whatever the status list says; that is switched off.
78
+ """
79
+
80
+ RETRY_AFTER_STATUS_CODES = frozenset()
81
+
82
+ def __init__(
83
+ self,
84
+ *args: Any,
85
+ config: RetryConfig,
86
+ on_retry: RetryListener | None = None,
87
+ **kwargs: Any,
88
+ ) -> None:
89
+ super().__init__(*args, **kwargs)
90
+ self.config = config
91
+ self.on_retry = on_retry
92
+ # Fixed when the retry is decided, so the wait reported and the wait
93
+ # slept are the same number.
94
+ self.planned_delay: float | None = None
95
+
96
+ def new(self, **kw: Any) -> Self:
97
+ kw.setdefault("config", self.config)
98
+ kw.setdefault("on_retry", self.on_retry)
99
+ return super().new(**kw)
100
+
101
+ def get_retry_after(self, response: BaseHTTPResponse) -> float | None:
102
+ # urllib3 raises on a header it cannot parse, which would turn a 503
103
+ # into a network error. increment() logs it; here it is just absent.
104
+ try:
105
+ return super().get_retry_after(response)
106
+ except InvalidHeader:
107
+ return None
108
+
109
+ def get_backoff_time(self) -> float:
110
+ if self.planned_delay is not None:
111
+ return self.planned_delay
112
+ return self.config.delay(len(self.history))
113
+
114
+ def increment(
115
+ self,
116
+ method: str | None = None,
117
+ url: str | None = None,
118
+ response: BaseHTTPResponse | None = None,
119
+ error: Exception | None = None,
120
+ _pool: ConnectionPool | None = None,
121
+ _stacktrace: TracebackType | None = None,
122
+ ) -> Self:
123
+ # Raises MaxRetryError when the budget is spent; nothing to report then.
124
+ retried = super().increment(method, url, response, error, _pool, _stacktrace)
125
+ retry_after = retried.get_retry_after(response) if response is not None else None
126
+ if retry_after is None and response is not None and response.headers.get("Retry-After"):
127
+ logger.warning(
128
+ "Unreadable Retry-After %r, backing off instead",
129
+ response.headers.get("Retry-After"),
130
+ )
131
+ delay = retry_after if retry_after is not None else self.config.delay(len(retried.history))
132
+ # Also for a Retry-After of 0, which urllib3 treats as "not slept" and
133
+ # falls back to the backoff for.
134
+ retried.planned_delay = delay
135
+ if self.on_retry is not None:
136
+ self.on_retry(
137
+ response.status if response is not None else None,
138
+ type(error).__name__ if error is not None else None,
139
+ delay,
140
+ )
141
+ return retried
142
+
143
+
144
+ def urllib3_retry(config: RetryConfig, on_retry: RetryListener | None = None) -> ObservedRetry:
145
+ """The urllib3 policy a :class:`RetryConfig` describes.
146
+
147
+ Every method is retried: the client only reads, and its one POST asks for a
148
+ rendition, which FotoWare prepares again without harm. Once the retries are
149
+ spent the last response is returned, not raised, so the client maps its
150
+ status like any other.
151
+ """
152
+ retries = config.max_attempts - 1
153
+ return ObservedRetry(
154
+ total=retries,
155
+ connect=retries,
156
+ read=retries,
157
+ status=retries,
158
+ other=retries,
159
+ allowed_methods=None,
160
+ status_forcelist=config.retry_on_status,
161
+ respect_retry_after_header=True,
162
+ retry_after_max=config.max_retry_after,
163
+ raise_on_status=False,
164
+ config=config,
165
+ on_retry=on_retry,
166
+ )