mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
feat(tunnel): free finished tunnels at once and wait out 429s on --remote
A tunnel closed with code 1000 is freed without the 60s grace, a shared: true API key counts tunnels per X-Client-Id, and --remote waits out Retry-After and a full tunnel cap with a countdown; responses carry X-RateLimit-* headers.
This commit is contained in:
@@ -224,6 +224,19 @@ A limit comes from `serve.tiers.<name>.rate_limit` or from the API key's own
|
|||||||
[dashboard rate limits](dashboard.md#rate-limits) for the config and the counters, and
|
[dashboard rate limits](dashboard.md#rate-limits) for the config and the counters, and
|
||||||
[services configuration](../../reference/configuration/services.md) for the keys themselves.
|
[services configuration](../../reference/configuration/services.md) for the keys themselves.
|
||||||
|
|
||||||
|
Every response to an API key that has a limit carries these headers, the `429` too. A
|
||||||
|
WebSocket or event stream response sends its headers before the handler returns, so it has none.
|
||||||
|
The CORS headers expose these headers and `Retry-After` to a browser client.
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `X-RateLimit-Limit` | Requests per hour that the API key may make. |
|
||||||
|
| `X-RateLimit-Remaining` | Requests left in the current window. |
|
||||||
|
| `X-RateLimit-Reset` | Seconds until the window resets. |
|
||||||
|
|
||||||
|
A `--remote` client that gets a `429` with `Retry-After` shows a countdown, waits that time
|
||||||
|
(one hour at most), and then sends the request again. It stops after 3 waits for one request.
|
||||||
|
|
||||||
!!! warning "`429` is not the `RATE_LIMITED` error body"
|
!!! warning "`429` is not the `RATE_LIMITED` error body"
|
||||||
`RATE_LIMITED` in [Errors](errors.md) is a *service* rate limit that a handler raises, and it
|
`RATE_LIMITED` in [Errors](errors.md) is a *service* rate limit that a handler raises, and it
|
||||||
arrives in the standard envelope with an `error_code`. The `429` here comes from the
|
arrives in the standard envelope with an `error_code`. The `429` here comes from the
|
||||||
@@ -311,6 +324,7 @@ With `--caddy`, unshackle also starts `caddy run` using the `Caddyfile` located
|
|||||||
| Missing-header body | `{"status": 401, "message": "Secret Key is Empty."}` |
|
| Missing-header body | `{"status": 401, "message": "Secret Key is Empty."}` |
|
||||||
| Invalid-key body | `{"status": 401, "message": "Secret Key is Invalid."}` |
|
| Invalid-key body | `{"status": 401, "message": "Secret Key is Invalid."}` |
|
||||||
| Over-limit body | `{"status": 429, "message": "Rate limit exceeded."}`, plus a `Retry-After` header |
|
| Over-limit body | `{"status": 429, "message": "Rate limit exceeded."}`, plus a `Retry-After` header |
|
||||||
|
| Rate limit headers | `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` on each response to a limited API key |
|
||||||
| Rate limit config | `serve.tiers.<name>.rate_limit`, overridden by `serve.users.<key>.rate_limit` |
|
| Rate limit config | `serve.tiers.<name>.rate_limit`, overridden by `serve.users.<key>.rate_limit` |
|
||||||
| Disable auth | `--no-key` (all requests allowed) |
|
| Disable auth | `--no-key` (all requests allowed) |
|
||||||
| Config location | `serve.api_secret` and `serve.users` in `unshackle.yaml` |
|
| Config location | `serve.api_secret` and `serve.users` in `unshackle.yaml` |
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ The server mounts all these routes, even in `--remote-only` mode. Paths use the
|
|||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/session/create` | Build + authenticate a service instance; returns a session ID immediately |
|
| `POST` | `/api/session/create` | Build + authenticate a service instance; returns a session ID immediately |
|
||||||
| `GET` | `/api/tunnel` | WebSocket that relays the connections of a `tunnel:<id>` proxy to the client; `?id=` reconnects |
|
| `GET` | `/api/tunnel` | WebSocket that relays the connections of a `tunnel:<id>` proxy to the client; `?id=` reconnects, close code 1000 frees the tunnel at once |
|
||||||
| `GET` | `/api/tunnel/check?id=` | Exit IP address and lookup time through the caller's tunnel, for `unshackle tunnel-test` |
|
| `GET` | `/api/tunnel/check?id=` | Exit IP address and lookup time through the caller's tunnel, for `unshackle tunnel-test` |
|
||||||
| `GET` | `/api/session/{id}/titles` | Fetch and cache the title list |
|
| `GET` | `/api/session/{id}/titles` | Fetch and cache the title list |
|
||||||
| `POST` | `/api/session/{id}/tracks` | Resolve tracks, manifests, chapters, headers/cookies for a title |
|
| `POST` | `/api/session/{id}/tracks` | Resolve tracks, manifests, chapters, headers/cookies for a title |
|
||||||
|
|||||||
@@ -336,8 +336,10 @@ A service that uses a server account does not work with `--local-proxy`, because
|
|||||||
logs in with its own account. If the tunnel drops, the client connects again, and a new request
|
logs in with its own account. If the tunnel drops, the client connects again, and a new request
|
||||||
from the server waits up to 10 seconds for it. A request that was in progress when the tunnel
|
from the server waits up to 10 seconds for it. A request that was in progress when the tunnel
|
||||||
dropped fails. The server closes a tunnel when its client has not connected for 60 seconds, and
|
dropped fails. The server closes a tunnel when its client has not connected for 60 seconds, and
|
||||||
the client then stops with `The tunnel to the server closed`. An API key can have 4 tunnels
|
the client then stops with `The tunnel to the server closed`. A client that exits closes its
|
||||||
open at once.
|
tunnel at once. An API key can have 4 tunnels open at once (4 for each person on a key with
|
||||||
|
`shared: true`, see [services configuration](../reference/configuration/services.md)). A fifth run waits for a free
|
||||||
|
tunnel and tries again every 15 seconds, for one hour at most.
|
||||||
|
|
||||||
## Basic (static proxies)
|
## Basic (static proxies)
|
||||||
|
|
||||||
|
|||||||
@@ -240,10 +240,17 @@ server vault with this grant, are the only source, and a missing content key sto
|
|||||||
|
|
||||||
`admin` is a boolean that lets the API key run the maintenance endpoints (clear-cache, clear-temp, refresh-services). It is `false` unless the entry sets it. Keys that have no `users` entry, such as `api_secret`, keep that access.
|
`admin` is a boolean that lets the API key run the maintenance endpoints (clear-cache, clear-temp, refresh-services). It is `false` unless the entry sets it. Keys that have no `users` entry, such as `api_secret`, keep that access.
|
||||||
|
|
||||||
|
`shared` is a boolean for an API key that a gateway in front of the server gives to many people.
|
||||||
|
The gateway puts its own ID for each person in the `X-Client-Id` header, and the server then
|
||||||
|
counts the `--local-proxy` tunnels of that API key for each person. Without `shared: true` the
|
||||||
|
server ignores the header, because a client that connects to the server directly can set any
|
||||||
|
value. Set it only when the gateway always replaces the header that the client sends.
|
||||||
|
|
||||||
A `tier` names an entry under `serve.tiers`, which holds the settings that several API keys
|
A `tier` names an entry under `serve.tiers`, which holds the settings that several API keys
|
||||||
share. Today a tier carries `rate_limit`, the requests per hour that API key may make; an API
|
share. Today a tier carries `rate_limit`, the requests per hour that API key may make; an API
|
||||||
key can also set its own `rate_limit`, which wins over its tier's. An API key with neither has
|
key can also set its own `rate_limit`, which wins over its tier's. An API key with neither has
|
||||||
no limit. Going over the limit answers `429` with a `Retry-After` header. The server keeps a
|
no limit. Going over the limit answers `429` with a `Retry-After` header, and a `--remote`
|
||||||
|
client waits that time before it tries again. The server keeps a
|
||||||
fixed window rather than a sliding one: it opens on the first request and resets an hour
|
fixed window rather than a sliding one: it opens on the first request and resets an hour
|
||||||
later. A `tier` that names no entry under `serve.tiers`, or a `rate_limit` that is not a
|
later. A `tier` that names no entry under `serve.tiers`, or a `rate_limit` that is not a
|
||||||
positive whole number, stops the server at startup, because the alternative is an API key that
|
positive whole number, stops the server at startup, because the alternative is an API key that
|
||||||
@@ -341,6 +348,8 @@ serve:
|
|||||||
f3a4b5c6:
|
f3a4b5c6:
|
||||||
services: [EXAMPLE1]
|
services: [EXAMPLE1]
|
||||||
server_vault: [EXAMPLE1] # the client's device licenses; the server vault still serves keys
|
server_vault: [EXAMPLE1] # the client's device licenses; the server vault still serves keys
|
||||||
|
d7e8f9a0:
|
||||||
|
shared: true # a gateway gives this key to many people; tunnels count per X-Client-Id
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! note "`dl` keys inside `serve`"
|
!!! note "`dl` keys inside `serve`"
|
||||||
|
|||||||
@@ -252,9 +252,13 @@ async def test_rate_limited_request_returns_429(aiohttp_client, monkeypatch) ->
|
|||||||
setup_routes(app, remote_only=True, dashboard=True)
|
setup_routes(app, remote_only=True, dashboard=True)
|
||||||
client = await aiohttp_client(app)
|
client = await aiohttp_client(app)
|
||||||
|
|
||||||
assert (await client.get("/api/services", headers={"X-Secret-Key": "tier-key"})).status == 200
|
resp = await client.get("/api/services", headers={"X-Secret-Key": "tier-key"})
|
||||||
|
assert resp.status == 200
|
||||||
|
assert (resp.headers["X-RateLimit-Limit"], resp.headers["X-RateLimit-Remaining"]) == ("1", "0")
|
||||||
|
assert 0 < int(resp.headers["X-RateLimit-Reset"]) <= 3600
|
||||||
resp = await client.get("/api/services", headers={"X-Secret-Key": "tier-key"})
|
resp = await client.get("/api/services", headers={"X-Secret-Key": "tier-key"})
|
||||||
assert resp.status == 429 and int(resp.headers["Retry-After"]) > 0
|
assert resp.status == 429 and int(resp.headers["Retry-After"]) > 0
|
||||||
|
assert resp.headers["X-RateLimit-Remaining"] == "0"
|
||||||
assert (await client.get("/api/health", headers={"X-Secret-Key": "tier-key"})).status == 200
|
assert (await client.get("/api/health", headers={"X-Secret-Key": "tier-key"})).status == 200
|
||||||
assert (await client.get("/api/health")).status == 401
|
assert (await client.get("/api/health")).status == 401
|
||||||
# The dashboard key leaves the middleware before the limit check, so it always answers.
|
# The dashboard key leaves the middleware before the limit check, so it always answers.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import click
|
||||||
import pytest
|
import pytest
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
@@ -244,3 +245,93 @@ def test_proxy_error_page_logs_its_title(client: RemoteClient, caplog: pytest.Lo
|
|||||||
client.get("/api/session/s/titles")
|
client.get("/api/session/s/titles")
|
||||||
assert "HTTP 524: example.com | 524: A timeout occurred" in caplog.text
|
assert "HTTP 524: example.com | 524: A timeout occurred" in caplog.text
|
||||||
assert "DOCTYPE" not in caplog.text
|
assert "DOCTYPE" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_a_429_with_retry_after_waits_then_retries(client: RemoteClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
waits: list[int] = []
|
||||||
|
monkeypatch.setattr("unshackle.core.remote_service.wait_retry_after", waits.append)
|
||||||
|
url = "http://srv:8786/api/services"
|
||||||
|
responses.add(responses.GET, url, status=429, json={"status": 429}, headers={"Retry-After": "7"})
|
||||||
|
responses.add(responses.GET, url, json={"status": "ok"})
|
||||||
|
|
||||||
|
assert client.get("/api/services") == {"status": "ok"}
|
||||||
|
assert waits == [7]
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_a_429_without_retry_after_fails_at_once(client: RemoteClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr("unshackle.core.remote_service.wait_retry_after", pytest.fail)
|
||||||
|
responses.add(responses.GET, "http://srv:8786/api/services", status=429, json={"error_code": "RATE_LIMITED"})
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
client.get("/api/services")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_retry_after_waits_out_its_own_deadline(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from unshackle.core import remote_service
|
||||||
|
|
||||||
|
clock = [0.0]
|
||||||
|
monkeypatch.setattr(remote_service.time, "monotonic", lambda: clock[0])
|
||||||
|
monkeypatch.setattr(remote_service.time, "sleep", lambda s: clock.__setitem__(0, clock[0] + s))
|
||||||
|
remote_service.wait_retry_after(3)
|
||||||
|
assert clock[0] == 3.0
|
||||||
|
remote_service.wait_retry_after(99999)
|
||||||
|
assert clock[0] == 3.0 + remote_service.RETRY_AFTER_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rate_limited_tunnel_waits_then_opens(client: RemoteClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from unshackle.core import remote_service
|
||||||
|
|
||||||
|
attempts: list[object] = []
|
||||||
|
|
||||||
|
class Refused:
|
||||||
|
def __init__(self, *_: object) -> None:
|
||||||
|
self.retry_after = 5 if not attempts else None
|
||||||
|
self.at_limit = len(attempts) == 1
|
||||||
|
attempts.append(self)
|
||||||
|
|
||||||
|
def start(self) -> str:
|
||||||
|
if self.retry_after or self.at_limit:
|
||||||
|
raise ConnectionError("HTTP 429")
|
||||||
|
return "id"
|
||||||
|
|
||||||
|
waits: list[int] = []
|
||||||
|
monkeypatch.setattr(remote_service, "TunnelClient", Refused)
|
||||||
|
monkeypatch.setattr(remote_service, "wait_retry_after", lambda s, *_: waits.append(s))
|
||||||
|
assert remote_service.start_tunnel(client) is attempts[2]
|
||||||
|
assert waits == [5, remote_service.TUNNEL_SLOT_POLL]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tunnel_refused_for_another_reason_fails_at_once(
|
||||||
|
client: RemoteClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
from unshackle.core import remote_service
|
||||||
|
|
||||||
|
class Refused:
|
||||||
|
retry_after = None
|
||||||
|
at_limit = False
|
||||||
|
|
||||||
|
def __init__(self, *_: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start(self) -> str:
|
||||||
|
raise ConnectionError("HTTP 403")
|
||||||
|
|
||||||
|
monkeypatch.setattr(remote_service, "TunnelClient", Refused)
|
||||||
|
monkeypatch.setattr(remote_service, "wait_retry_after", pytest.fail)
|
||||||
|
with pytest.raises(click.ClickException, match="HTTP 403"):
|
||||||
|
remote_service.start_tunnel(client)
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_the_429_wait_gives_up_after_its_tries(client: RemoteClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from unshackle.core import remote_service
|
||||||
|
|
||||||
|
waits: list[int] = []
|
||||||
|
monkeypatch.setattr(remote_service, "wait_retry_after", waits.append)
|
||||||
|
responses.add(responses.GET, "http://srv:8786/api/services", status=429, json={}, headers={"Retry-After": "0"})
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
client.get("/api/services")
|
||||||
|
assert waits == [1] * remote_service.RATE_LIMIT_TRIES
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import pytest
|
|||||||
import requests
|
import requests
|
||||||
import responses
|
import responses
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from multidict import CIMultiDict
|
||||||
|
|
||||||
from unshackle.core import tunnel
|
from unshackle.core import tunnel
|
||||||
from unshackle.core.api.routes import tunnel as tunnel_route
|
from unshackle.core.api.routes import tunnel as tunnel_route
|
||||||
@@ -356,6 +357,7 @@ def test_a_malformed_frame_still_leaves_the_tunnel_to_expire(server: str) -> Non
|
|||||||
async with http.ws_connect(server + ROUTE) as ws:
|
async with http.ws_connect(server + ROUTE) as ws:
|
||||||
tunnel_id = (await ws.receive_json())["id"]
|
tunnel_id = (await ws.receive_json())["id"]
|
||||||
await ws.send_bytes(b"\x01")
|
await ws.send_bytes(b"\x01")
|
||||||
|
await ws.close(code=aiohttp.WSCloseCode.GOING_AWAY)
|
||||||
return str(tunnel_id)
|
return str(tunnel_id)
|
||||||
|
|
||||||
tunnel_id = asyncio.run(send_short_frame())
|
tunnel_id = asyncio.run(send_short_frame())
|
||||||
@@ -369,12 +371,112 @@ def test_an_api_key_has_a_tunnel_limit(server: str) -> None:
|
|||||||
clients = [TunnelClient(server, {"X-Secret-Key": "bob"}) for _ in range(tunnel.MAX_TUNNELS)]
|
clients = [TunnelClient(server, {"X-Secret-Key": "bob"}) for _ in range(tunnel.MAX_TUNNELS)]
|
||||||
for client in clients:
|
for client in clients:
|
||||||
client.start()
|
client.start()
|
||||||
with pytest.raises(ConnectionError, match="429"):
|
with pytest.raises(ConnectionError, match="429, the API key already has the maximum number of tunnels open"):
|
||||||
TunnelClient(server, {"X-Secret-Key": "bob"}).start()
|
TunnelClient(server, {"X-Secret-Key": "bob"}).start()
|
||||||
for client in clients:
|
for client in clients:
|
||||||
client.stop()
|
client.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("shared", [True, False])
|
||||||
|
def test_a_shared_api_key_counts_tunnels_for_each_client(
|
||||||
|
server: str, monkeypatch: pytest.MonkeyPatch, shared: bool
|
||||||
|
) -> None:
|
||||||
|
from unshackle.core.config import config
|
||||||
|
|
||||||
|
monkeypatch.setitem(config.serve, "users", {"tier": {"shared": True} if shared else {}})
|
||||||
|
first = [TunnelClient(server, {"X-Secret-Key": "tier", "X-Client-Id": "u1"}) for _ in range(tunnel.MAX_TUNNELS)]
|
||||||
|
second = TunnelClient(server, {"X-Secret-Key": "tier", "X-Client-Id": "u2"})
|
||||||
|
try:
|
||||||
|
for client in first:
|
||||||
|
client.start()
|
||||||
|
if shared:
|
||||||
|
second.start()
|
||||||
|
else:
|
||||||
|
with pytest.raises(ConnectionError, match="maximum number of tunnels"):
|
||||||
|
second.start()
|
||||||
|
with pytest.raises(ConnectionError, match="maximum number of tunnels"):
|
||||||
|
TunnelClient(server, {"X-Secret-Key": "tier", "X-Client-Id": "u1"}).start()
|
||||||
|
finally:
|
||||||
|
for client in [*first, second]:
|
||||||
|
client.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_client_of_a_shared_api_key_cannot_reattach(server: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from unshackle.core.config import config
|
||||||
|
|
||||||
|
monkeypatch.setitem(config.serve, "users", {"tier": {"shared": True}})
|
||||||
|
owner = TunnelClient(server, {"X-Secret-Key": "tier", "X-Client-Id": "u1"})
|
||||||
|
tunnel_id = owner.start()
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{server}{ROUTE}?id={tunnel_id}", headers={"X-Secret-Key": "tier", "X-Client-Id": "u2"}, timeout=10
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
finally:
|
||||||
|
owner.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stopping_one_client_leaves_the_other_tunnels_of_its_api_key_open(
|
||||||
|
server: str, target: int, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(tunnel, "is_public_ip", lambda address: True)
|
||||||
|
monkeypatch.setattr(tunnel, "dialled_itself", lambda writer: False)
|
||||||
|
clients = [TunnelClient(server, {"X-Secret-Key": "erin"}) for _ in range(3)]
|
||||||
|
stopped, *running = [client.start() for client in clients]
|
||||||
|
try:
|
||||||
|
clients[0].stop()
|
||||||
|
assert wait_until(lambda: stopped not in TUNNELS, 5)
|
||||||
|
for tunnel_id in running:
|
||||||
|
proxy = tunnel_proxy(tunnel_id, "erin")
|
||||||
|
assert proxy
|
||||||
|
resp = requests.get(f"http://127.0.0.1:{target}/", proxies={"all": proxy}, timeout=10)
|
||||||
|
assert resp.text == "hello through the tunnel"
|
||||||
|
finally:
|
||||||
|
for client in clients[1:]:
|
||||||
|
client.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value", "seconds"),
|
||||||
|
[
|
||||||
|
("5", 5),
|
||||||
|
("0", 1),
|
||||||
|
(None, None),
|
||||||
|
("", None),
|
||||||
|
("\u00b2", None),
|
||||||
|
("9" * 5000, None),
|
||||||
|
("Wed, 21 Oct 2026 07:28:00 GMT", None),
|
||||||
|
],
|
||||||
|
ids=["seconds", "zero", "absent", "empty", "superscript", "huge", "http-date"],
|
||||||
|
)
|
||||||
|
def test_parse_retry_after(value: str | None, seconds: int | None) -> None:
|
||||||
|
assert tunnel.parse_retry_after(value) == seconds
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_client_that_exits_frees_its_tunnel_at_once(server: str) -> None:
|
||||||
|
async def open_then_leave() -> str:
|
||||||
|
async with aiohttp.ClientSession(headers={"X-Secret-Key": "dave"}) as http:
|
||||||
|
async with http.ws_connect(server + ROUTE) as ws:
|
||||||
|
return str((await ws.receive_json())["id"])
|
||||||
|
|
||||||
|
tunnel_id = asyncio.run(open_then_leave())
|
||||||
|
assert wait_until(lambda: tunnel_id not in TUNNELS, 5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("status", "headers", "reason"),
|
||||||
|
[
|
||||||
|
(429, {"Retry-After": "120"}, "HTTP 429, the API key is over its hourly request limit, retry in 120s"),
|
||||||
|
(429, {"retry-after": "120"}, "HTTP 429, the API key is over its hourly request limit, retry in 120s"),
|
||||||
|
(429, {}, "HTTP 429, the API key already has the maximum number of tunnels open"),
|
||||||
|
(404, None, "HTTP 404"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_refused_tunnel_names_the_reason(status: int, headers: dict[str, str] | None, reason: str) -> None:
|
||||||
|
error = aiohttp.WSServerHandshakeError(None, (), status=status, headers=CIMultiDict(headers or {})) # type: ignore[arg-type]
|
||||||
|
assert tunnel.refusal(error) == reason
|
||||||
|
|
||||||
|
|
||||||
def test_a_connection_that_stops_reading_is_dropped() -> None:
|
def test_a_connection_that_stops_reading_is_dropped() -> None:
|
||||||
sent: list = []
|
sent: list = []
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from unshackle.core.api.compression import compression_middleware
|
|||||||
from unshackle.core.api.events import publish_refresh_events, publish_service_event
|
from unshackle.core.api.events import publish_refresh_events, publish_service_event
|
||||||
from unshackle.core.api.handlers import (
|
from unshackle.core.api.handlers import (
|
||||||
DASHBOARD_PREFIX,
|
DASHBOARD_PREFIX,
|
||||||
|
add_rate_limit_headers,
|
||||||
api_key_authentication,
|
api_key_authentication,
|
||||||
dashboard_authentication,
|
dashboard_authentication,
|
||||||
dashboard_key,
|
dashboard_key,
|
||||||
@@ -559,6 +560,8 @@ def serve(
|
|||||||
f"https://git.gay/ready-dl/pyplayready serve v{pyplayready_version}"
|
f"https://git.gay/ready-dl/pyplayready serve v{pyplayready_version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if request.path != "/api/health" and secret_key and known:
|
||||||
|
add_rate_limit_headers(response, secret_key)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
return serve_authentication
|
return serve_authentication
|
||||||
|
|||||||
@@ -1354,6 +1354,7 @@ CORS_HEADERS = {
|
|||||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
"Access-Control-Allow-Headers": "Content-Type, X-Secret-Key, Authorization",
|
"Access-Control-Allow-Headers": "Content-Type, X-Secret-Key, Authorization",
|
||||||
"Access-Control-Max-Age": "3600",
|
"Access-Control-Max-Age": "3600",
|
||||||
|
"Access-Control-Expose-Headers": "Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1408,10 +1409,19 @@ def rate_limit_response(secret_key: str) -> Optional[web.Response]:
|
|||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"status": 429, "message": "Rate limit exceeded."},
|
{"status": 429, "message": "Rate limit exceeded."},
|
||||||
status=429,
|
status=429,
|
||||||
headers={"Retry-After": str(retry_after)},
|
headers={"Retry-After": str(retry_after), **stats.rate_limit_headers(secret_key)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_rate_limit_headers(response: web.StreamResponse, secret_key: str) -> web.StreamResponse:
|
||||||
|
"""Put *secret_key*'s rate-limit headers on *response*; a streamed response has sent its headers already."""
|
||||||
|
from unshackle.core.api.stats import stats
|
||||||
|
|
||||||
|
if not response.prepared:
|
||||||
|
response.headers.update(stats.rate_limit_headers(secret_key))
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def api_key_authentication(request: web.Request, handler: Any) -> web.StreamResponse:
|
async def api_key_authentication(request: web.Request, handler: Any) -> web.StreamResponse:
|
||||||
"""Gate every non-dashboard route behind an API key in ``app["config"]["users"]``, then rate limit it.
|
"""Gate every non-dashboard route behind an API key in ``app["config"]["users"]``, then rate limit it.
|
||||||
@@ -1433,7 +1443,7 @@ async def api_key_authentication(request: web.Request, handler: Any) -> web.Stre
|
|||||||
return web.json_response({"status": 401, "message": "Secret Key is Invalid."}, status=401)
|
return web.json_response({"status": 401, "message": "Secret Key is Invalid."}, status=401)
|
||||||
if request.path == "/api/health":
|
if request.path == "/api/health":
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
return rate_limit_response(secret_key) or await handler(request)
|
return rate_limit_response(secret_key) or add_rate_limit_headers(await handler(request), secret_key)
|
||||||
|
|
||||||
|
|
||||||
def caller_key(request: Optional[web.Request] = None) -> str:
|
def caller_key(request: Optional[web.Request] = None) -> str:
|
||||||
@@ -5649,19 +5659,30 @@ async def session_delete_handler(session_id: str, request: Optional[web.Request]
|
|||||||
return web.json_response(response)
|
return web.json_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
def shared_key_client(request: web.Request) -> Optional[str]:
|
||||||
|
"""The person behind a ``shared: true`` API key, from the ``X-Client-Id`` its gateway sets; None for other keys.
|
||||||
|
|
||||||
|
Only a shared key reads the header, so a client that talks to the server directly cannot use it to pass a limit.
|
||||||
|
"""
|
||||||
|
if serve_user_config(request_secret_key(request) or "").get("shared") is not True:
|
||||||
|
return None
|
||||||
|
return request.headers.get("X-Client-Id", "")[:64] or None
|
||||||
|
|
||||||
|
|
||||||
async def tunnel_handler(request: web.Request) -> web.StreamResponse:
|
async def tunnel_handler(request: web.Request) -> web.StreamResponse:
|
||||||
"""Open a ``--local-proxy`` tunnel, or reattach to the caller's tunnel named by ``?id=``."""
|
"""Open a ``--local-proxy`` tunnel, or reattach to the caller's tunnel named by ``?id=``."""
|
||||||
owner = request_secret_key(request)
|
owner = request_secret_key(request)
|
||||||
|
client = shared_key_client(request)
|
||||||
tunnel_id = request.query.get("id")
|
tunnel_id = request.query.get("id")
|
||||||
tunnel = TUNNELS.get(tunnel_id) if tunnel_id else None
|
tunnel = TUNNELS.get(tunnel_id) if tunnel_id else None
|
||||||
if tunnel_id and (not tunnel or tunnel.owner != owner):
|
if tunnel_id and (not tunnel or tunnel.owner != owner or tunnel.client != client):
|
||||||
raise APIError(APIErrorCode.NOT_FOUND, "No open tunnel has this id.")
|
raise APIError(APIErrorCode.NOT_FOUND, "No open tunnel has this id.")
|
||||||
if not tunnel and sum(t.owner == owner for t in TUNNELS.values()) >= MAX_TUNNELS:
|
if not tunnel and sum(t.owner == owner and t.client == client for t in TUNNELS.values()) >= MAX_TUNNELS:
|
||||||
raise APIError(APIErrorCode.RATE_LIMITED, f"An API key can have {MAX_TUNNELS} tunnels open at once.")
|
raise APIError(APIErrorCode.RATE_LIMITED, f"An API key can have {MAX_TUNNELS} tunnels open at once.")
|
||||||
ws = web.WebSocketResponse(heartbeat=HEARTBEAT, max_msg_size=MAX_MESSAGE)
|
ws = web.WebSocketResponse(heartbeat=HEARTBEAT, max_msg_size=MAX_MESSAGE)
|
||||||
await ws.prepare(request) # a request that is not a WebSocket upgrade fails here, before a tunnel exists
|
await ws.prepare(request) # a request that is not a WebSocket upgrade fails here, before a tunnel exists
|
||||||
if not tunnel:
|
if not tunnel:
|
||||||
tunnel = Tunnel(owner)
|
tunnel = Tunnel(owner, client)
|
||||||
await tunnel.start()
|
await tunnel.start()
|
||||||
await tunnel.serve(ws)
|
await tunnel.serve(ws)
|
||||||
return ws
|
return ws
|
||||||
|
|||||||
@@ -2074,7 +2074,9 @@ async def tunnel(request: web.Request) -> web.StreamResponse:
|
|||||||
description: >-
|
description: >-
|
||||||
A WebSocket that a --remote client holds open. The server sends the requests of a remote session
|
A WebSocket that a --remote client holds open. The server sends the requests of a remote session
|
||||||
created with proxy tunnel:<id> through it, and the client makes them from its own connection.
|
created with proxy tunnel:<id> through it, and the client makes them from its own connection.
|
||||||
The first message is {"id": <tunnel id>}.
|
The first message is {"id": <tunnel id>}. A client that has finished closes with code 1000,
|
||||||
|
which frees the tunnel at once; after any other close the tunnel waits 60 seconds for a reconnect.
|
||||||
|
An API key with shared: true counts its tunnels for each X-Client-Id value, which its gateway sets.
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
in: query
|
in: query
|
||||||
@@ -2088,7 +2090,9 @@ async def tunnel(request: web.Request) -> web.StreamResponse:
|
|||||||
'404':
|
'404':
|
||||||
description: No open tunnel with this id for this API key
|
description: No open tunnel with this id for this API key
|
||||||
'429':
|
'429':
|
||||||
description: The API key has the maximum number of tunnels open
|
description: >-
|
||||||
|
The API key has the maximum number of tunnels open, or, with a Retry-After header,
|
||||||
|
the API key is over its hourly request limit
|
||||||
"""
|
"""
|
||||||
return await tunnel_handler(request)
|
return await tunnel_handler(request)
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,18 @@ class ServerStats:
|
|||||||
entry.window_used += 1
|
entry.window_used += 1
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def rate_limit_headers(self, key: str) -> Dict[str, str]:
|
||||||
|
"""The ``X-RateLimit-*`` headers for ``key``'s current window; none for an API key with no limit."""
|
||||||
|
limit = key_rate_limit(key)
|
||||||
|
if limit is None:
|
||||||
|
return {}
|
||||||
|
entry = self.key_stats(key)
|
||||||
|
return {
|
||||||
|
"X-RateLimit-Limit": str(limit),
|
||||||
|
"X-RateLimit-Remaining": str(max(0, limit - entry.window_used)),
|
||||||
|
"X-RateLimit-Reset": str(max(0, int(entry.window_start + RATE_LIMIT_WINDOW - time.time()))),
|
||||||
|
}
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import contextlib
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
@@ -35,9 +36,10 @@ from langcodes import Language
|
|||||||
from requests.adapters import HTTPAdapter, Retry
|
from requests.adapters import HTTPAdapter, Retry
|
||||||
from rich.padding import Padding
|
from rich.padding import Padding
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
|
from rich.spinner import Spinner
|
||||||
|
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.console import console, prompt_user, show_notice
|
from unshackle.core.console import SyncLive, console, prompt_user, show_notice
|
||||||
from unshackle.core.constants import AnyTrack
|
from unshackle.core.constants import AnyTrack
|
||||||
from unshackle.core.credential import Credential
|
from unshackle.core.credential import Credential
|
||||||
from unshackle.core.drm import BlowfishStripe, cbcs, real_kids
|
from unshackle.core.drm import BlowfishStripe, cbcs, real_kids
|
||||||
@@ -52,7 +54,7 @@ from unshackle.core.titles.music import SONG_FIELDS, Album, Song
|
|||||||
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
|
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
|
||||||
from unshackle.core.tracks.attachment import Attachment
|
from unshackle.core.tracks.attachment import Attachment
|
||||||
from unshackle.core.tracks.track import Track
|
from unshackle.core.tracks.track import Track
|
||||||
from unshackle.core.tunnel import TunnelClient
|
from unshackle.core.tunnel import TunnelClient, parse_retry_after
|
||||||
from unshackle.core.utils import run_proxy
|
from unshackle.core.utils import run_proxy
|
||||||
from unshackle.core.utils.collections import ci_get
|
from unshackle.core.utils.collections import ci_get
|
||||||
from unshackle.core.utils.redact import redact_path, redact_text, safe_display_url
|
from unshackle.core.utils.redact import redact_path, redact_text, safe_display_url
|
||||||
@@ -69,6 +71,10 @@ DEFAULT_AUTH_HEADERS = ["X-Secret-Key", "X-Api-Key"]
|
|||||||
SESSION_LOST = ("SESSION_NOT_FOUND", "CONNECT", "TIMEOUT", "TUNNEL_CLOSED")
|
SESSION_LOST = ("SESSION_NOT_FOUND", "CONNECT", "TIMEOUT", "TUNNEL_CLOSED")
|
||||||
# what a recovering call can meet on its way, so these log only once the recovery fails
|
# what a recovering call can meet on its way, so these log only once the recovery fails
|
||||||
RECOVERABLE = (*SESSION_LOST, "SERVER_DRAINING")
|
RECOVERABLE = (*SESSION_LOST, "SERVER_DRAINING")
|
||||||
|
RETRY_AFTER_MAX = 3600
|
||||||
|
RETRY_AFTER_LOCK = Lock()
|
||||||
|
RATE_LIMIT_TRIES = 3
|
||||||
|
TUNNEL_SLOT_POLL = 15
|
||||||
REOPEN_WAIT = 1800.0 # seconds a lost session waits for its server to come back; covers a deploy's whole drain
|
REOPEN_WAIT = 1800.0 # seconds a lost session waits for its server to come back; covers a deploy's whole drain
|
||||||
|
|
||||||
_recovering = local()
|
_recovering = local()
|
||||||
@@ -92,6 +98,23 @@ def redact_secrets(text: str, data: Optional[Dict[str, Any]] = None) -> str:
|
|||||||
return redact_text(text, secrets) or ""
|
return redact_text(text, secrets) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def wait_retry_after(seconds: int, reason: str = "Rate limited by the server") -> None:
|
||||||
|
"""Count down a wait on the console, as ``--slow`` does, so the caller can retry; one hour at most, the rate-limit window.
|
||||||
|
|
||||||
|
Threads refused together share one countdown, each still waits out its own deadline.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + min(seconds, RETRY_AFTER_MAX)
|
||||||
|
with RETRY_AFTER_LOCK:
|
||||||
|
if deadline <= time.monotonic():
|
||||||
|
return
|
||||||
|
log.info(f"{reason}, retrying in {math.ceil(deadline - time.monotonic())}s")
|
||||||
|
spinner = Spinner("dots")
|
||||||
|
with SyncLive(Padding(spinner, (0, 5)), console=console, refresh_per_second=12.5, transient=True):
|
||||||
|
while (left := deadline - time.monotonic()) > 0:
|
||||||
|
spinner.update(text=f"{reason}, retrying in {math.ceil(left)} seconds...")
|
||||||
|
time.sleep(min(1.0, left))
|
||||||
|
|
||||||
|
|
||||||
class RemoteError(SystemExit):
|
class RemoteError(SystemExit):
|
||||||
"""A remote call that failed: logged, then exits a CLI run like ``SystemExit(1)``.
|
"""A remote call that failed: logged, then exits a CLI run like ``SystemExit(1)``.
|
||||||
|
|
||||||
@@ -172,6 +195,7 @@ class RemoteClient:
|
|||||||
{"json": data} if body is None else {"data": body, "headers": {"Content-Type": "application/octet-stream"}}
|
{"json": data} if body is None else {"data": body, "headers": {"Content-Type": "application/octet-stream"}}
|
||||||
)
|
)
|
||||||
gateway_retried = False
|
gateway_retried = False
|
||||||
|
rate_waits = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
with self.in_flight(endpoint):
|
with self.in_flight(endpoint):
|
||||||
@@ -186,6 +210,14 @@ class RemoteClient:
|
|||||||
# servers differ on which header carries the key, so retry the rest before giving up
|
# servers differ on which header carries the key, so retry the rest before giving up
|
||||||
if resp.status_code == 401 and self.next_auth_header():
|
if resp.status_code == 401 and self.next_auth_header():
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
resp.status_code == 429
|
||||||
|
and rate_waits < RATE_LIMIT_TRIES
|
||||||
|
and (retry_after := parse_retry_after(resp.headers.get("Retry-After")))
|
||||||
|
):
|
||||||
|
rate_waits += 1
|
||||||
|
wait_retry_after(retry_after)
|
||||||
|
continue
|
||||||
# a proxy HTML page, not the server's JSON, means a dropped tunnel stream, which one retry gets past
|
# a proxy HTML page, not the server's JSON, means a dropped tunnel stream, which one retry gets past
|
||||||
if (
|
if (
|
||||||
resp.status_code in (502, 503, 504)
|
resp.status_code in (502, 503, 504)
|
||||||
@@ -831,11 +863,21 @@ def resolve_remote_proxy_arg(proxy_arg: str, providers: Optional[list] = None) -
|
|||||||
def start_tunnel(client: RemoteClient) -> TunnelClient:
|
def start_tunnel(client: RemoteClient) -> TunnelClient:
|
||||||
"""Open a tunnel that sends the server's requests out through this machine's own connection."""
|
"""Open a tunnel that sends the server's requests out through this machine's own connection."""
|
||||||
headers = {k: str(v) for k, v in client.session.headers.items() if k.lower() != "connection"}
|
headers = {k: str(v) for k, v in client.session.headers.items() if k.lower() != "connection"}
|
||||||
tunnel = TunnelClient(client.server_url, headers)
|
slot_deadline = time.monotonic() + RETRY_AFTER_MAX
|
||||||
try:
|
rate_waits = 0
|
||||||
tunnel.start()
|
while True:
|
||||||
except ConnectionError as e:
|
tunnel = TunnelClient(client.server_url, headers)
|
||||||
raise click.ClickException(f"--local-proxy could not open a tunnel to the server: {e}")
|
try:
|
||||||
|
tunnel.start()
|
||||||
|
break
|
||||||
|
except ConnectionError as e:
|
||||||
|
if tunnel.retry_after and rate_waits < RATE_LIMIT_TRIES:
|
||||||
|
rate_waits += 1
|
||||||
|
wait_retry_after(tunnel.retry_after)
|
||||||
|
elif tunnel.at_limit and time.monotonic() < slot_deadline:
|
||||||
|
wait_retry_after(TUNNEL_SLOT_POLL, "Every tunnel of this API key is in use")
|
||||||
|
else:
|
||||||
|
raise click.ClickException(f"--local-proxy could not open a tunnel to the server: {e}")
|
||||||
client.tunnel = tunnel
|
client.tunnel = tunnel
|
||||||
return tunnel
|
return tunnel
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import threading
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiohttp import WSMsgType, web
|
from aiohttp import WSCloseCode, WSMsgType, web
|
||||||
|
from multidict import CIMultiDict
|
||||||
|
|
||||||
log = logging.getLogger("tunnel")
|
log = logging.getLogger("tunnel")
|
||||||
|
|
||||||
@@ -144,10 +145,11 @@ TUNNELS: dict[str, "Tunnel"] = {}
|
|||||||
class Tunnel:
|
class Tunnel:
|
||||||
"""The server side of one client's tunnel: a loopback SOCKS5 listener and the client's current link."""
|
"""The server side of one client's tunnel: a loopback SOCKS5 listener and the client's current link."""
|
||||||
|
|
||||||
def __init__(self, owner: Optional[str]) -> None:
|
def __init__(self, owner: Optional[str], client: Optional[str] = None) -> None:
|
||||||
self.id = secrets.token_urlsafe(16)
|
self.id = secrets.token_urlsafe(16)
|
||||||
self.secret = secrets.token_urlsafe(24)
|
self.secret = secrets.token_urlsafe(24)
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
|
self.client = client
|
||||||
self.link: Optional[Link] = None
|
self.link: Optional[Link] = None
|
||||||
self.pending: dict[int, asyncio.Future[bool]] = {}
|
self.pending: dict[int, asyncio.Future[bool]] = {}
|
||||||
self.sids = itertools.count(1)
|
self.sids = itertools.count(1)
|
||||||
@@ -290,7 +292,9 @@ class Tunnel:
|
|||||||
self.link = None
|
self.link = None
|
||||||
self.linked.clear()
|
self.linked.clear()
|
||||||
link.end_all()
|
link.end_all()
|
||||||
if self.id in TUNNELS:
|
if ws.close_code == WSCloseCode.OK:
|
||||||
|
self.expire()
|
||||||
|
elif self.id in TUNNELS:
|
||||||
self.expiry = asyncio.get_running_loop().call_later(GRACE, self.expire)
|
self.expiry = asyncio.get_running_loop().call_later(GRACE, self.expire)
|
||||||
|
|
||||||
def expire(self) -> None:
|
def expire(self) -> None:
|
||||||
@@ -377,6 +381,27 @@ async def connect_public(host: str, port: int) -> tuple[asyncio.StreamReader, as
|
|||||||
raise error
|
raise error
|
||||||
|
|
||||||
|
|
||||||
|
def parse_retry_after(value: Optional[str]) -> Optional[int]:
|
||||||
|
"""Whole seconds, at least 1, from a delay-seconds Retry-After; None for an absent, HTTP-date or malformed one."""
|
||||||
|
if not value or len(value) > 9 or not (value.isascii() and value.isdigit()):
|
||||||
|
return None
|
||||||
|
return max(1, int(value))
|
||||||
|
|
||||||
|
|
||||||
|
def retry_after(e: aiohttp.WSServerHandshakeError) -> Optional[int]:
|
||||||
|
"""The Retry-After seconds of a rate-limit refusal, or None for any other refusal."""
|
||||||
|
return parse_retry_after(CIMultiDict(e.headers or {}).get("Retry-After")) if e.status == 429 else None
|
||||||
|
|
||||||
|
|
||||||
|
def refusal(e: aiohttp.WSServerHandshakeError) -> str:
|
||||||
|
"""Why the server refused a tunnel. aiohttp drops the response body, so a 429 is told apart by its headers."""
|
||||||
|
if e.status != 429:
|
||||||
|
return f"HTTP {e.status}"
|
||||||
|
if (wait := retry_after(e)) is not None:
|
||||||
|
return f"HTTP 429, the API key is over its hourly request limit, retry in {wait}s"
|
||||||
|
return "HTTP 429, the API key already has the maximum number of tunnels open"
|
||||||
|
|
||||||
|
|
||||||
class TunnelClient:
|
class TunnelClient:
|
||||||
"""The client side: a background thread that keeps the tunnel's WebSocket open and dials for the server."""
|
"""The client side: a background thread that keeps the tunnel's WebSocket open and dials for the server."""
|
||||||
|
|
||||||
@@ -385,6 +410,8 @@ class TunnelClient:
|
|||||||
self.headers = headers
|
self.headers = headers
|
||||||
self.id: Optional[str] = None
|
self.id: Optional[str] = None
|
||||||
self.error: Optional[BaseException] = None
|
self.error: Optional[BaseException] = None
|
||||||
|
self.retry_after: Optional[int] = None
|
||||||
|
self.at_limit = False
|
||||||
self.lost = False
|
self.lost = False
|
||||||
self.ready = threading.Event()
|
self.ready = threading.Event()
|
||||||
self.relays: set[asyncio.Task[None]] = set()
|
self.relays: set[asyncio.Task[None]] = set()
|
||||||
@@ -398,7 +425,7 @@ class TunnelClient:
|
|||||||
self.thread.start()
|
self.thread.start()
|
||||||
if not self.ready.wait(timeout) or not self.id:
|
if not self.ready.wait(timeout) or not self.id:
|
||||||
self.stop() # or the thread opens the tunnel later and nobody uses it
|
self.stop() # or the thread opens the tunnel later and nobody uses it
|
||||||
raise ConnectionError(f"Could not open a tunnel to the server: {self.error or 'timed out'}")
|
raise ConnectionError(str(self.error or "timed out"))
|
||||||
return self.id
|
return self.id
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
@@ -431,7 +458,9 @@ class TunnelClient:
|
|||||||
backoff = 1
|
backoff = 1
|
||||||
await self.serve(Link(ws))
|
await self.serve(Link(ws))
|
||||||
except aiohttp.WSServerHandshakeError as e:
|
except aiohttp.WSServerHandshakeError as e:
|
||||||
self.error = e
|
self.error = ConnectionError(refusal(e))
|
||||||
|
self.retry_after = retry_after(e)
|
||||||
|
self.at_limit = e.status == 429 and self.retry_after is None
|
||||||
# a 404 means the tunnel expired or the server has no tunnel route; a 5xx is a proxy in between
|
# a 404 means the tunnel expired or the server has no tunnel route; a 5xx is a proxy in between
|
||||||
if not self.id or e.status < 500:
|
if not self.id or e.status < 500:
|
||||||
if self.id:
|
if self.id:
|
||||||
|
|||||||
Reference in New Issue
Block a user