# Solpuga > Solpuga is a cross-platform desktop HTTP/HTTPS debugging proxy for developers, QA, and security teams. Free Base tier; Pro by yearly subscription. --- ## Quick Start Source: https://solpuga.pro/en/docs/quick-start ## System requirements All platforms require a 64-bit operating system and network access. | Platform | Minimum version | Requirements | | --- | --- | --- | | macOS | 10.15 Catalina or later | Apple Silicon (ARM64), Xcode Command Line Tools | | Windows | Windows 10 version 1803 or later | x64, WebView2, Microsoft Visual C++ Redistributable | | Linux | Ubuntu 22.04+ (AMD/ARM) | WebKitGTK 4.1, GTK 3, OpenSSL, libfuse2 (AppImage only) | On Linux, the app also needs a way to obtain administrator privileges: `pkexec`, graphical `sudo` (`zenity`, `kdialog`, or `yad`), or terminal `sudo`. VPN clients, antivirus software, and corporate proxies that intercept the same traffic may conflict with the app. ## Installation ### macOS Open the `.dmg` and drag the app into Applications. ### Windows Run the `-setup.exe` installer. ### Linux The app ships as an AppImage. A browser saves it without the execute bit, so set that bit yourself before launching: ```bash chmod +x Solpuga__aarch64.AppImage ./Solpuga__aarch64.AppImage ``` Without `chmod +x` the launch fails with `Permission denied`. - **`chmod +x` did not help?** The file is on a `noexec` mount — a shared VM folder, or `/tmp` on some systems. Move it to your home directory and try again. - **Ubuntu 24.04 or newer** needs FUSE 2: `sudo apt install libfuse2t64`. Without it the AppImage reports `dlopen(): error loading libfuse.so.2`. - **Do not run it with `sudo`.** As root, the app writes its CA and its configuration into `/root`, so the certificate wizard and the captured traffic will not match what you see on a normal launch. ## First launch ### 1. Install the certificate HTTPS can only be decrypted once the app's own root CA is installed and trusted on your computer. Each installation generates its own certificate. The wizard lives under the **SSL Certificate** menu and does the work for you; your operating system may ask for an administrator password. See [Certificate Installation](/docs/certificate) for details. ### 2. Start capturing Start capture from the main window. The system proxy is configured automatically, so you do not need to change the proxy settings yourself. If that fails, the proxy is still listening — point a browser or an app at it manually, using the address and port it reports. Capture can also be started and stopped from the app's system tray icon. ### 3. Open a browser or app Open the website or application whose traffic you want to inspect. Its requests appear as they happen, and selecting one shows its headers, body, status, timing, and the rest of the detail. See [Capture & Inspect Traffic](/docs/traffic) for what is recorded. ## Address and port By default the proxy listens on all network interfaces (`0.0.0.0`), port `7070`. Both can be changed under **Settings → Proxy**. ## What to do next - [Map Local](/docs/map-local) — replace a response with your own content. - [Breakpoints](/docs/breakpoints) — pause a request or a response and edit it. - [Mobile Devices](/docs/smartphones) — capture traffic from iOS and Android devices and emulators. - [Settings](/docs/settings) — proxy address, startup behaviour, bypass list. - [License and activation](/docs/license) — how to activate a licence and manage devices. --- ## Certificate Installation Source: https://solpuga.pro/en/docs/certificate To show the contents of HTTPS traffic, the app has to decrypt it on your computer. It uses its own root CA certificate for that. Each installation generates its own certificate; it is never shared between machines. Without it, HTTPS requests still appear in the captured traffic, but stay encrypted. - [Installation](https://media-solpuga.s3.cloud.ru/ru/ca.mp4) ## What has to end up true The certificate must be in your system's trusted root store **and** marked as trusted for SSL. The wizard under the **SSL Certificate** menu does both, and verifies the result; it needs administrator privileges, so expect a password prompt or a system security dialog. On Linux the privileges are obtained through `pkexec` or a graphical `sudo` helper (`zenity`, `kdialog`, `yad`). If none of them is available, the installation cannot proceed. Where the certificate ends up: | Platform | Location | | --- | --- | | macOS | login keychain, trusted for SSL | | Windows | Trusted Root Certification Authorities | | Debian/Ubuntu | `/usr/local/share/ca-certificates/solpuga-proxy-ca.crt` | | Arch | `/etc/ca-certificates/trust-source/anchors/solpuga-proxy-ca.crt` | | RHEL/Fedora | `/etc/pki/ca-trust/source/anchors/solpuga-proxy-ca.crt` | | openSUSE | `/etc/pki/trust/anchors/solpuga-proxy-ca.crt` | **Firefox** keeps its own certificate store and does not read the system one. Import the certificate there separately, through its privacy and security settings. ## If the automatic installation does not work The wizard can also hand you the certificate file and the exact commands or dialog sequence for your platform, then re-check the result. In short, what you are doing by hand is: - **macOS** — add the certificate to a keychain and grant it `trustRoot` trust for SSL. - **Windows** — place it in **Trusted Root Certification Authorities** for the local machine. - **Linux** — copy it into the distribution's CA anchor directory and refresh the trust store (`update-ca-certificates` or `update-ca-trust extract`). A certificate that is installed but not yet trusted makes verification fail — that is the step to repeat, not the installation. Browser still refusing it after that? See [ERR_CERT_AUTHORITY_INVALID through a proxy](/errors/err-cert-authority-invalid). ## Exporting the certificate The installed certificate can be exported in PEM format from **Settings → Proxy**. Use that copy where the app cannot install it itself — a container, a CI runner, or a device you set up by hand. ## Removing the certificate The certificate can be removed from **Settings → Proxy**, or from the certificate wizard. Removing it does not remove the app and does not delete captured traffic. On macOS, a certificate that was moved into the **System** keychain by hand has to be removed through Keychain Access. On Linux, when the removal cannot be performed directly, the app opens a terminal with the commands to run. ## Services that refuse the certificate Some apps and services use certificate pinning and will not accept any proxy CA. They typically stop connecting while capture is on. Add such a host to the bypass list (see [Settings](/docs/settings)) and it is tunnelled through without decryption: it keeps working, and its traffic is still recorded, just not readable. ## Mobile devices Simulators, emulators, and physical phones need the certificate installed and trusted on the device as well — see [Mobile Devices](/docs/smartphones). --- ## Capture & Inspect Traffic Source: https://solpuga.pro/en/docs/traffic While capture is running, every request that goes through the proxy is recorded: the request and the response, their headers and bodies, the status, the timing, and the upstream TLS certificate. Captured requests are grouped by host, by the application they came from, and by device, so traffic from one app or one phone can be looked at on its own. Hosts you care about can be pinned; hosts you do not want decrypted can be sent to the bypass list straight from a captured request. Clearing the session discards everything recorded so far. Stopping capture does not clear it. ## Narrowing it down Two mechanisms work together: - a text search over the captured requests; - a filter built from rules, combined with **AND** (all must match) or **OR** (any one must match). A filter rule matches on the URL, the path, the method, the scheme, or the status. For URL and path it can test equality, containment, a prefix, a suffix, or a regular expression; for the status it takes a class (2xx, 3xx, 4xx, 5xx), an exact code, a range, or a state for requests that have no code at all — still running, failed, or bypassed. A filter holds a limited number of rules, and individual rules can be switched off without being deleted. ## What you see for one request - **Headers** of the request and of the response. - **Body**, rendered according to its content type: JSON as a collapsible tree, HTML rendered, images with their dimensions, `multipart/form-data` as fields with previews for file parts, anything binary as a hex dump. Text bodies can be searched. - **Authentication** detected in the request — the scheme, the header it came from, and the decoded token. - **Raw** HTTP message, as it went over the wire. - **Summary** — host, path, scheme, HTTP version, source application, status, duration, body sizes, timestamps, and the server's TLS certificate (subject, issuer, validity, serial, fingerprint, SANs). Bodies too large to display are not loaded into the viewer; they can be saved to a file instead. ### Streams A response with `Content-Type: text/event-stream` is passed through to the client in real time and its events are recorded as they arrive. WebSocket connections are recorded frame by frame, with direction, opcode, size, and payload; binary payloads are shown as hex, text payloads are pretty-printed when they parse as JSON. Both are capped: 1000 entries per request, and a single WebSocket payload is captured up to 64 KiB. If an event is in the stream but never reaches the page, paste the raw response into the [SSE parser](/tools/sse-parser): it shows what a browser reads from it and what it drops in silence. Streams that behave oddly have their own guides: [SSE arrives in one chunk](/errors/sse-buffered-nginx), [a stream that dies after 60 seconds](/errors/stream-dies-after-60-seconds), [replacement characters in a stream](/errors/stream-replacement-characters), [the six-connection limit](/errors/six-connection-limit), and [WebSocket closed with code 1006](/errors/websocket-1006). ### Timing Timing is measured per phase, so a slow request can be attributed: reading the request body, matching [Map Local](/docs/map-local) rules, a rule's own delay, time spent paused on a [breakpoint](/docs/breakpoints), establishing the upstream connection, waiting for the server, reading the response body, and time paused before the response reached the client. Whether the upstream connection was new or reused is recorded too. ### Failures A request that never got a response is recorded with the reason: DNS resolution failed, the server rejected the proxy's TLS connection, the TLS handshake with the client failed, connection refused, reset, or timed out, the client closed the connection first, a WebSocket upgrade never completed. The underlying error is kept alongside the explanation. A body shown as encrypted content means the certificate is not installed or not trusted on the client — see [Certificate Installation](/docs/certificate). ## Getting data out A single request can be copied or saved: its headers, its body, or the raw HTTP message. ### How to record a HAR file A browser saves a HAR for one tab. Solpuga records one for everything that goes through the proxy: a mobile app, a desktop program, a script, or several browsers at once. 1. Start capture and reproduce the problem. HTTPS bodies are readable once the [certificate is installed](/docs/certificate). A phone is connected as described in [Mobile Devices](/docs/smartphones). 2. Choose **Tools** → **Export Session as HAR…** (`Cmd/Ctrl + Shift + E`), or click the download button in the title bar, next to Clear. 3. Save the file. The file holds the whole session as HAR 1.2. It opens in Chrome DevTools, Charles, Fiddler, Insomnia, and Postman, and with nothing to install in the [HAR viewer](/tools/har-viewer), right in your browser. Export is unavailable while nothing has been captured. Need a HAR of a single browser tab instead? DevTools saves that one: [how to save a HAR file in Chrome, Firefox and Safari](/tools/har-viewer#how-to-save). What to expect in the file: - requests that have not finished are skipped; failed requests are exported with status `0`; - bodies are stored decoded and decompressed, while response headers are exported as captured, so some importers try to decompress them a second time; - binary response bodies are base64-encoded, binary request bodies are omitted and carry a comment instead. ### How to open a HAR file A `.har` file from a browser or another proxy opens through **Tools** → **Import Session from HAR…** (`Cmd/Ctrl + Shift + I`), or by opening it with the app from your file manager. It opens in a separate read-only window with its own list and inspector, so the live capture is untouched. Bodies and raw messages can be saved out of it. Files larger than 500 MB are rejected. --- ## Map Local Source: https://solpuga.pro/en/docs/map-local A Map Local rule answers a matching request from inside the app instead of from the network: the request never reaches the server, and the client gets the status, headers, and body you wrote. It is one of four ways to mock a response — see [how it compares to Chrome Local Overrides, MSW, and a client interceptor](/errors/mock-api-response). Rules live in their own window, opened from the **Tools** menu (`Cmd/Ctrl + Shift + M`). A rule can also be created from a captured request, which pre-fills it with the response that actually came back — usually the fastest way to write one. ## Matching A rule is matched against the full URL — scheme, host, path, and query — using a wildcard or a regular expression, and can be limited to specific methods. Wildcards: | Token | Matches | | --- | --- | | `*` | any characters except `/` — one path segment | | `**` | any characters, including `/` — any path | | `?` | a single character | A wildcard pattern has to match the whole URL, so `*/api/users/*` is anchored at both ends. A regular expression is used as written and matches anywhere in the URL unless you anchor it yourself with `^` and `$`. A pattern can be tried against a sample URL before the rule is saved. Default ports are normalised on both sides, so `https://example.com/v1` matches a captured `https://example.com:443/v1`. `CONNECT` cannot be targeted: a stored body is not a valid answer to a tunnel request. Use a [breakpoint](/docs/breakpoints) if you need to act on tunnel setup. ## The response A rule carries a status code (100–599), a set of headers — duplicates allowed, and each one can be switched off individually — and a body, which can be written as JSON, text, or HTML, or edited as a raw HTTP/1.x message. It can also carry a delay of up to 60 seconds, applied before the response is sent. Because the response is assembled from the rule rather than forwarded from a server, a few headers are corrected on the way out: - `Content-Length` is recomputed from the stored body; - `Content-Encoding`, `Transfer-Encoding`, `ETag`, and `Content-MD5` are dropped — they describe bytes that never existed; - hop-by-hop headers are dropped; - for `1xx`, `204`, and `304` the body is dropped as well. When a rule is created from a captured request, the stored headers are limited to the ones the mock will really send: `Content-Encoding` and `Date` are left out, because the captured body is stored decompressed. ## Behaviour - If several rules match, the most recently created one wins. - A mocked request is still recorded, and is marked as answered by a rule. A rule's delay is visible while it waits. - Rules are resolved **before** breakpoints, so a URL covered by both is mocked and never pauses. - A delay holds up only its own request; concurrent requests are unaffected. If the client gives up during the delay, the request is recorded as failed. - The bypass list is checked first: a rule cannot answer a request to a host that is not being intercepted. ## Limitations - WebSocket upgrades and requests imported from a HAR file cannot be used to pre-fill a rule. - An SSE request can have a rule, but it answers the *next* request for that URL with an ordinary response rather than a stream, and nothing is pre-filled as its body. - Bodies over 8 MB are not opened for editing; they are kept as they are and can be saved to a file. A body with a single line longer than 128 KB is shown wrapped and read-only — the stored bytes stay unchanged either way. Rules and their bodies are kept in an encrypted local database. --- ## Breakpoints Source: https://solpuga.pro/en/docs/breakpoints A breakpoint stops a request as it passes through the proxy. A paused request can be edited and then sent on, or dropped; a paused response can be rewritten before the client ever sees it. Breakpoint rules and the queue of currently paused requests each have their own window, both reachable from the **Tools** menu (`Cmd/Ctrl + Shift + B` opens the queue). A rule can also be created from a captured request, which pre-fills it. ## Rules A rule decides what pauses. It is matched against the full URL with a wildcard or a regular expression — the same syntax as [Map Local](/docs/map-local) — and can be limited to specific methods, `CONNECT` included. Pausing can happen on the request, on the response, or on both: - **on the request** — before anything is sent to the server, so the outgoing request can be changed; - **on the response** — after the server answered, before the client receives it. A rule takes effect on new traffic as soon as it is saved, and can be switched off without being deleted. ## The queue Each paused request waits until you decide what happens to it, and shows how long it has been waiting. While it waits you can change the method, the headers, and the body of a request, or the status code, the headers, and the body of a response. Then it is either forwarded — with your edits — or dropped. The whole queue can be forwarded or dropped at once. Up to 50 requests can be paused at the same time. Beyond that, new matching requests pass through without pausing. ### When the client gives up A paused client can disconnect while it waits — a timeout, a reload, a closed tab. The request stays in the queue, marked as disconnected, so the captured data can still be inspected. It can also be sent to the server anyway: the response is then attached to the request and shown in the inspector. Turn on auto-forwarding under **Settings → Proxy** to have that happen without asking. Disconnected entries can be cleared out of the queue. ## Behaviour - A [Map Local](/docs/map-local) rule is resolved before breakpoints, so a URL covered by both is mocked and never pauses. - Hosts on the bypass list are never paused — they are tunnelled through without being read. - A paused request holds a real client connection open, so a long pause can hit the client's own timeout. - Quitting the app with requests still paused asks for confirmation first and forwards them, so no client is left hanging. Breakpoint rules are kept in the same encrypted local database as Map Local rules. --- ## Mobile Devices Source: https://solpuga.pro/en/docs/smartphones Traffic from iOS and Android — physical devices, the iOS Simulator, and Android emulators — can be captured too. Each target has its own wizard under the **SSL Certificate** menu, which does as much of the work as the platform allows and finishes by opening a test page on the device to confirm that the traffic arrives. Whatever the target, two things have to end up true: 1. The app's root CA is installed on the device **in a store the app you are debugging trusts**. 2. The device's traffic reaches the proxy — either because the device shares the computer's network stack, or because its proxy is pointed at the computer's address and port. Start the proxy before running a wizard: certificate delivery and the test both go through it. No root or jailbreak is required, but on Android an unrooted device limits which apps will trust the certificate. ## iOS Simulator Available on macOS, and requires **Xcode Command Line Tools** plus at least one booted simulator. The certificate goes into the macOS keychain and from there into every booted simulator; the simulators are then restarted so all processes pick up the new trust settings. A simulator uses the Mac's network stack, so there is no proxy to configure. If HTTPS requests still stay pending, enable trust by hand on the simulator: **Settings → General → About → Certificate Trust Settings**. - [Installation](https://media-solpuga.s3.cloud.ru/ru/ios_simulator.mp4) ## iOS Device Available on macOS. The device's Wi-Fi proxy has to be set **first**: **Settings → Wi-Fi → (i) next to your network → Configure Proxy → Manual**, with the computer's address on the local network and the proxy port (`7070` by default), authentication off. The certificate is then delivered either by scanning a QR code — the download address only resolves through the proxy, which is why the proxy comes first — or over AirDrop. On the device, the downloaded configuration profile has to be installed from **Settings**, and then trusted: **Settings → General → About → Certificate Trust Settings**. > Disable any VPN on both the Mac and the iOS device before setting the proxy. To undo it: remove the profile under **Settings → General → VPN & Device Management**, and remove the manual proxy from the Wi-Fi settings. ## Android Emulator Available on all supported platforms. Requires the **Android SDK** with **ADB** and a running emulator. The certificate is written into the emulator's system trust store, which needs a writable system partition, and the Android global HTTP proxy is pointed at `10.0.2.2` — the emulator's route back to your computer — on the proxy's port. Apps that were already running have to be restarted to pick the proxy up. Caveats: - **Google Play images are not supported** — their system partition is read-only. - The system certificate does not survive an emulator reboot; install it again afterwards. - On some systems the installation falls back to a terminal-based installer; on Windows that fallback needs **Git Bash** (Git for Windows). The wizard can also clear the proxy and remove the certificate again. - [Installation](https://media-solpuga.s3.cloud.ru/ru/android_emulator.mp4) ## Android Device Both a cable and a wireless setup are supported: over USB with **USB Debugging** enabled the proxy and the certificate can be pushed with ADB, while over Wi-Fi the proxy is set by hand and the certificate is downloaded by scanning a QR code. The manual proxy lives under **Settings → Network (or Internet) → your network → Modify → Advanced → Proxy → Manual**; the address and port are the computer's. Without Wi-Fi, `gnirehtet` can share the computer's connection over USB and the proxy is set to the same address and port. Which store the certificate goes into decides what will trust it: - **System store** — possible on a rooted device, via `adb remount`, and the device reboots afterwards. Trusted by all apps, including those targeting API 24+. - **User store** — the only option without root. Chrome and apps targeting API 24+ ignore user CAs unless the app itself permits them, which means repackaging the APK with a network security config that trusts user certificates. Installing a user certificate always has to be confirmed on the phone: on Android 11 and later from **Settings** (its search is the fastest route), on Android 10 and earlier through the install dialog that appears on the device. Platform notes: on Linux ADB may need `udev` rules for USB access; on Windows you may need the **Google USB Driver**. To undo it: remove the manual proxy from the device's Wi-Fi settings, remove the CA from Android's trusted credentials (user or system, wherever it was installed), and restart the device. ## If HTTPS still does not decrypt - The app uses certificate pinning. Add its host to the bypass list (see [Settings](/docs/settings)) so it keeps working, or debug a build that trusts your CA. - On Android, Chrome relies on its own root store for some checks — verify interception with your own app, not with Chrome. - The certificate is installed, but trust was never enabled (iOS), or it is in the user store while the app only trusts the system one (Android). - Still stuck on iOS specifically — see [iOS: profile installed, certificate not trusted](/errors/ios-trust-proxy-certificate). - Still stuck on Android specifically — see [Android does not trust the proxy certificate](/errors/android-network-security-config). --- ## Settings Source: https://solpuga.pro/en/docs/settings Settings open from the application menu (`Cmd/Ctrl + ,`) and are grouped into **Proxy**, **Startup**, **Bypass List**, **Advanced**, and **Account**. ## Proxy **Address.** Which interfaces the proxy listens on. `0.0.0.0` — all of them; this is the default and it is what mobile devices need in order to reach your computer. `127.0.0.1` — the local machine only. **Port.** Any port from `1` to `65535`; the default is `7070`. Ports below `1024` are privileged and need administrator rights or port forwarding. **Certificate.** The installed root CA with its issue and expiry dates and its trust status. It can be exported in PEM format or removed here — see [Certificate Installation](/docs/certificate). **Auto-forward disconnected requests.** When a client closes the connection while a request is paused on a [breakpoint](/docs/breakpoints), the captured request is sent to the server anyway and the response is attached to it. Off by default. ## Startup **Override the system proxy at launch.** Configure the system proxy as soon as the app starts. **Capture automatically.** Start capturing traffic as soon as the app starts. **Transparent mode** (Linux only) redirects all HTTP/HTTPS traffic through the proxy at the kernel level, so applications that ignore the system proxy settings — command-line tools, Flatpak apps — are captured too. It needs a one-time administrator approval, which installs a small root helper. Switching the option off stops the redirection but leaves the helper installed; removing the helper is a separate action in the same section. ## Bypass List Services that use certificate pinning do not work through HTTPS interception. A host on the bypass list is tunnelled through without being decrypted: it keeps working, and its traffic is still recorded, just not readable. Patterns can be a hostname (`example.com`), a wildcard (`*.example.com`), an IP address (`192.168.1.1`), or a CIDR range (`192.168.1.0/24`) — without `http://` or `https://`. Hosts can also be added and removed straight from captured traffic. ## Advanced **Strict TLS validation** makes the proxy validate upstream server certificates against standard WebPKI roots. It is off by default, so the proxy accepts upstream certificates without that validation — which is what you want when debugging staging servers with self-signed certificates. Turn it on for production-level strictness. The proxy has to be restarted for the change to take effect. ## Account The licence state on this device, and where it is activated — see [License and activation](/docs/license). ## Language, theme, updates The interface language (English or Russian) and the theme (system, light, or dark) are both under the **View** menu, and apply immediately. Updates are checked on every launch. A new version is announced in the main window, and installing it restarts the app. A manual check is available from the application menu. --- ## License and activation Source: https://solpuga.pro/en/docs/license A Pro license is activated per device by signing in to the account that owns it. ## Activation Buy a license on the site first. It is linked to the account you pay with, so create that account beforehand and use it at checkout. In the app, activation lives under **Settings → Account**: it opens your browser, you sign in there and pick the licence to use on this device, and the browser hands the result back to the app. From then on the device shows which plan and account it is licensed under and how long the licence runs, and it can re-check that against the licence server at any time. If you reinstall the app or move to a new computer, sign in to the same account again — the licence is there. ## How many devices A Pro licence can be active on several devices at the same time. The [pricing page](/pricing) states how many. The devices may run different operating systems: one licence covers macOS, Windows, and Ubuntu. To free a slot for a new device, release the old one — either on that device under **Settings → Account**, or from your [account pages](https://id.solpuga.pro/), which also list your licences and devices. Activation fails while every slot is taken. --- ## WebSocket closed with code 1006 — how to find the cause Source: https://solpuga.pro/en/errors/websocket-1006 ## What it means `1006` never travels over the network. [RFC 6455 § 7.4.1](https://datatracker.ietf.org/doc/html/rfc6455#section-7.4.1) forbids it in a Close frame, so your own WebSocket client fills it in when the connection died and no Close frame ever arrived. So it tells you one fact: nobody said goodbye. It does not tell you why. ## How to fix it Here is everything the event gives you: `event.code` is `1006`, `event.reason` is an empty string, `event.wasClean` is `false`. That is deliberate. The spec bans the browser from telling a script whether DNS failed, the port was shut, TLS broke or the handshake was refused, because otherwise any web page could scan your network. Do not mix it up with `1005`. There a Close frame **did** arrive, just without the two-byte status code, and `wasClean` is `true` once the closing handshake finished. Different problem, different place to look. There are four causes in practice. To your JavaScript all four look the same. Chrome's console says a little more: a failed handshake prints `Unexpected response code: 403`. Every variant of that message is in [Error during WebSocket handshake](/errors/error-during-websocket-handshake). **1. The handshake failed.** The server answered the `Upgrade` with something other than `101`: a `404`, a `403`, a `502`, a redirect. Browsers never follow a redirect here (the spec sets the request's redirect mode to `error`), so a `301` is just a failure. Check this first. It is plain HTTP and you can read all of it: ```bash curl -i -N --http1.1 --max-time 5 \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ https://example.com/ws ``` `--http1.1` is not optional. Without it curl negotiates HTTP/2 over TLS, the `Upgrade` headers are dropped, and you get a plain `200`, which is a fake answer. That `Sec-WebSocket-Key` is the sample value from the RFC: base64 of 16 bytes. Fine for a manual check, but [RFC 6455 § 4.1](https://datatracker.ietf.org/doc/html/rfc6455#section-4.1) requires a real client to generate a fresh random nonce every time. A server that speaks WebSocket answers it with `Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=`. For any other key the [Sec-WebSocket-Accept calculator](/tools/websocket-accept) gives the expected value. [`HTTP/1.1 101 Switching Protocols`](https://datatracker.ietf.org/doc/html/rfc9110#section-15.2.2) means the handshake works and the cause is elsewhere. Any other status is your answer. **2. An idle timeout on a proxy.** The default is 60 seconds nearly everywhere. nginx ships `proxy_read_timeout 60s`. An AWS ALB ships `idle_timeout` at 60 seconds. The tell is the clock: the `1006` lands on [the same second](/errors/stream-dies-after-60-seconds) every time. Raise it on the proxy: ```nginx location /ws { proxy_pass http://app; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } ``` Then add a heartbeat in the app, because the next proxy in the chain has a timeout of its own that you do not know about: ```js const ping = setInterval(() => { if (socket.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); }, 30_000); socket.addEventListener('close', () => clearInterval(ping)); ``` The server has to answer it. nginx's `proxy_read_timeout` only counts bytes coming **from** the server, so client pings alone fix nothing there. An ALB differs here: traffic in either direction resets it. And JavaScript cannot send the protocol's own Ping frame ([RFC 6455 § 5.5.2](https://datatracker.ietf.org/doc/html/rfc6455#section-5.5.2)), so the heartbeat must be an ordinary message both sides agree on. **3. The server process died.** A restart, the OOM killer, a deploy. The time is random and every connection drops at once. That is what tells it apart from a timeout. **4. The network changed.** Wi-Fi to cellular, a VPN, a laptop going to sleep. On phones this is the usual cause. There is nothing to fix, so just reconnect, with a backoff: ```js let attempt = 0; function connect() { const socket = new WebSocket(url); socket.addEventListener('open', () => { attempt = 0; }); socket.addEventListener('close', (e) => { if (e.code === 1000) return; // normal close, do not retry const delay = Math.min(30_000, 500 * 2 ** attempt++); setTimeout(connect, delay * (0.5 + Math.random())); }); } ``` The jitter is not optional. Without it a thousand clients dropped by one restart come back in the same millisecond and take the server down again. ## See it on your own traffic Causes 1 and 2 live in the HTTP exchange, and that is fully visible from the client side. Find the `Upgrade` request to `/ws` in the request list and read the status: a `101` means the handshake passed, any other status is your cause. If it is a `101`, compare how long each connection lived: the same ceiling every time means a proxy timeout. See [Capture & Inspect Traffic](/docs/traffic) for how Solpuga records a stream. --- ## Error during WebSocket handshake — what each message means Source: https://solpuga.pro/en/errors/error-during-websocket-handshake ## What it means A WebSocket starts as an HTTP request with the header `Upgrade: websocket`. The server must answer `101 Switching Protocols` with the headers `Upgrade`, `Connection` and `Sec-WebSocket-Accept` ([RFC 6455 § 4.2.2](https://datatracker.ietf.org/doc/html/rfc6455#section-4.2.2)). Chrome prints this error when the answer was anything else. DNS, the port and TLS are fine. If one of them had failed, the message would be `Error in connection establishment`. JavaScript sees only a `close` event with [code 1006](/errors/websocket-1006) and an empty reason. The cause is in the console, at the end of the message. ## How to fix it Read the text after `Error during WebSocket handshake:`. **`Unexpected response code: NNN`.** The status is not `101`: - `200`: a reverse proxy dropped the `Upgrade` header and the backend returned a normal page. Or the path is wrong and a single-page app returned `index.html`. - `400`, `426`: usually the same dropped header, but the backend refused the request. Socket.IO also answers `400` when the client and server versions do not match. - `403`: the server rejected the `Origin` header or the login. Many libraries accept only the server's own origin by default. - `404`: wrong path. `/ws` and `/ws/` can be different routes. - `301`, `302`: a redirect, often from `ws://` to `wss://` or to a trailing slash. Browsers do not follow it. Use the final URL. - `502`, `503`, `504`: the proxy works, the application behind it does not answer. nginx does not pass `Upgrade` to the backend by default. For `200` and `400`, check these lines first: ```nginx location /ws { proxy_pass http://app; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } ``` Send the same request with curl to read the full answer: ```bash curl -i -N --http1.1 --max-time 5 \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ https://example.com/ws ``` Keep `--http1.1`. Without it curl uses HTTP/2 where it can. HTTP/2 has no `Upgrade` header, so the server answers as it would to a normal request. To reproduce a `403`, add `-H "Origin: https://your-site.example"`: a browser always sends `Origin`, curl does not. **`Incorrect 'Sec-WebSocket-Accept' header value`**, or **`header is missing`.** The value must be the SHA-1 hash of the key plus a fixed GUID, in base64. Libraries get it right, so this is a hand-written server. The usual mistakes: the key is decoded from base64 before hashing, the hash is sent as hex, the GUID has a typo. Check with the [Sec-WebSocket-Accept calculator](/tools/websocket-accept). For the key in the curl command the correct value is `s3pPLMBiTxaQ9kYGzzhZRbK+xOo=`. **A message about `Upgrade`, `Connection` or `Sec-WebSocket-Protocol`.** The status is `101`, but a header is wrong: - `'Upgrade' header is missing`, `'Connection' header value must contain 'Upgrade'`: a hand-written server did not send these headers, or a proxy without WebSocket support removed them. - `Sent non-empty 'Sec-WebSocket-Protocol' header but no response was received`: you called `new WebSocket(url, ['v1'])` and the server chose no subprotocol. Browsers require it to return one of the names you offered. Choose one on the server or remove the second argument. - `Response must not include 'Sec-WebSocket-Protocol' header if not present in request`: the server returned a subprotocol the client did not ask for. **`net::ERR_…`.** The connection opened, but the reply was cut off or was not HTTP. Check the port in the URL and the server log. ## See it on your own traffic Find the `Upgrade` request to `/ws` in the request list and look at the status and the response headers. Compare `Sec-WebSocket-Key` in the request with `Sec-WebSocket-Accept` in the response. If the request has `Upgrade` and `Connection` and the answer is `200`, a proxy dropped them or the path is wrong. See [Capture & Inspect Traffic](/docs/traffic) for how Solpuga records a stream. --- ## Android does not trust the proxy certificate Source: https://solpuga.pro/en/errors/android-network-security-config ## What it means The certificate is installed, Android lists it in settings, Chrome on the phone works, and the app still dies with `SSLHandshakeException: Trust anchor for certification path not found`. A trust anchor is the root certificate that path validation starts from ([RFC 5280 § 6.1.1](https://datatracker.ietf.org/doc/html/rfc5280#section-6.1.1)), and the app accepts none here: an app built for **Android 7.0 (API 24)** or newer trusts only system certificate authorities and ignores everything the user added. That is deliberate: it stops anyone holding your phone from reading an app's traffic. ## How to fix it The app has to allow user certificates **itself**, and only in a debug build. Create `app/src/main/res/xml/network_security_config.xml`: ```xml ``` Point the manifest at it: ```xml ``` `` is the whole trick. The block applies **only** when the app is built with `android:debuggable="true"`. Gradle sets that flag on debug builds and never on release ones, so this file is safe to ship. Do not use a plain `` with `src="user"` instead. It works, and it works in release too, which is the exact hole Android closed. One host rather than all of them: ```xml api.example.com ``` This one cannot go inside ``, which accepts nothing but ``. So a `` is live in release builds as well. Keep it out of them by putting a separate copy of the file in `app/src/debug/res/xml/`: Gradle then uses it for the debug variant only. Two more things that trip people up: - The cutoff is the app's `targetSdkVersion`, not the phone's Android version. - The config is compiled into the APK. After editing it, **rebuild and reinstall** the app, because restarting it changes nothing. ## What this will not fix **Certificate pinning.** The app may check the fingerprint itself: OkHttp's `CertificatePinner`, a `` in the same config, a hand-written `TrustManager`. Then none of the above helps, because the app rejects a certificate the system already trusts. Pinning comes out in code (drop `CertificatePinner` in the debug variant), or it does not come out at all. **Flutter.** Dart has its own TLS stack. It ignores `network_security_config` and never looks at user-installed certificates. You need a `SecurityContext` carrying your CA, or `badCertificateCallback` in a debug build. **Somebody else's app.** No source, nowhere to put the config. Two options left: install the certificate as a system CA (needs root, plus a Magisk or KernelSU module such as `cert-fixer` or `AlwaysTrustUserCerts`), or repatch the APK with `apk-mitm`. **Android 14+.** The **system** CA store moved out of `/system/etc/security/cacerts` into the read-only `com.android.conscrypt` APEX, at `/apex/com.android.conscrypt/cacerts`. The old «drop a file into `/system`» tricks fail there, so pick a module that knows the APEX path. User certificates did not move. ## See it on your own traffic Once the app trusts the certificate, its requests show up in full: host, path, headers, request and response bodies. Before that Solpuga shows only a connection that opened and died at once, which is the sign that `network_security_config` has not taken effect yet. Setting the proxy on the phone is [a separate step](/errors/capture-phone-traffic). Full setup steps: [Mobile Devices](/docs/smartphones). --- ## iOS: profile installed, certificate not trusted Source: https://solpuga.pro/en/errors/ios-trust-proxy-certificate ## What it means The profile is installed, but Safari still says the connection is not private and the proxy stays empty. On iOS a root certificate takes two steps: install it, then trust it. Apple split those steps in iOS 10.3. Until you turn trust on by hand, the certificate does nothing. ## How to fix it Set the proxy in the Wi-Fi settings first. [A separate page](/errors/capture-phone-traffic) covers that. **Step 1. Install the profile.** Open the certificate URL in **Safari**. Other iOS browsers just save the file; Settings never offers to install it. Safari asks whether to allow the download. Allow it. Then: Settings → **«Profile Downloaded»** (near the top, under your account details) → Install → passcode → Install again → Done. Two limits here. A downloaded profile is deleted after 8 minutes if you do not install it, and only one profile can be pending at a time. If the row is gone, download the certificate again. **Step 2. Turn on full trust.** This is the step people miss. Settings → General → **About** → scroll to the bottom → **Certificate Trust Settings** → turn on the switch next to your certificate, under «Enable full trust for root certificates». iOS warns that this lets the certificate's owner read your traffic. The warning is correct. The owner is you. **Step 3. Check.** Open any https site in Safari. No warning, and requests appearing in the proxy, means you are done. ## Special cases **Before step 2 the profile says «Not Verified».** That red line under Settings → General → VPN & Device Management → your profile is normal. It turns to «Verified» once full trust is on. **There is no switch to turn on.** Two reasons. The certificate is not a root CA, so it has to be self-signed and carry `basicConstraints` with `cA` set ([RFC 5280 § 4.2.1.9](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.9)). Or it was installed by MDM or Apple Configurator: those are trusted on install and never get a switch. **A managed phone.** An MDM administrator can block profile installation entirely. Then step 1 fails and there is nothing to trust. **The simulator.** One command, no tapping: ```bash xcrun simctl keychain booted add-root-cert /path/to/ca.cer ``` That writes the certificate straight into the simulator's trust store, so step 2 is not needed. Dragging the `.cer` onto the simulator window also works, but then you still have to do step 2 inside the simulator. **An app that pins.** System trust means nothing to it. An app that checks the certificate itself will refuse the connection. Only a code change fixes that. **App Transport Security.** `NSAllowsArbitraryLoads` is a popular answer and a wrong one. ATS accepts a root certificate the user has given full trust to. Do not disable ATS for a proxy: in a release build you will have to justify it to App Review. **A wrong clock.** A certificate is only valid for a fixed window ([RFC 5280 § 4.1.2.5](https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.5)). If the phone's date is off, iOS reads the certificate as expired and refuses it even with trust on. Settings → General → Date & Time → Set Automatically. **An old certificate from another proxy.** Two root certificates are in the store and the phone may pick the wrong one. Remove the extra one: Settings → General → VPN & Device Management → the profile → Remove Profile. ## See it on your own traffic After step 2 the phone's requests arrive in full: host, path, headers, bodies. Before it you see only connections to port 443 that open and die: the app started the TLS handshake, rejected the certificate and left. Full setup steps: [Mobile Devices](/docs/smartphones). --- ## ERR_CERT_AUTHORITY_INVALID through a proxy — how to fix it Source: https://solpuga.pro/en/errors/err-cert-authority-invalid ## What it means Chrome could not build a chain from the site's certificate up to a root certificate it trusts. That walk is the path validation of [RFC 5280 § 6](https://datatracker.ietf.org/doc/html/rfc5280#section-6), and the trust anchor it starts from is an input to it ([§ 6.1.1](https://datatracker.ietf.org/doc/html/rfc5280#section-6.1.1)). Behind a debugging proxy the reason is almost always the same: the proxy signs certificates with its own certificate authority, and that CA's root certificate is not installed. Nearby errors are different problems: `ERR_CERT_COMMON_NAME_INVALID` is a name mismatch ([RFC 9110 § 4.3.4](https://datatracker.ietf.org/doc/html/rfc9110#section-4.3.4), which defers to [RFC 6125 § 6](https://datatracker.ietf.org/doc/html/rfc6125#section-6)), `ERR_CERT_DATE_INVALID` is the clock or the validity dates ([RFC 5280 § 4.1.2.5](https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.5)), and `ERR_CERT_REVOKED` means the certificate was revoked ([§ 6.3](https://datatracker.ietf.org/doc/html/rfc5280#section-6.3)). ## How to fix it Install the proxy's root certificate into the store that the browser actually reads. There is more than one store. **macOS.** Double-click the `.pem` or `.cer`. It lands in Keychain Access, usually in the login keychain. Then the second step: open the certificate, expand **Trust**, set «When using this certificate» to **Always Trust**. Without it the certificate is stored but not trusted. From a terminal, system-wide: ```bash sudo security add-trusted-cert -d -r trustRoot \ -k /Library/Keychains/System.keychain proxy-ca.pem ``` **Windows.** Run «Install Certificate» and change both defaults. The wizard opens on **Current User** and on «Automatically select the certificate store». Switch to **Local Machine**, then choose «Place all certificates in the following store» → **Trusted Root Certification Authorities**. Auto-select does not put it there. The same thing in PowerShell, run as Administrator: ```powershell Import-Certificate -FilePath .\proxy-ca.cer ` -CertStoreLocation Cert:\LocalMachine\Root ``` **Linux.** The paths differ per distribution. On Debian and Ubuntu the file must be PEM and must end in `.crt`, or `update-ca-certificates` skips it: ```bash # Debian / Ubuntu sudo cp proxy-ca.crt /usr/local/share/ca-certificates/proxy-ca.crt sudo update-ca-certificates # Fedora / RHEL sudo cp proxy-ca.pem /etc/pki/ca-trust/source/anchors/ sudo update-ca-trust ``` **Then restart the browser completely.** Chrome caches the verification result, so an open tab keeps showing the error. ## Why it may still not work **Firefox has its own store.** Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import. The `security.enterprise_roots.enabled` flag in `about:config` makes Firefox read the system store as well, but only on Windows and macOS. On Linux that flag does nothing, so import the file. **Chrome ships its own list of root certificates.** The Chrome Root Store is the default since Chrome 108 on Windows and macOS, 114 on Linux and ChromeOS, 115 on Android. It only replaces the list of public authorities: a certificate that you or an administrator added to the platform store is still trusted on a TLS connection. So the steps above work. **Node.js, Python, curl and Java do not look there at all.** Each has its own list: `NODE_EXTRA_CA_CERTS=/path/ca.pem`, `REQUESTS_CA_BUNDLE` or `SSL_CERT_FILE`, `curl --cacert ca.pem`, `keytool -importcert` into `cacerts`. A browser that started working proves nothing about them. **There is no «Proceed anyway» button.** Then the site uses HSTS: it is on the preload list, or it sent [`Strict-Transport-Security`](https://datatracker.ietf.org/doc/html/rfc6797#section-6.1) on an earlier visit. That button is missing by design, not by accident: [RFC 6797 § 12.1](https://datatracker.ietf.org/doc/html/rfc6797#section-12.1) is titled «No User Recourse» and tells the browser not to offer it. Install the certificate. ## See it on your own traffic A debugging proxy shows its own root certificate and exports it in one click, which is where the install starts. After that you see the whole exchange: request, response, body. Until it is installed you only see a TLS handshake that starts and dies, the same signature as [on a phone](/errors/capture-phone-traffic). Where the certificate ends up on each platform: [Certificate Installation](/docs/certificate). --- ## How to see the traffic from an iPhone or an Android phone Source: https://solpuga.pro/en/errors/capture-phone-traffic ## What has to be set up Two things. The proxy setting on the Wi-Fi network makes the phone send its requests through your computer. The root certificate lets your computer decrypt them. Miss the proxy and you see nothing; miss the certificate and you see connections open and die. ## How to do it **Step 1. One Wi-Fi network.** Phone and computer on the same network. Not a guest network, because guest access points isolate clients and the devices cannot see each other. Turn mobile data off on the phone. Both iOS and Android switch to cellular on their own when Wi-Fi looks broken to them. **Step 2. Find the computer's address.** ```bash # macOS — Wi-Fi is usually en0; try en1 if this prints nothing ipconfig getifaddr en0 # Linux ip route get 1.1.1.1 | awk '{print $7}' # Windows ipconfig | findstr IPv4 ``` You want a private address: `192.168.x.x`, `10.x.x.x`, or `172.16.x.x`–`172.31.x.x`. Those are the three blocks [RFC 1918 § 3](https://datatracker.ietf.org/doc/html/rfc1918#section-3) reserves. Not `127.0.0.1`. **Step 3. Set the proxy on the phone.** Address from step 2, port of the proxy. Solpuga listens on `7070`. Authentication off. *iOS:* Settings → Wi-Fi → ⓘ next to the network → Configure Proxy → Manual. *Android:* Settings → Network & internet → Internet → gear next to the network → pencil (edit) → Advanced options → Proxy → Manual. On older Android: long-press the network → Modify network. Now open any `http://` page on the phone. Nothing loads? A firewall on the computer is blocking the port; that takes ten seconds to settle, and the proxy is not the problem. It loads? The proxy already shows http requests and attempted https connections. **Step 4. Install the certificate.** The certificate is served through the proxy, so step 3 has to work first. In Solpuga the **SSL Certificate** menu gives you a QR code to scan, or sends the file over AirDrop. *iOS* needs two more steps and both are mandatory. Install the downloaded profile: Settings → General → VPN & Device Management → the entry under Downloaded Profile → Install. Then turn on full trust: Settings → General → About → Certificate Trust Settings. [The long version](/errors/ios-trust-proxy-certificate) walks through it. *Android* puts the certificate in the user store, and apps that target Android 7 (API 24) or later ignore that store. Chrome included. The fix lives in the app: a `network_security_config` that trusts user certificates. [The long version](/errors/android-network-security-config) covers it. ## What usually gets in the way **A firewall on the computer.** Windows blocks incoming connections by default. The macOS firewall is off out of the box, but switch it on or install a security tool and it blocks too. The check in step 3 catches both. **A VPN on the phone.** An active VPN takes the traffic, and the Wi-Fi proxy setting stops applying. Turn it off. **iCloud Private Relay on iOS.** Safari's traffic goes around the proxy. Fastest fix: turn off Settings → Wi-Fi → ⓘ → Limit IP Address Tracking. Globally: Settings → Apple ID → iCloud → Private Relay. **DNS over HTTPS** ([RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484))**.** It does not break capture, and the proxy still sees every request. It only adds connections to a resolver like `dns.google` or `cloudflare-dns.com`. Don't read those as app traffic. **An app that pins.** No certificate will ever work on it. Put its host in the bypass list and the app keeps running: the traffic is recorded, just not readable. To read it you need a build that trusts your CA. **QUIC and HTTP/3.** A Wi-Fi proxy only covers TCP. Anything speaking [QUIC](https://datatracker.ietf.org/doc/html/rfc9000) over UDP walks straight past it. In Chrome, turn it off at `chrome://flags/#enable-quic`; for other apps, block outbound UDP port 443 on the router. ## See it on your own traffic With all four steps in place, every request shows up: host, method, path, headers, body, response. Certificate installed and the browser on the computer still complains? That is [ERR_CERT_AUTHORITY_INVALID](/errors/err-cert-authority-invalid). The full reference — Android system vs. user store, Linux/Windows ADB notes — lives in [Mobile Devices](/docs/smartphones). --- ## SSE arrives in one chunk: nginx buffering Source: https://solpuga.pro/en/errors/sse-buffered-nginx ## What it means The server writes events one at a time. The browser gets them in a batch: thirty seconds of nothing, then twenty `message` events at once. `curl -N` on the same URL streams fine, so the bytes do leave the app and something between the app and the browser is holding them. That something is almost always nginx. ## How to fix it Three layers hold a stream. Check them in this order. **Layer 1: nginx.** `proxy_buffering` is `on` by default. nginx collects the whole response, frees the backend, then feeds the client at its own pace. For a stream that is exactly wrong: ```nginx location /events { proxy_pass http://app; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; proxy_read_timeout 1h; } ``` Line by line: - `proxy_buffering off` is the actual fix. Every chunk from the backend goes straight to the client. - `proxy_cache off` is already the default, but a cache inherited from a parent block would collect the stream. Say it here anyway. - `chunked_transfer_encoding on` restates the default, and it has to stay on. Copied configs often set it `off`; that breaks SSE. A stream has no [`Content-Length`](https://datatracker.ietf.org/doc/html/rfc9112#section-6.2), so [chunked encoding](https://datatracker.ietf.org/doc/html/rfc9112#section-7.1) is what frames it. - `proxy_read_timeout` defaults to `60s`. A stream that goes quiet dies at the minute mark. That is [a separate problem](/errors/stream-dies-after-60-seconds). - `proxy_http_version 1.1` plus `proxy_set_header Connection ''` fixes something else. nginx sends `Connection: close` to the backend by default, and old versions proxy over HTTP/1.0. These two let an `upstream … keepalive` pool reuse the connection, which is the persistence [RFC 9112 § 9.3](https://datatracker.ietf.org/doc/html/rfc9112#section-9.3) describes. They do not affect buffering. Cannot touch the config? Send a header from the application instead: ```http X-Accel-Buffering: no ``` nginx reads it and turns buffering off for that one response. This is the more portable option, because the header ships with the code. It only works if nginx is the proxy and `proxy_ignore_headers` is not dropping it. **Layer 2: compression** ([`Content-Encoding`](https://datatracker.ietf.org/doc/html/rfc9110#section-8.4))**.** nginx gzips only the types listed in `gzip_types`, and the default list is `text/html` alone, so SSE is usually safe. If somebody added `text/event-stream` (or `*`), gzip holds bytes until it has a block to emit. There is no way to subtract one type from the list, so turn gzip off in the SSE location: ```nginx gzip off; # inside the location that serves the stream ``` **Layer 3: the application.** Many frameworks hold the response until the handler returns. Send the headers first, then write each event as it happens. ```js // Node.js res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }); res.flushHeaders(); function send(data) { res.write(`data: ${JSON.stringify(data)}\n\n`); } ``` A blank line is what ends an event, so both `\n` are required: with one the browser keeps waiting. Node does not buffer `res.write`, but Express's `compression` middleware does: skip it for this route, or call `res.flush()` after every event. ```python # Flask return Response( generate(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) ``` `generate()` has to `yield` complete events (`"data: …\n\n"`) one by one, as they happen. Build a list first and you have written the buffering yourself. Gunicorn's sync worker is the other Python trap: it handles one connection at a time and kills the request at `--timeout`, 30 seconds by default. Use `gevent` or `eventlet` workers for WSGI, or run an ASGI app under `uvicorn`. ## Finding out which layer it is Two requests: ```bash # straight to the application, bypassing nginx curl -N http://127.0.0.1:8000/events # through nginx curl -N https://example.com/events ``` - First steady, second batched: nginx is buffering. - Both batched: the application is buffering. - Both steady, browser still waits: check `Content-Type`. `EventSource` needs exactly `text/event-stream`; on anything else it fires `error` and closes. ## See it on your own traffic In a `text/event-stream` response the timing matters as much as the body. A proxy shows the body filling in: events one at a time with gaps means the stream is alive, the whole body landing at once after forty seconds means something collected it. Same answer as the two `curl` runs, without a shell on the server. See [Capture & Inspect Traffic](/docs/traffic) for how Solpuga records a stream. --- ## The stream dies after 60 seconds: proxy_read_timeout Source: https://solpuga.pro/en/errors/stream-dies-after-60-seconds ## What it means The stream works, events arrive, then the connection closes, always on the **same second**, usually the sixtieth. Networks break at random. Timeouts fire on a schedule, so this is a timeout; the only question is whose. ## How to fix it Raise the timeout on every proxy you control. Defaults below. **nginx** sets `proxy_read_timeout` to 60 seconds. It counts the gap between two reads from your backend, not the age of the connection. So a stream with an event every ten seconds lives forever, and a stream that goes quiet for a minute dies. ```nginx location /events { proxy_pass http://app; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; send_timeout 3600s; } ``` Three directives, because they count three different things. `proxy_read_timeout` is a pause in the backend's response. `proxy_send_timeout` is a pause while writing to the backend. `send_timeout` is a pause while writing to the client. All three default to 60 seconds. **Kubernetes nginx ingress** takes the same numbers as annotations: ```yaml nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" ``` **AWS ALB** holds its `idle_timeout.timeout_seconds` attribute at 60 seconds. ```bash aws elbv2 modify-load-balancer-attributes \ --load-balancer-arn \ --attributes Key=idle_timeout.timeout_seconds,Value=3600 ``` **Apache** inherits `ProxyTimeout` from `Timeout`, which is 60 seconds in 2.4. Set `ProxyTimeout 3600`. **Envoy** deserves a second read. The route `timeout` is 15 seconds by default and it caps the **whole** response, not the pauses in it. A heartbeat cannot beat it. Set it to `0s` and lean on `stream_idle_timeout` instead, which is 5 minutes. **Cloudflare** returns 524 if your origin goes quiet for 125 seconds. Only Enterprise can raise that (up to 6000 s). Everyone else needs a heartbeat. **Heroku** is not configurable. Your app has 30 seconds to send something, then 55 seconds between every byte after that, or the router cuts the connection (H12, H15, H28). **gunicorn** sets `timeout` to 30 seconds, and it is not a proxy timeout: the master kills a worker that has produced nothing for that long, and with sync workers a long stream looks exactly like that. Use `gevent` or `eventlet` workers, or `--timeout 0`. **uvicorn** has no request timeout at all. `--timeout-keep-alive` (5 seconds) only covers idle keep-alive connections, so there is nothing here to blame for a cut stream. ## A heartbeat beats the setting You can only raise the timeout on proxies you know about. Between your nginx and the user's browser there may be a corporate proxy, a home router and a mobile carrier. Each has a timeout of its own, and you will never see their config. [RFC 9112 § 9.5](https://datatracker.ietf.org/doc/html/rfc9112#section-9.5) lets any of them drop an idle connection at any time. So the rule is: never go quiet for long. SSE has a comment line for this. It starts with a colon, produces no event, and the client ignores it. ```js const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000); req.on('close', () => clearInterval(heartbeat)); ``` Every 15 seconds, not every 59: timeouts in the proxy chain are sometimes shorter than a minute. The cost is eight bytes. One catch. A heartbeat only beats **idle** timeouts. A limit on total duration ends the response no matter how much data you send: that is what Envoy's route `timeout` does, and plenty of API gateways behave the same way. Those you have to raise by hand. While you are there, tell the client how long to wait before reconnecting. `EventSource` applies it on its own: ```text retry: 3000 ``` ## Telling a timeout apart from everything else | What you see | What it is | | --- | --- | | Cut on the same second every time | a timeout on one of the proxies | | Cut at a random time, every client at once | the application restarted or crashed | | Cut at a random time, one client | that client's network | | Data arrives in a batch, then a cut | [buffering](/errors/sse-buffered-nginx) plus a timeout | WebSocket does the same thing for the same reason. It just surfaces as [close code 1006](/errors/websocket-1006), which tells you nothing. ## See it on your own traffic You need two numbers: when the connection opened and when it closed. A proxy logs the duration of every request: a column full of exactly sixty seconds means a timeout. The same view shows whether data was still flowing before the cut: if the last event arrived at second fifty-nine, this is not a timeout and the application is where to look. See [Capture & Inspect Traffic](/docs/traffic) for how Solpuga records a stream. --- ## Replacement characters «�» in a stream: TextDecoder Source: https://solpuga.pro/en/errors/stream-replacement-characters ## What it means Events arrive, but some characters come out as `�`, which is U+FFFD, the replacement character. Short responses are clean, long ones get one or two per paragraph. The server's encoding is fine. The chunk boundary is not. ## How to fix it In UTF-8 every non-ASCII character takes 2 to 4 bytes ([RFC 3629 § 3](https://datatracker.ietf.org/doc/html/rfc3629#section-3) allows 1 to 4 octets per character). A chunk ends where TCP ended it, not where a character ended. Cut «я» in half and the first chunk ends with `0xD1`, the next starts with `0x8F`. Decode each chunk on its own and you get two `�`: one for the unfinished sequence, one for the continuation with no start. A split 4-byte emoji can give three. ASCII text never shows this, which is why the bug reaches production. The fix is one decoder for the whole stream plus `stream: true`: ```js const decoder = new TextDecoder(); // ONE for the whole stream const reader = response.body.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; handle(decoder.decode(value, { stream: true })); // ← the flag } const tail = decoder.decode(); // "" normally if (tail) console.warn('stream ended mid-character'); ``` `stream: true` makes the decoder hold back unfinished bytes until the next call. `new TextDecoder()` inside the loop defeats the point, because the state lives in the instance, and a fresh one remembers nothing. That last `decode()` with no arguments is not a flush of pending text. A finished character is handed over immediately, so there is never any text waiting. It returns `""` for a clean stream and `"�"` for one that was cut mid-character. It checks for truncation, nothing more. `TextDecoderStream` is shorter and harder to get wrong, and it does the same: ```js const reader = response.body .pipeThrough(new TextDecoderStream()) .getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; handle(value); } ``` `for await (const chunk of stream)` reads better, but async iteration over a `ReadableStream` is new: Chrome 124, Firefox 110, Safari 27. `TextDecoderStream` itself goes back to Safari 14.1, so the reader loop is the portable form. Node runs the same code, because `TextDecoder` is global there. The Node spelling of this bug is `chunk.toString()` on every chunk; `StringDecoder` from `node:string_decoder` is the older fix and keeps the same state. With `EventSource` the problem cannot happen: the browser decodes for you. It hits people reading SSE or NDJSON through `fetch`, which is everybody who needs headers or a POST body, neither of which `EventSource` supports. ## The other half of the same bug Chunk boundaries do not line up with **event** boundaries either. `data: {"a":1}\n\n` can arrive in two pieces, and «split the chunk on `\n\n`» loses half of it. The same trick applies: a buffer that outlives the chunk. ```js let buffer = ''; function handle(text) { buffer += text; if (buffer.endsWith('\r')) return; // \r or \r\n? wait for the next chunk buffer = buffer.replace(/\r\n|\r/g, '\n'); // SSE allows \n, \r\n and bare \r const parts = buffer.split('\n\n'); buffer = parts.pop() ?? ''; // the tail waits for next time for (const part of parts) emit(part); } ``` The `parts.pop()` is not optional. The last element is either an empty string (the chunk ended exactly on a separator) or an unfinished event. Both belong in the buffer, not in the handler. At `done`, emit whatever is still in it. Normalise line endings on the **buffer**, not on the chunk. `\r` and `\n` can land in different chunks, so a per-chunk `text.replace(/\r\n/g, '\n')` leaves a stray `\r` behind. That is the same boundary bug, one layer up. The BOM ([RFC 3629 § 6](https://datatracker.ietf.org/doc/html/rfc3629#section-6)) needs less work than people think. `TextDecoder` and `TextDecoderStream` strip a leading BOM already (`ignoreBOM` defaults to `false`, which means «skip it»). Strip it by hand only if you passed `ignoreBOM: true`, or decoded with Node's `toString('utf8')` or `StringDecoder`, which both keep it. A surviving `` turns the first field name into something the parser does not know, so the first `data:` line is silently dropped. ## See it on your own traffic A proxy shows the response body **as bytes**, not as a string the browser has already stitched together. You can see where the chunk boundary actually falls and whether it landed inside a character. That settles the question: bad encoding from the server, or a decoder that joined the chunks wrong. See [Capture & Inspect Traffic](/docs/traffic) for how Solpuga records a stream. --- ## The seventh tab never loads: the six-connection limit Source: https://solpuga.pro/en/errors/six-connection-limit ## What it means Your app is open in seven tabs. Six get data, the seventh hangs: blank screen, one pending request, nothing in the server log, because the request never left the browser. A browser opens at most six HTTP/1.1 connections per origin, and a long-lived stream like `EventSource` holds one of them for the whole life of the tab. ## How to fix it First, the numbers you are up against: - Six per origin in Chrome, Edge, Firefox and Safari. - Per **origin**, not per domain: scheme, host and port, as [RFC 6454 § 3.2](https://datatracker.ietf.org/doc/html/rfc6454#section-3.2) defines it. `https://example.com` and `https://api.example.com` get six each. - The six are shared by every tab in the browser profile. So a full pool does not only break the seventh tab. Nothing in any tab can reach that origin until a connection frees up. - No RFC sets the number. [RFC 9112 § 9.4](https://datatracker.ietf.org/doc/html/rfc9112#section-9.4) only says a client "ought to limit" its connections; the two-connection ceiling of [RFC 2616 § 8.1.4](https://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4) is gone. Six is a browser choice, found by experiment. **The real fix is HTTP/2.** It opens one connection per origin and carries requests as streams inside it. The server sets the stream cap through `SETTINGS_MAX_CONCURRENT_STREAMS` ([RFC 9113 § 6.5.2](https://datatracker.ietf.org/doc/html/rfc9113#section-6.5.2), which recommends no less than 100), and nginx allows 128 by default, so six streams is nothing. [HTTP/3](https://datatracker.ietf.org/doc/html/rfc9114) works the same way. No application code changes. Check whether it is on: ```bash curl -sI --http2 https://example.com/ | head -1 # HTTP/2 200 ``` In nginx 1.25.1 and newer: ```nginx listen 443 ssl; http2 on; ``` Older nginx: `listen 443 ssl http2;` This is why the bug looks like "only in production" or "only locally". The limit bites on whichever side still speaks HTTP/1.1, and that is usually the local dev server. HTTP/2 raises the ceiling, it does not remove it. Open more streams than the server's cap and they queue again, at 128 instead of 6. **No HTTP/2? One stream for all tabs.** A `SharedWorker` holds a single connection and fans events out: ```js // sse-worker.js const source = new EventSource('/events'); const ports = []; self.onconnect = (e) => { const port = e.ports[0]; ports.push(port); port.start(); }; source.onmessage = (event) => { for (const port of ports) port.postMessage(event.data); }; ``` ```js // in every tab const worker = new SharedWorker('/sse-worker.js'); worker.port.start(); worker.port.onmessage = (e) => handle(e.data); ``` `SharedWorker` is missing in Safari before 16: it was dropped in Safari 7 and came back in Safari 16.0. If older Safari is on your list, the fallback is `BroadcastChannel` plus a leader tab elected with `navigator.locks` (Safari 15.4+). **A WebSocket also sidesteps it.** WebSockets live in a separate pool, and the cap there is in the hundreds: 255 in Chrome, 200 in Firefox by default. One socket per tab is then fine. **The cheapest fix is to close the stream in a hidden tab:** ```js let source = new EventSource('/events'); document.addEventListener('visibilitychange', () => { if (document.hidden) source.close(); else source = new EventSource('/events'); }); ``` Crude, and note the gap: a fresh `EventSource` sends no `Last-Event-ID`, so events that fired while the tab was hidden are lost. Pass the last id yourself (`/events?from=`) if that matters. ## What does not fix it - **Sharding across subdomains** (`a.example.com`, `b.example.com`). On HTTP/1.1 it works and gives you six per origin, times the number of origins, but you pay a DNS lookup and a TLS handshake per shard, and you split cookies and cache. On HTTP/2 it buys nothing, and Firefox may coalesce the shards back onto one connection when they share an IP address and a certificate, which [RFC 9113 § 9.1.1](https://datatracker.ietf.org/doc/html/rfc9113#section-9.1.1) permits. - **Retries.** The request was not refused, it is queued. A retry joins the same queue. - **Server-side timeouts.** The server does not know these requests exist. ## See it on your own traffic A tab's devtools shows only that tab's requests, so the seventh tab gives you one row stuck in "Stalled" and no reason. A proxy sees every tab at once: six live connections to one host, and a seventh that never opened. See [Capture & Inspect Traffic](/docs/traffic) for how Solpuga records a stream. --- ## How to mock an API response without touching the backend Source: https://solpuga.pro/en/errors/mock-api-response ## When you need it You want to see how the app handles a response the backend will not give you: an empty list, a 500, two hundred items instead of three, a field that does not exist yet. The backend is off limits: it is someone else's, or it is being worked on, or it is production. There are four ways to do this, and they differ in **where** the swap happens, so each one reaches different traffic. ## Chrome DevTools: Local Overrides Fastest way for the web. Nothing to install. 1. DevTools → Network → right-click the request → **Override content**. 2. Pick a folder on disk and allow access to it. 3. The response opens in an editor. Edit it and save with `Ctrl/Cmd+S`. 4. Reload. That request now comes from your file. Two limits. You can override the body and the response headers, but **not the status code**, so you cannot turn a 200 into a 500 this way. And it works only in this browser, with DevTools open: close DevTools and the real response is back. ## MSW: mocking inside the application `msw` registers a Service Worker in the page. The app sends its requests as usual. Your code answers them. First copy the worker file into your public folder: ```bash npx msw init ./public --save ``` ```js // mocks/handlers.js import { http, HttpResponse } from 'msw'; export const handlers = [ http.get('/api/orders', () => HttpResponse.json({ items: [] }), // an empty list ), http.post('/api/orders', () => new HttpResponse(null, { status: 500 }), // a server error ), ]; ``` ```js // mocks/browser.js import { setupWorker } from 'msw/browser'; import { handlers } from './handlers'; export const worker = setupWorker(...handlers); ``` ```js // main.jsx — development only if (import.meta.env.DEV) { const { worker } = await import('./mocks/browser'); await worker.start(); } // render the app after this point ``` Wait for `start()` before the app renders. Requests sent earlier go to the real backend. Best option when the mock has to be **repeatable**: it is code, it lives in the repository, the whole team gets it, and tests reuse the same handlers through `setupServer` from `msw/node`. The limit: it covers your own web app only, not somebody else's and not a native client. ## An interceptor in the mobile client If the app is yours, mock it where the networking is. ```kotlin // Android, OkHttp val mockInterceptor = Interceptor { chain -> val request = chain.request() if (BuildConfig.DEBUG && request.url.encodedPath == "/api/orders") { Response.Builder() .request(request) .protocol(Protocol.HTTP_1_1) .code(500) .message("Mocked") .body("""{"error":"boom"}""".toResponseBody("application/json".toMediaType())) .build() } else { chain.proceed(request) } } val client = OkHttpClient.Builder() .addInterceptor(mockInterceptor) .build() ``` Keep the `BuildConfig.DEBUG` check. Without it the mock ships to users. The cost: you need the source, and every change needs a rebuild. ## A proxy: mocking outside the application A debugging proxy swaps the response **on the wire**, not inside the app. So it does not care whose app it is or what it is written in: a web page, a native mobile client, a CLI tool, somebody else's binary with no source. One rule says it all: for this URL, send this response, meaning status, headers and body, with a delay if you want one. A request that matches never reaches the network. It is the only one of the four that works when: - the app is not yours and cannot be rebuilt; - the traffic comes from [a phone](/errors/capture-phone-traffic) rather than a browser; - you need to show a colleague or a tester without making them set up an environment. One limit matters. An app that pins its certificate rejects the proxy's certificate, so the connection fails and you see nothing in that traffic, and change nothing either. ## Which to use | What you need | What to use | | --- | --- | | A one-off look in your own browser | Local Overrides | | A scenario the whole team and the tests share | MSW | | Mocking inside your own mobile client | an interceptor in code | | Somebody else's app, or a phone, without a rebuild | a proxy | ## See it on your own traffic In Solpuga a Map Local rule holds the whole response: status, headers and body. A request it answered is badged `Rule` in the list, so you can tell the rule fired. The usual mocking failure is a rule that never matched: the path is off, a query string was left in the pattern, or the app is showing cached data. Full reference: [Map Local](/docs/map-local). --- ## Migrating from Charles Proxy to Solpuga Source: https://solpuga.pro/en/migrate/charles ## What maps to what Charles puts everything under **Tools**; Solpuga has some of it under **Tools** and some in the request list itself. Where there is no equivalent, the cell is a dash. | Charles | Solpuga | | --- | --- | | Map Local | Map Local | | Breakpoints | Breakpoints | | SSL Proxying exclusions | Exclusion list | | Client Process | Originating application | | Export Session → HAR | HAR 1.2 export | | Import Session → HAR | HAR import, read-only | | The host tree | Grouping by host and application | | Filter | Search and filters | | Timing | Per-phase timing | | WebSocket | Partly: messages recorded, none sent | | Map Remote | — | | Rewrite | — | | Throttling | — | | Repeat, Repeat Advanced, Compose | — | | DNS Spoofing, Mirror, Auto Save, Block List | — | | HTTP/2 | — | | Scripting | — | ## Carrying sessions over as HAR Charles's own `.chlz` is readable by nothing else, so HAR is the route: Charles has exported it since 3.6, and Solpuga opens it. **In Charles.** `File` → `Export Session…` → format **HTTP Archive (.har)** → save. For part of a recording: select the requests and `File` → `Export Selected Session…`. **In Solpuga.** **Tools** → import HAR (`Cmd/Ctrl + Shift + I`), or open the `.har` from your file manager. It opens **in a separate read-only window** with its own list and inspector, so a live capture is untouched — you can read somebody else's session without stopping your own. One limit: files over 500 MB are refused. A day-long Charles session passes that easily, so export a selection rather than everything. ### What comes across Request and response headers, bodies, statuses and timings — everything HAR 1.2 describes. The imported window uses the same inspector as a live capture: JSON folds into a tree, images show their dimensions, binary shows as hex. Bodies and raw messages can be saved to files. ### What is lost These are limits of the **format**, not of anybody's implementation: - **Rules.** Map Local, Rewrite, breakpoints and filters are not in HAR at all — the format describes traffic, not settings. Rules have to be recreated, and the fastest way is from a captured request: the rule is filled in with the response that actually arrived. - **WebSocket.** HAR has nowhere to put it. - **Binary request bodies.** Solpuga leaves them out of its own export and flags them with a comment; other exporters have size limits of their own. - **Unfinished requests.** Solpuga's export omits them, and failed ones go out with status `0`. - **Double decompression.** Bodies in Solpuga's HAR are decrypted and decompressed, while the response headers are kept as they arrived. An importer that believes `Content-Encoding` will try to decompress a second time. Text opening as garbage is this, not a broken file. ## What you get instead An interface in English and Russian, and a free tier with no time limit. --- ## Migrating from Proxyman to Solpuga Source: https://solpuga.pro/en/migrate/proxyman ## What maps to what Proxyman and Solpuga both run on macOS, Windows and Linux. Where there is no equivalent, the cell is a dash. | Proxyman | Solpuga | | --- | --- | | Map Local | Map Local | | Breakpoint | Breakpoints | | Export → as HAR | HAR 1.2 export | | Opening a HAR by dragging it in | HAR import, read-only | | The host tree | Grouping by host and application | | The inspector | Request and response inspector | | The timeline | Per-phase timing | | Block List | Partly: the exclusion list only | | WebSocket | Partly: messages recorded, none sent | | Map Remote | — | | Scripting (Rewrite) | — | | Protobuf | — | | Diff | — | | External Proxying | — | | No Caching, Clear Cache | — | | HTTP/2 | — | | Command line | — | ## Carrying sessions over as HAR Proxyman both writes and reads HAR; so does Solpuga. Proxyman's own `Proxyman Log` has nowhere to go. **In Proxyman.** Right-click a request → `Export` → `as HAR (HTTP Archive)`. For a whole session, the same through the `File` menu. On macOS 5.20 and later there is also a command-line export, with a choice of format and domain. **In Solpuga.** **Tools** → import HAR (`Cmd/Ctrl + Shift + I`), or open the file from your file manager. It opens in a separate read-only window and leaves a live capture running. Files over 500 MB are refused. ### What comes across Headers, bodies, statuses and timings — everything HAR 1.2 describes. The imported window uses the same inspector as a live capture, and bodies and raw messages can be written out to files. ### What is lost - **Rules.** Map Local, Breakpoint, the lists — HAR describes traffic, not settings. Rules get recreated, fastest from a captured request, where the rule is filled in with the response that actually arrived. - **Scripts.** `Scripting` is not in HAR, and there is nowhere to carry it to — see below. - **WebSocket.** HAR has nowhere to put it. - **Protobuf.** The decoded view does not travel, and there is no decoder here: `application/protobuf` shows as a binary body in hex. - **Double decompression.** Bodies in Solpuga's HAR are decompressed while the response headers are kept as they arrived, so some importers try to decompress again. Garbage instead of text is that. ## What you get instead An interface in English and Russian, and a free tier with no time limit. --- ## Migrating from Fiddler to Solpuga: Classic and Everywhere Source: https://solpuga.pro/en/migrate/fiddler ## First: Classic or Everywhere **Fiddler Classic** — Windows only, and since 3 August 2026 licensed **for personal, non-commercial use only**: for commercial use Telerik points you at Everywhere. It is not in active development, with no commitment to releases, patches or support. It has FiddlerScript, AutoResponder, Composer, its own `.saz` archive, and HTTP/1.x. **Fiddler Everywhere** — Windows, macOS and Linux, by subscription, with HTTP/2. There is no FiddlerScript in it: a `Rules` tab replaces AutoResponder. If you are on Classic and the work is commercial, you have to move anyway and the only question is where. If you are on Everywhere, read the table below more carefully — Everywhere does more. ## What maps to what | Fiddler | Solpuga | | --- | --- | | AutoResponder (Classic) / Rules (Everywhere) | Map Local | | Breakpoints (`bpu`, `bpafter`) | Breakpoints | | Inspectors | Request and response inspector | | Statistics | Per-phase timing and body sizes | | Decryption skip rules | Exclusion list | | Export Sessions → HTTPArchive v1.2 | HAR 1.2 export | | Import Snapshot → `.har` | HAR import, read-only | | Filters | Search and filters | | WebSocket | Partly: messages recorded, none sent | | FiddlerScript (Classic) | — | | Composer | — | | Replay | — | | Reverse proxy | — | | HTTP/2 (Everywhere) | — | | Map Remote | — | | Slow-network emulation | — | | `.saz` | — | ## Carrying sessions over as HAR `.saz` is readable only by Fiddler, so HAR is the bridge. **In Fiddler Classic.** `File` → `Export Sessions` → `All Sessions` (or `Selected Sessions`) → format **HTTPArchive v1.2**. Take **v1.2**, not v1.1: v1.1 cannot store non-textual response bodies. And know about Classic's second limit: a `.har` is not compressed, so by default it stores non-textual content **only up to 32 kB**. Larger images and files will not be in the export — that is the exporter's setting, not Solpuga's. **In Fiddler Everywhere.** Export sessions as HAR from the `File` menu. **In Solpuga.** **Tools** → import HAR (`Cmd/Ctrl + Shift + I`), or open the file from your file manager. A separate read-only window; a live capture keeps running. Files over 500 MB are refused. ### What comes across Headers, bodies, statuses, timings. The imported window uses the same inspector as a live capture: JSON as a tree, images with dimensions, binary as hex; the raw message and the bodies can be written to files. ### What is lost - **AutoResponder and Rules.** HAR describes traffic, not settings. They get recreated; from a captured request the rule is filled in with the response that actually arrived. - **FiddlerScript.** Not in HAR, and nowhere to carry it to — see below. - **Non-textual bodies over 32 kB** — if the export came out of Classic with default settings. - **WebSocket.** HAR has nowhere to put it. - **Double decompression.** Bodies in Solpuga's HAR are decompressed while the response headers are kept as they arrived; an importer that trusts `Content-Encoding` will try again. ## What you get instead Coming from **Classic**: builds for macOS and Linux, not Windows only, and no restriction on commercial use. An interface in English and Russian, and a free tier with no time limit.