justrouting 0.1.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.
- justrouting/__init__.py +129 -0
- justrouting/client.py +139 -0
- justrouting/consts.py +30 -0
- justrouting/error.py +269 -0
- justrouting/geo.py +203 -0
- justrouting/health.py +48 -0
- justrouting/matrix.py +185 -0
- justrouting/optimization.py +558 -0
- justrouting/routes.py +348 -0
- justrouting/transport.py +258 -0
- justrouting-0.1.0.dist-info/METADATA +281 -0
- justrouting-0.1.0.dist-info/RECORD +15 -0
- justrouting-0.1.0.dist-info/WHEEL +5 -0
- justrouting-0.1.0.dist-info/licenses/LICENSE +21 -0
- justrouting-0.1.0.dist-info/top_level.txt +1 -0
justrouting/__init__.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""JustRouting Python client.
|
|
2
|
+
|
|
3
|
+
Official Python client for the JustRouting API — routing, distance
|
|
4
|
+
matrices, and vehicle routing optimization across Southeast Asia.
|
|
5
|
+
|
|
6
|
+
client = justrouting.Client("YOUR_API_KEY")
|
|
7
|
+
|
|
8
|
+
route = client.routes.get(justrouting.RouteRequest(
|
|
9
|
+
origin=[103.8198, 1.3521],
|
|
10
|
+
destination=[103.9915, 1.3644],
|
|
11
|
+
))
|
|
12
|
+
print(f"Distance: {route.distance / 1000:.1f} km")
|
|
13
|
+
|
|
14
|
+
Coordinates are always [longitude, latitude], the order used by GeoJSON,
|
|
15
|
+
OSRM and VROOM. Failed calls raise a subclass of [justrouting.Error] —
|
|
16
|
+
see [justrouting.NoRouteError] and friends.
|
|
17
|
+
|
|
18
|
+
The package has no dependencies outside the standard library.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .client import Client
|
|
22
|
+
from .consts import DEFAULT_BASE_URL, DEFAULT_PROFILE, VERSION
|
|
23
|
+
from .error import (
|
|
24
|
+
CrossCountryError,
|
|
25
|
+
DecodeError,
|
|
26
|
+
Error,
|
|
27
|
+
InvalidCoordinatesError,
|
|
28
|
+
InvalidRequestError,
|
|
29
|
+
JustRoutingError,
|
|
30
|
+
NoRouteError,
|
|
31
|
+
PlanLimitExceededError,
|
|
32
|
+
QuotaExceededError,
|
|
33
|
+
RateLimitedError,
|
|
34
|
+
TransportError,
|
|
35
|
+
UnauthorizedError,
|
|
36
|
+
UpstreamUnavailableError,
|
|
37
|
+
)
|
|
38
|
+
from .geo import Geometry, LineString, Point, Waypoint
|
|
39
|
+
from .health import Health, HealthService
|
|
40
|
+
from .matrix import MatrixRequest, MatrixResponse, MatrixService
|
|
41
|
+
from .optimization import (
|
|
42
|
+
Job,
|
|
43
|
+
OptimizationOptions,
|
|
44
|
+
OptimizationRequest,
|
|
45
|
+
OptimizationService,
|
|
46
|
+
RouteStep,
|
|
47
|
+
Shipment,
|
|
48
|
+
ShipmentStep,
|
|
49
|
+
Solution,
|
|
50
|
+
Summary,
|
|
51
|
+
TimeWindow,
|
|
52
|
+
Unassigned,
|
|
53
|
+
Vehicle,
|
|
54
|
+
VehicleRoute,
|
|
55
|
+
)
|
|
56
|
+
from .routes import (
|
|
57
|
+
Annotation,
|
|
58
|
+
Intersection,
|
|
59
|
+
Lane,
|
|
60
|
+
Leg,
|
|
61
|
+
Maneuver,
|
|
62
|
+
Route,
|
|
63
|
+
RouteRequest,
|
|
64
|
+
RouteResponse,
|
|
65
|
+
RoutesService,
|
|
66
|
+
Step,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__version__ = VERSION
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
# Client
|
|
73
|
+
"Client",
|
|
74
|
+
"VERSION",
|
|
75
|
+
"DEFAULT_BASE_URL",
|
|
76
|
+
"DEFAULT_PROFILE",
|
|
77
|
+
# Errors
|
|
78
|
+
"JustRoutingError",
|
|
79
|
+
"Error",
|
|
80
|
+
"UnauthorizedError",
|
|
81
|
+
"RateLimitedError",
|
|
82
|
+
"QuotaExceededError",
|
|
83
|
+
"PlanLimitExceededError",
|
|
84
|
+
"CrossCountryError",
|
|
85
|
+
"InvalidCoordinatesError",
|
|
86
|
+
"NoRouteError",
|
|
87
|
+
"UpstreamUnavailableError",
|
|
88
|
+
"InvalidRequestError",
|
|
89
|
+
"TransportError",
|
|
90
|
+
"DecodeError",
|
|
91
|
+
# Geo
|
|
92
|
+
"Point",
|
|
93
|
+
"Geometry",
|
|
94
|
+
"LineString",
|
|
95
|
+
"Waypoint",
|
|
96
|
+
# Services
|
|
97
|
+
"RoutesService",
|
|
98
|
+
"MatrixService",
|
|
99
|
+
"OptimizationService",
|
|
100
|
+
"HealthService",
|
|
101
|
+
# Routes
|
|
102
|
+
"RouteRequest",
|
|
103
|
+
"RouteResponse",
|
|
104
|
+
"Route",
|
|
105
|
+
"Leg",
|
|
106
|
+
"Step",
|
|
107
|
+
"Maneuver",
|
|
108
|
+
"Intersection",
|
|
109
|
+
"Lane",
|
|
110
|
+
"Annotation",
|
|
111
|
+
# Matrix
|
|
112
|
+
"MatrixRequest",
|
|
113
|
+
"MatrixResponse",
|
|
114
|
+
# Optimization
|
|
115
|
+
"OptimizationRequest",
|
|
116
|
+
"OptimizationOptions",
|
|
117
|
+
"TimeWindow",
|
|
118
|
+
"Vehicle",
|
|
119
|
+
"Job",
|
|
120
|
+
"ShipmentStep",
|
|
121
|
+
"Shipment",
|
|
122
|
+
"Solution",
|
|
123
|
+
"Summary",
|
|
124
|
+
"VehicleRoute",
|
|
125
|
+
"RouteStep",
|
|
126
|
+
"Unassigned",
|
|
127
|
+
# Health
|
|
128
|
+
"Health",
|
|
129
|
+
]
|
justrouting/client.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The JustRouting client.
|
|
2
|
+
|
|
3
|
+
A client is created with an API key and, optionally, keyword options::
|
|
4
|
+
|
|
5
|
+
client = justrouting.Client("YOUR_API_KEY")
|
|
6
|
+
|
|
7
|
+
route = client.routes.get(justrouting.RouteRequest(
|
|
8
|
+
origin=[103.8198, 1.3521],
|
|
9
|
+
destination=[103.9915, 1.3644],
|
|
10
|
+
))
|
|
11
|
+
print(f"Distance: {route.distance / 1000:.1f} km")
|
|
12
|
+
|
|
13
|
+
Coordinates are always [longitude, latitude], the order used by GeoJSON,
|
|
14
|
+
OSRM and VROOM. Every call accepts an optional ``timeout`` argument in
|
|
15
|
+
seconds that bounds the whole retry sequence, like a context deadline in
|
|
16
|
+
the Go client.
|
|
17
|
+
|
|
18
|
+
Failed calls raise a subclass of [justrouting.Error]. Use ``except`` clauses
|
|
19
|
+
for control flow and [justrouting.Error] when the status code or raw body is
|
|
20
|
+
needed::
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
route = client.routes.get(req)
|
|
24
|
+
except justrouting.RateLimitedError:
|
|
25
|
+
# back off and retry later
|
|
26
|
+
|
|
27
|
+
Unlike the Go client, invalid constructor options raise ``ValueError``
|
|
28
|
+
immediately — Python constructors can fail, so there is no need to defer
|
|
29
|
+
the error to the first request.
|
|
30
|
+
|
|
31
|
+
The package has no dependencies outside the standard library.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import urllib.parse
|
|
37
|
+
from typing import Callable, Optional
|
|
38
|
+
|
|
39
|
+
from .consts import DEFAULT_BASE_URL, DEFAULT_PROFILE, VERSION
|
|
40
|
+
from .health import HealthService
|
|
41
|
+
from .matrix import MatrixService
|
|
42
|
+
from .optimization import OptimizationService
|
|
43
|
+
from .routes import RoutesService
|
|
44
|
+
from .transport import Transport, default_backoff
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Client",
|
|
48
|
+
"VERSION",
|
|
49
|
+
"DEFAULT_BASE_URL",
|
|
50
|
+
"DEFAULT_PROFILE",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
_DEFAULT_MAX_RETRIES = 2
|
|
54
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validate_base_url(raw: str) -> str:
|
|
58
|
+
raw = raw.strip()
|
|
59
|
+
if not raw:
|
|
60
|
+
raise ValueError("justrouting: base_url must not be empty")
|
|
61
|
+
parts = urllib.parse.urlsplit(raw)
|
|
62
|
+
if not parts.scheme or not parts.netloc:
|
|
63
|
+
raise ValueError(f"justrouting: base_url {raw!r} is not an absolute URL")
|
|
64
|
+
return raw
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Client:
|
|
68
|
+
"""A JustRouting API client.
|
|
69
|
+
|
|
70
|
+
It is safe for concurrent use by multiple threads, and should be created
|
|
71
|
+
once and reused.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
api_key: The API key, sent as an ``Authorization: Bearer`` header on
|
|
75
|
+
every request that needs it. An empty key is allowed but only
|
|
76
|
+
[HealthService.get] will work; every other call fails with
|
|
77
|
+
[UnauthorizedError] before any request is sent.
|
|
78
|
+
base_url: The API endpoint, default [DEFAULT_BASE_URL]. Target a
|
|
79
|
+
local or staging server with ``base_url="http://localhost:8080"``.
|
|
80
|
+
Any path is kept as a prefix for every request.
|
|
81
|
+
user_agent: Override the User-Agent header. Identifying your
|
|
82
|
+
application helps when diagnosing traffic against the API.
|
|
83
|
+
max_retries: How many times a failed request is retried, on top of
|
|
84
|
+
the initial attempt. The default is 2. Pass 0 to disable
|
|
85
|
+
retries. Only rate-limit (429), server (5xx) and transport
|
|
86
|
+
errors are retried; other 4xx responses are returned immediately.
|
|
87
|
+
backoff: Callable(attempt: int) -> float returning the delay in
|
|
88
|
+
seconds before retry ``attempt`` (1 for the first retry, 2 for
|
|
89
|
+
the second, and so on). The default is exponential backoff with
|
|
90
|
+
jitter, starting at 500ms and capped at 8s. A Retry-After
|
|
91
|
+
response header, when present, takes precedence.
|
|
92
|
+
timeout: Seconds allowed for a single HTTP attempt, like
|
|
93
|
+
``http.Client.Timeout`` in Go. The default is 30. Use the
|
|
94
|
+
per-call ``timeout`` argument instead to bound the whole retry
|
|
95
|
+
sequence.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
api_key: str,
|
|
101
|
+
*,
|
|
102
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
103
|
+
user_agent: Optional[str] = None,
|
|
104
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
105
|
+
backoff: Optional[Callable[[int], float]] = None,
|
|
106
|
+
timeout: Optional[float] = _DEFAULT_TIMEOUT,
|
|
107
|
+
) -> None:
|
|
108
|
+
if max_retries < 0:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"justrouting: max_retries must not be negative, got {max_retries}"
|
|
111
|
+
)
|
|
112
|
+
if user_agent is not None and not user_agent.strip():
|
|
113
|
+
raise ValueError("justrouting: user_agent must not be empty")
|
|
114
|
+
if backoff is not None and not callable(backoff):
|
|
115
|
+
raise ValueError("justrouting: backoff must be a callable")
|
|
116
|
+
if timeout is not None and timeout <= 0:
|
|
117
|
+
raise ValueError("justrouting: timeout must be positive or None")
|
|
118
|
+
|
|
119
|
+
self._transport = Transport(
|
|
120
|
+
api_key=api_key.strip(),
|
|
121
|
+
base_url=_validate_base_url(base_url),
|
|
122
|
+
user_agent=user_agent or f"justrouting-py/{VERSION}",
|
|
123
|
+
max_retries=max_retries,
|
|
124
|
+
backoff=backoff or default_backoff,
|
|
125
|
+
timeout=timeout,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Routes computes routes between two or more coordinates.
|
|
129
|
+
self.routes = RoutesService(self._transport)
|
|
130
|
+
# Matrix computes duration and distance matrices.
|
|
131
|
+
self.matrix = MatrixService(self._transport)
|
|
132
|
+
# Optimization solves vehicle routing problems.
|
|
133
|
+
self.optimization = OptimizationService(self._transport)
|
|
134
|
+
# Health reports API and upstream availability.
|
|
135
|
+
self.health = HealthService(self._transport)
|
|
136
|
+
|
|
137
|
+
def base_url(self) -> str:
|
|
138
|
+
"""The API endpoint the client sends requests to."""
|
|
139
|
+
return self._transport.base_url
|
justrouting/consts.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Package constants, in a leaf module so services and the client can both
|
|
2
|
+
import them without a cycle."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import urllib.parse
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"VERSION",
|
|
10
|
+
"DEFAULT_BASE_URL",
|
|
11
|
+
"DEFAULT_PROFILE",
|
|
12
|
+
"profile_or_default",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
# VERSION is the client version, reported in the User-Agent header.
|
|
16
|
+
VERSION = "0.1.0"
|
|
17
|
+
|
|
18
|
+
# DEFAULT_BASE_URL is the hosted JustRouting API endpoint.
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.justrouting.tech"
|
|
20
|
+
|
|
21
|
+
# DEFAULT_PROFILE is the routing profile used when a request leaves
|
|
22
|
+
# Profile empty.
|
|
23
|
+
DEFAULT_PROFILE = "driving"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def profile_or_default(profile: str) -> str:
|
|
27
|
+
"""The profile to use in a request path, with URL path escaping."""
|
|
28
|
+
if not profile:
|
|
29
|
+
return DEFAULT_PROFILE
|
|
30
|
+
return urllib.parse.quote(profile, safe="")
|
justrouting/error.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Errors raised by the JustRouting client.
|
|
2
|
+
|
|
3
|
+
The Go client classifies failures with sentinel errors and ``errors.Is``.
|
|
4
|
+
Python expresses the same classification as an exception hierarchy, so
|
|
5
|
+
callers use ``except`` clauses instead of string matching::
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
route = client.routes.get(req)
|
|
9
|
+
except justrouting.QuotaExceededError:
|
|
10
|
+
# daily allowance used up — retrying will not help
|
|
11
|
+
except justrouting.RateLimitedError:
|
|
12
|
+
# throttled; the client already retried
|
|
13
|
+
except justrouting.CrossCountryError:
|
|
14
|
+
# coordinates span more than one country
|
|
15
|
+
except justrouting.NoRouteError:
|
|
16
|
+
# no road connects these points
|
|
17
|
+
|
|
18
|
+
``QuotaExceededError`` is a subclass of ``RateLimitedError``, mirroring how
|
|
19
|
+
the Go client reports a daily-quota response as both
|
|
20
|
+
``ErrQuotaExceeded`` and ``ErrRateLimited``.
|
|
21
|
+
|
|
22
|
+
Every API failure — non-2xx responses and in-body engine failures alike —
|
|
23
|
+
raises an ``Error`` (or one of its subclasses). Catch it when the status
|
|
24
|
+
code, engine code, or raw body is needed::
|
|
25
|
+
|
|
26
|
+
except justrouting.Error as e:
|
|
27
|
+
log("HTTP %d: %s\n%s", e.status_code, e.message, e.body)
|
|
28
|
+
|
|
29
|
+
``Error`` carries the same fields as the Go ``*Error`` struct. Failures
|
|
30
|
+
that never produced an API response (local validation, transport, decoding)
|
|
31
|
+
raise ``InvalidRequestError``, ``TransportError`` or ``DecodeError``
|
|
32
|
+
instead, which are *not* subclasses of ``Error``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import http.client
|
|
36
|
+
import json
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"JustRoutingError",
|
|
40
|
+
"Error",
|
|
41
|
+
"UnauthorizedError",
|
|
42
|
+
"RateLimitedError",
|
|
43
|
+
"QuotaExceededError",
|
|
44
|
+
"PlanLimitExceededError",
|
|
45
|
+
"CrossCountryError",
|
|
46
|
+
"InvalidCoordinatesError",
|
|
47
|
+
"NoRouteError",
|
|
48
|
+
"UpstreamUnavailableError",
|
|
49
|
+
"InvalidRequestError",
|
|
50
|
+
"TransportError",
|
|
51
|
+
"DecodeError",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# maxErrorBodyBytes bounds how much of a response body is retained on an
|
|
55
|
+
# Error, so that a large or misbehaving upstream cannot balloon a log line.
|
|
56
|
+
_MAX_ERROR_BODY_BYTES = 8 << 10
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class JustRoutingError(Exception):
|
|
60
|
+
"""Base class of every error raised by this package."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Error(JustRoutingError):
|
|
64
|
+
"""A failed API call.
|
|
65
|
+
|
|
66
|
+
It is raised whenever the API responds with a non-2xx status, and also
|
|
67
|
+
when a 2xx response carries an in-body failure code — the routing and
|
|
68
|
+
optimization engines can both report errors alongside HTTP 200.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
status_code: The HTTP status of the response. Zero for errors that
|
|
72
|
+
were produced without an HTTP response, such as local validation.
|
|
73
|
+
message: The human-readable reason reported by the API.
|
|
74
|
+
osrm_code: The routing engine's status code, such as ``"NoRoute"``
|
|
75
|
+
or ``"InvalidValue"``. Empty when the error did not come from
|
|
76
|
+
the routing engine.
|
|
77
|
+
vroom_code: The optimization engine's non-zero status code. Zero
|
|
78
|
+
when the error did not come from the optimization engine.
|
|
79
|
+
body: The raw response body, truncated to a sane limit. Useful for
|
|
80
|
+
logging responses this package does not model.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
message: str = "",
|
|
86
|
+
*,
|
|
87
|
+
status_code: int = 0,
|
|
88
|
+
osrm_code: str = "",
|
|
89
|
+
vroom_code: int = 0,
|
|
90
|
+
body: bytes = b"",
|
|
91
|
+
) -> None:
|
|
92
|
+
super().__init__(message)
|
|
93
|
+
self.message = message
|
|
94
|
+
self.status_code = status_code
|
|
95
|
+
self.osrm_code = osrm_code
|
|
96
|
+
self.vroom_code = vroom_code
|
|
97
|
+
self.body = body
|
|
98
|
+
|
|
99
|
+
def __str__(self) -> str:
|
|
100
|
+
# A zero status means the error was produced without an HTTP
|
|
101
|
+
# response; the message then already carries the full text.
|
|
102
|
+
if self.status_code == 0:
|
|
103
|
+
return self.message
|
|
104
|
+
parts = ["justrouting: "]
|
|
105
|
+
parts.append(f"HTTP {self.status_code}")
|
|
106
|
+
if self.osrm_code:
|
|
107
|
+
parts.append(f" ({self.osrm_code})")
|
|
108
|
+
elif self.vroom_code:
|
|
109
|
+
parts.append(f" (optimization code {self.vroom_code})")
|
|
110
|
+
if self.message:
|
|
111
|
+
parts.append(f": {self.message}")
|
|
112
|
+
return "".join(parts)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class UnauthorizedError(Error):
|
|
116
|
+
"""The API key is missing, invalid or revoked."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RateLimitedError(Error):
|
|
120
|
+
"""The request was throttled (per-second limit or daily quota)."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class QuotaExceededError(RateLimitedError):
|
|
124
|
+
"""The plan's daily request quota is used up.
|
|
125
|
+
|
|
126
|
+
Retrying before the quota resets will not help. Subclasses
|
|
127
|
+
``RateLimitedError`` because quota exhaustion is a form of throttling.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class PlanLimitExceededError(Error):
|
|
132
|
+
"""The request is larger than the plan allows — too many matrix
|
|
133
|
+
coordinates, jobs, or vehicles."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CrossCountryError(Error):
|
|
137
|
+
"""The coordinates span more than one country.
|
|
138
|
+
|
|
139
|
+
Every coordinate in a request must fall within a single country.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class InvalidCoordinatesError(Error):
|
|
144
|
+
"""A coordinate was malformed, out of range, or could not be parsed."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class NoRouteError(Error):
|
|
148
|
+
"""No route exists between the given coordinates."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class UpstreamUnavailableError(Error):
|
|
152
|
+
"""The routing engine behind the API could not be reached.
|
|
153
|
+
|
|
154
|
+
This is usually transient.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class InvalidRequestError(Error):
|
|
159
|
+
"""The request was rejected before being sent because required fields
|
|
160
|
+
were missing or inconsistent.
|
|
161
|
+
|
|
162
|
+
These errors carry no HTTP status; the message holds the reason.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TransportError(JustRoutingError):
|
|
167
|
+
"""A network-level failure while contacting the API.
|
|
168
|
+
|
|
169
|
+
The original exception is available as ``__cause__``. Transport errors
|
|
170
|
+
are transient and are retried like 429 and 5xx responses.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class DecodeError(JustRoutingError):
|
|
175
|
+
"""A successful response could not be decoded.
|
|
176
|
+
|
|
177
|
+
The original exception is available as ``__cause__``.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _truncate(body: bytes, max_bytes: int) -> bytes:
|
|
182
|
+
if len(body) <= max_bytes:
|
|
183
|
+
return body
|
|
184
|
+
return body[:max_bytes]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def osrm_status_error(code: str, message: str) -> "Error | None":
|
|
188
|
+
"""Convert a routing engine status code into an Error, or None when the
|
|
189
|
+
response was successful."""
|
|
190
|
+
if not code or code.lower() == "ok":
|
|
191
|
+
return None
|
|
192
|
+
if not message:
|
|
193
|
+
message = f"routing engine returned {code}"
|
|
194
|
+
cls = _classify_error(http.client.OK, message, code)
|
|
195
|
+
return cls(status_code=http.client.OK, osrm_code=code, message=message)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def error_from_response(status_code: int, body: bytes) -> "Error":
|
|
199
|
+
"""Build the right Error subclass for an HTTP error response.
|
|
200
|
+
|
|
201
|
+
Bodies are classified by shape rather than Content-Type: some API
|
|
202
|
+
handlers emit a JSON body while leaving the header set to text/plain.
|
|
203
|
+
"""
|
|
204
|
+
message, osrm_code, vroom_code = "", "", 0
|
|
205
|
+
|
|
206
|
+
payload = None
|
|
207
|
+
try:
|
|
208
|
+
stripped = body.strip()
|
|
209
|
+
if stripped:
|
|
210
|
+
payload = json.loads(stripped)
|
|
211
|
+
except ValueError:
|
|
212
|
+
payload = None
|
|
213
|
+
|
|
214
|
+
if isinstance(payload, dict):
|
|
215
|
+
# {"error": ...} gateway errors
|
|
216
|
+
# {"code": "NoRoute", "message": ...} routing engine (string code)
|
|
217
|
+
# {"code": 3, "error": ...} optimization engine (int code)
|
|
218
|
+
message = payload.get("error") or payload.get("message") or ""
|
|
219
|
+
code = payload.get("code")
|
|
220
|
+
if isinstance(code, str) and code.lower() != "ok":
|
|
221
|
+
osrm_code = code
|
|
222
|
+
elif isinstance(code, int):
|
|
223
|
+
vroom_code = code
|
|
224
|
+
|
|
225
|
+
if not message:
|
|
226
|
+
message = _fallback_message(status_code, body)
|
|
227
|
+
|
|
228
|
+
cls = _classify_error(status_code, message, osrm_code)
|
|
229
|
+
return cls(
|
|
230
|
+
status_code=status_code,
|
|
231
|
+
message=message,
|
|
232
|
+
osrm_code=osrm_code,
|
|
233
|
+
vroom_code=vroom_code,
|
|
234
|
+
body=_truncate(body, _MAX_ERROR_BODY_BYTES),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _classify_error(status_code: int, message: str, osrm_code: str):
|
|
239
|
+
"""Pick the exception class for a failure, in the same priority order
|
|
240
|
+
the Go client's Error.Is uses."""
|
|
241
|
+
msg = message.lower()
|
|
242
|
+
if status_code == http.client.UNAUTHORIZED:
|
|
243
|
+
return UnauthorizedError
|
|
244
|
+
if status_code == http.client.TOO_MANY_REQUESTS:
|
|
245
|
+
if "daily quota" in msg:
|
|
246
|
+
return QuotaExceededError
|
|
247
|
+
return RateLimitedError
|
|
248
|
+
if status_code == http.client.BAD_GATEWAY or "upstream unavailable" in msg:
|
|
249
|
+
return UpstreamUnavailableError
|
|
250
|
+
if "cross-country" in msg:
|
|
251
|
+
return CrossCountryError
|
|
252
|
+
if "exceeds plan limit" in msg or "too many jobs" in msg or "too many vehicles" in msg:
|
|
253
|
+
return PlanLimitExceededError
|
|
254
|
+
if osrm_code.lower() == "noroute":
|
|
255
|
+
return NoRouteError
|
|
256
|
+
if "cannot parse coordinates" in msg or "invalid coordinates" in msg:
|
|
257
|
+
return InvalidCoordinatesError
|
|
258
|
+
return Error
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _fallback_message(status_code: int, body: bytes) -> str:
|
|
262
|
+
"""Produce a message for responses with no recognisable error field,
|
|
263
|
+
such as an HTML error page from an intermediary proxy."""
|
|
264
|
+
s = body.decode("utf-8", errors="replace").strip()
|
|
265
|
+
if s and not s.startswith("{") and not s.startswith("<") and len(s) <= 200:
|
|
266
|
+
return s
|
|
267
|
+
if text := http.client.responses.get(status_code, ""):
|
|
268
|
+
return text.lower()
|
|
269
|
+
return "unexpected response"
|