diff --git a/CHANGELOG.md b/CHANGELOG.md index 26736a9..8efdc3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v2.5.1 (2026-05-20) + +- **Adaptive retry for transient errors** (429/502/503) + - Exponential backoff: 2s → 4s → 8s, up to 3 retries + - Works for both single-provider and BGP mode + - BGP routes retry before failing over to next route + - Connection errors (reset/broken pipe) also retried +- **Proxy socket reuse** — no more `Address already in use` crashes on restart +- **BGP startup log** shows route count and names + ## v2.5.0 (2026-05-20) - **AI BGP — Multi-provider routing with automatic failover** diff --git a/codex-launcher_2.5.0_all.deb b/codex-launcher_2.5.0_all.deb deleted file mode 100644 index bd99b79..0000000 Binary files a/codex-launcher_2.5.0_all.deb and /dev/null differ diff --git a/codex-launcher_2.5.1_all.deb b/codex-launcher_2.5.1_all.deb new file mode 100644 index 0000000..22ad5ac Binary files /dev/null and b/codex-launcher_2.5.1_all.deb differ diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index 8cf1803..9fd8e7c 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -25,6 +25,12 @@ model_catalog_json = "" """ CHANGELOG = [ + ("2.5.1", "2026-05-20", [ + "Adaptive retry for 429/502/503 errors with exponential backoff", + "BGP routes also retry transient errors before failing over", + "Proxy socket reuse — no more 'Address already in use' crashes", + "BGP route count shown at proxy startup", + ]), ("2.5.0", "2026-05-20", [ "AI BGP — multi-provider routing with automatic failover", "Create BGP pools with ordered routes from any configured endpoint", @@ -629,7 +635,7 @@ class LauncherWin(Gtk.Window): # header row hdr = Gtk.Box(spacing=8) vbox.pack_start(hdr, False, False, 0) - lbl = Gtk.Label(label="Codex Launcher v2.5.0") + lbl = Gtk.Label(label="Codex Launcher v2.5.1") lbl.set_use_markup(True) hdr.pack_start(lbl, False, False, 0) changelog_btn = Gtk.Button(label="Changelog") diff --git a/src/translate-proxy.py b/src/translate-proxy.py index 6d3997b..80e4e69 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -1041,14 +1041,30 @@ class Handler(http.server.BaseHTTPRequestHandler): "Authorization": f"Bearer {effective_key}", }, browser_ua=True) print(f"[translate-proxy] POST {target} model={model} stream={stream} items={len(input_data) if isinstance(input_data,list) else 1}", file=sys.stderr) - req = urllib.request.Request(target, data=json.dumps(chat_body).encode(), headers=fwd) - try: - upstream = urllib.request.urlopen(req, timeout=180) - except urllib.error.HTTPError as e: - err = e.read().decode() - return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err}}) - except Exception as e: - return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}}) + chat_body_b = json.dumps(chat_body).encode() + max_retries = 3 + for attempt in range(max_retries + 1): + req = urllib.request.Request(target, data=chat_body_b, headers=fwd) + try: + upstream = urllib.request.urlopen(req, timeout=180) + except urllib.error.HTTPError as e: + err_body = e.read().decode() + if e.code in (429, 502, 503) and attempt < max_retries: + wait = min(2 ** (attempt + 1), 15) + print(f"[translate-proxy] HTTP {e.code} (attempt {attempt+1}/{max_retries}), retrying in {wait}s: {err_body[:150]}", file=sys.stderr) + time.sleep(wait) + continue + return self.send_json(e.code, {"error": {"type": "upstream_error", "message": err_body}}) + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) as e: + if attempt < max_retries: + wait = min(2 ** (attempt + 1), 10) + print(f"[translate-proxy] connection error (attempt {attempt+1}/{max_retries}), retrying in {wait}s: {e}", file=sys.stderr) + time.sleep(wait) + continue + return self.send_json(502, {"error": {"type": "proxy_error", "message": str(e)}}) + except Exception as e: + return self.send_json(500, {"error": {"type": "proxy_error", "message": str(e)}}) + break self._forward_oa_compat(upstream, stream, model, chat_body, body, input_data, fwd, target) def _build_chat_body(self, model, messages, body, stream): @@ -1108,18 +1124,37 @@ class Handler(http.server.BaseHTTPRequestHandler): }, browser_ua=True) print(f"[bgp] trying route '{route.get('name', r_url)}' model={r_model}", file=sys.stderr) req = urllib.request.Request(target, data=json.dumps(chat_body).encode(), headers=fwd) - try: - upstream = urllib.request.urlopen(req, timeout=180) - print(f"[bgp] route '{route.get('name', r_url)}' connected OK", file=sys.stderr) - self._forward_oa_compat(upstream, stream, r_model, chat_body, body, input_data, fwd, target) - return - except urllib.error.HTTPError as e: - err = e.read().decode() - print(f"[bgp] route '{route.get('name', r_url)}' FAILED: HTTP {e.code}: {err[:200]}", file=sys.stderr) - errors.append(f"{route.get('name','?')}: HTTP {e.code}") - except Exception as e: - print(f"[bgp] route '{route.get('name', r_url)}' FAILED: {e}", file=sys.stderr) - errors.append(f"{route.get('name','?')}: {e}") + route_ok = False + for attempt in range(3): + try: + upstream = urllib.request.urlopen(req, timeout=180) + print(f"[bgp] route '{route.get('name', r_url)}' connected OK", file=sys.stderr) + self._forward_oa_compat(upstream, stream, r_model, chat_body, body, input_data, fwd, target) + return + except urllib.error.HTTPError as e: + err = e.read().decode() + if e.code in (429, 502, 503) and attempt < 2: + wait = min(2 ** (attempt + 1), 10) + print(f"[bgp] route '{route.get('name', r_url)}' HTTP {e.code}, retry {attempt+1}/2 in {wait}s", file=sys.stderr) + time.sleep(wait) + req = urllib.request.Request(target, data=json.dumps(chat_body).encode(), headers=fwd) + continue + print(f"[bgp] route '{route.get('name', r_url)}' FAILED: HTTP {e.code}: {err[:200]}", file=sys.stderr) + errors.append(f"{route.get('name','?')}: HTTP {e.code}") + break + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) as e: + if attempt < 2: + wait = min(2 ** (attempt + 1), 8) + print(f"[bgp] route '{route.get('name', r_url)}' conn error, retry {attempt+1}/2 in {wait}s: {e}", file=sys.stderr) + time.sleep(wait) + req = urllib.request.Request(target, data=json.dumps(chat_body).encode(), headers=fwd) + continue + errors.append(f"{route.get('name','?')}: {e}") + break + except Exception as e: + print(f"[bgp] route '{route.get('name', r_url)}' FAILED: {e}", file=sys.stderr) + errors.append(f"{route.get('name','?')}: {e}") + break print(f"[bgp] ALL ROUTES FAILED: {errors}", file=sys.stderr) self.send_json(502, {"error": {"type": "bgp_all_routes_failed", "message": f"All BGP routes failed: {'; '.join(errors)}"}}) @@ -1440,8 +1475,12 @@ class Handler(http.server.BaseHTTPRequestHandler): print(f"[translate-proxy] {BACKEND} {msg}", file=sys.stderr) if __name__ == "__main__": - server = http.server.HTTPServer(("127.0.0.1", PORT), Handler) + class ReusableHTTPServer(http.server.HTTPServer): + allow_reuse_address = True + server = ReusableHTTPServer(("127.0.0.1", PORT), Handler) print(f"translate-proxy ({BACKEND}) listening on http://127.0.0.1:{PORT}", flush=True) print(f"Target: {TARGET_URL}", flush=True) print(f"Models: {[m['id'] for m in MODELS]}", flush=True) + if BGP_ROUTES: + print(f"BGP routes: {len(BGP_ROUTES)} ({[r.get('name','?') for r in BGP_ROUTES]})", flush=True) server.serve_forever()