It runs on my machine 03

Previously

In the previous post, I got the plugin to make real HTTP requests to a llama.cpp server and receiving completions. The code worked, but it was all crammed into the Service class: HTTP connection management, JSON parsing, error handling, notifications, everything.

Time to clean up the mess.

The problem with the current code

Let me look at what I had in Service.kt:

private suspend fun getCompletion(prefix: String, suffix: String): String {
    return withContext(Dispatchers.IO) {
        try {
            val settings = Settings.getInstance()
            val state = settings.state
            val endpointUrl = state.endpointUrl

            // Create the HTTP connection
            val url = URI(endpointUrl).toURL()
            val connection = url.openConnection() as HttpURLConnection
            connection.requestMethod = "POST"
            connection.setRequestProperty("Content-Type", "application/json")
            connection.doOutput = true

            // Add API key if configured
            if (state.apiKey.isNotEmpty()) {
                connection.setRequestProperty("Authorization", "Bearer ${state.apiKey}")
            }

            // Build the request body
            val requestBody = JSONObject()
            requestBody.put("input_prefix", prefix)
            requestBody.put("input_suffix", suffix)

            // Send the request
            val writer = OutputStreamWriter(connection.outputStream)
            writer.write(requestBody.toString())
            writer.flush()
            writer.close()

            // Read the response
            val responseCode = connection.responseCode
            if (responseCode == HttpURLConnection.HTTP_OK) {
                val response = connection.inputStream.bufferedReader().use { it.readText() }
                val jsonResponse = JSONObject(response)
                val content = jsonResponse.optString("content", "")

                logger.info("Got completion: $content")
                content
            } else {
                logger.warn("HTTP request failed with code: $responseCode")
                showErrorNotification("Failed to get completion: HTTP $responseCode")
                ""
            }
        } catch (e: Exception) {
            logger.warn("Failed to get completion", e)
            showErrorNotification("Failed to connect to LLM endpoint: ${e.message}")
            ""
        }
    }
}

This function does everything: HTTP, JSON, error handling, logging, notifications. And what if I want to reuse this HTTP logic elsewhere? Copy-paste? No thanks.

The plan

I asked myself: what should this refactoring look like?

After some thinking, I settled on:

  1. Extract HTTP logic to a dedicated class (InfillHttpClient) that only handles raw HTTP requests
  2. Create a result type (HttpResponse) that represents success vs. error responses
  3. Keep JSON parsing in Service - that's application logic, not HTTP logic
  4. Let HTTP exceptions bubble up - connection failures are exceptional, not normal flow
  5. Test the HTTP client thoroughly using a real test HTTP server

The HTTP client should know nothing about JSON, completions, or IntelliJ. It just sends bytes over the wire and tells you what came back.

Here's what the refactoring looks like (credit to AI for this piece of ASCII art, I would not have done it manually):

Before:
┌─────────────────────────────────────┐
│          Service.kt                 │
│                                     │
│  ┌───────────────────────────────┐  │
│  │ HTTP connection management    │  │
│  │ JSON parsing                  │  │
│  │ Error handling                │  │
│  │ Notifications                 │  │
│  │ Logging                       │  │
│  │ Settings access               │  │
│  │ ... everything mixed ...      │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

After:
┌─────────────────────────────────────┐
│          Service.kt                 │
│  (Application Logic)                │
│                                     │
│  • JSON parsing                     │
│  • Notifications                    │
│  • Logging                          │
│  • Settings access                  │
│                                     │
│         │                           │
│         ▼                           │
│  ┌─────────────────────┐            │
│  │ InfillHttpClient    │            │
│  │ (HTTP Only)         │            │
│  │                     │            │
│  │ • POST requests     │            │
│  │ • Headers           │            │
│  │ • Timeouts          │            │
│  │ • Cancellation      │            │
│  └─────────────────────┘            │
│         │                           │
│         ▼                           │
│  ┌─────────────────────┐            │
│  │   HttpResponse      │            │
│  │  (Result Types)     │            │
│  │                     │            │
│  │ • Success (2xx)     │            │
│  │ • Error (others)    │            │
│  └─────────────────────┘            │
└─────────────────────────────────────┘

An aside: among all the good things one can do with AI, custom ASCII art is still the one that amazes me the most.

Building a response type

First, I needed a way to represent HTTP responses. I wanted to distinguish between successful responses (2xx status codes) and error responses (everything else).

In Kotlin, sealed classes are perfect for this:

sealed class HttpResponse(
    open val statusCode: Int,
    open val body: String,
    open val headers: Map<String, List<String>>
) {
    data class Success(
        override val statusCode: Int,
        override val body: String,
        override val headers: Map<String, List<String>>
    ) : HttpResponse(statusCode, body, headers)

    data class Error(
        override val statusCode: Int,
        override val body: String,
        override val headers: Map<String, List<String>>
    ) : HttpResponse(statusCode, body, headers)
}

What are "sealed classes"? They do not really have a corresponding in PHP. They are like abstract in the sense that they must be extended; they are like final classes in the sense that they cannot be extended. Differently from PHP the final* comes with a condition that says "it cannot be extended save for X and Y".
Here the HttpResponse class can only be extended by the Success and Error classes.

This gives me type-safe pattern matching when handling responses. The compiler will remind me if I forget to handle one of the cases. I also chose to use a sealed class (not interface) with a base constructor so I could have common properties like statusCode, body, and headers available on all responses without duplicating them (although the kotlin grammar immediately makes it more verbose than required...).

Here's how the sealed class hierarchy works:

                    ┌──────────────────────────────┐
                    │      HttpResponse            │
                    │    (Sealed Base Class)       │
                    │                              │
                    │  • statusCode: Int           │
                    │  • body: String              │
                    │  • headers: Map<String, ...> │
                    └──────────────┬───────────────┘
                                   │
                   ┌───────────────┴───────────────┐
                   │                               │
                   ▼                               ▼
    ┌──────────────────────────┐   ┌──────────────────────────┐
    │   HttpResponse.Success   │   │   HttpResponse.Error     │
    │                          │   │                          │
    │  When statusCode is 2xx  │   │  When statusCode is not  │
    │  (200, 201, 204, etc.)   │   │  2xx (404, 500, etc.)    │
    │                          │   │                          │
    │  Inherits all properties │   │  Inherits all properties │
    │  from HttpResponse       │   │  from HttpResponse       │
    └──────────────────────────┘   └──────────────────────────┘

When you pattern match:
  when (response) {
    is HttpResponse.Success -> // Compiler knows it's 2xx
    is HttpResponse.Error   -> // Compiler knows it's error
    // Compiler error if you forget a case!
  }

Did I mention AI does ASCII art really well?

Putting the HTTP client together

Now for the HTTP client itself. I wanted:

  • Constructor takes the URL (can be changed later with setUrl())
  • post() method for making POST requests
  • Returns HttpResponse.Success for 2xx, HttpResponse.Error for other status codes
  • Throws exceptions for connection failures
  • Automatically cancels previous request. This is important as completion requests will come in bursts as I type and there's no use in getting a completion for a request that will never be used.

Here's what I came up with:

class InfillHttpClient(
    private var url: String,
    private val connectTimeoutMs: Int = 5000,
    private val readTimeoutMs: Int = 30000
) {
    private var currentConnection: HttpURLConnection? = null

    fun getUrl(): String = url
    fun setUrl(newUrl: String) { url = newUrl }

    fun cancelPreviousRequest() {
        currentConnection?.disconnect()
        currentConnection = null
    }

    fun post(headers: Map<String, String>, body: String): HttpResponse {
        // Cancel any previous request
        cancelPreviousRequest()

        val uri = URI(url).toURL()
        val connection = uri.openConnection() as HttpURLConnection
        currentConnection = connection

        try {
            connection.requestMethod = "POST"
            connection.connectTimeout = connectTimeoutMs
            connection.readTimeout = readTimeoutMs
            connection.doOutput = true

            // Set headers
            headers.forEach { (key, value) ->
                connection.setRequestProperty(key, value)
            }

            // Write request body
            val writer = OutputStreamWriter(connection.outputStream)
            writer.write(body)
            writer.flush()
            writer.close()

            // Get response
            val responseCode = connection.responseCode
            val responseHeaders = connection.headerFields
                .filterKeys { it != null }
                .mapKeys { it.key!! }

            // Read response body (from either inputStream or errorStream)
            val responseBody = if (responseCode in 200..299) {
                connection.inputStream.bufferedReader().use { it.readText() }
            } else {
                connection.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
            }

            return if (responseCode in 200..299) {
                HttpResponse.Success(responseCode, responseBody, responseHeaders)
            } else {
                HttpResponse.Error(responseCode, responseBody, responseHeaders)
            }
        } finally {
            if (currentConnection == connection) {
                currentConnection = null
            }
            connection.disconnect()
        }
    }
}

The key decisions:

  1. Store the current connection: This lets me cancel it if a new request comes in while the old one is still running. Critical for inline completions where the user might type quickly.
  2. Read from errorStream for non-2xx responses: HTTP servers should put error details in the error stream, not the output stream.
  3. Return result types, throw for real failures: If I get a 404, that's an HttpResponse.Error. If the network is down, that's an exception.

Testing with an actual server

Here's where it got interesting. I wanted to test this HTTP client properly, which meant I needed... an HTTP server. Not a mock, but an actual server that accepts connections and sends responses.

Turns out the JDK ships with com.sun.net.httpserver.HttpServer - a simple HTTP server perfect for testing. I wrapped it in a helper class:

class TestHttpServer(private val port: Int = 0) {
    private var server: HttpServer? = null
    private var responseHandler: ((HttpExchange) -> Unit)? = null

    fun start() {
        server = HttpServer.create(InetSocketAddress(port), 0)
        server?.createContext("/") { exchange ->
            responseHandler?.invoke(exchange) ?: run {
                exchange.sendResponseHeaders(200, 0)
                exchange.close()
            }
        }
        server?.executor = null
        server?.start()
    }

    fun stop() {
        server?.stop(0)
        server = null
    }

    fun getUrl(): String {
        val actualPort = server?.address?.port
            ?: throw IllegalStateException("Server not started")
        return "http://localhost:$actualPort"
    }

    fun respondWith(statusCode: Int, body: String, headers: Map<String, String> = emptyMap()) {
        responseHandler = { exchange ->
            headers.forEach { (key, value) ->
                exchange.responseHeaders.set(key, value)
            }
            val responseBytes = body.toByteArray()
            exchange.sendResponseHeaders(statusCode, responseBytes.size.toLong())
            exchange.responseBody.write(responseBytes)
            exchange.close()
        }
    }

    // ... more helper methods
}

Using port = 0 tells the OS to pick any available port, which is perfect for tests - no conflicts.

Now I could write real tests:

class InfillHttpClientTest : BasePlatformTestCase() {
    private lateinit var server: TestHttpServer
    private lateinit var client: InfillHttpClient

    override fun setUp() {
        super.setUp()
        server = TestHttpServer()
        server.start()
        client = InfillHttpClient(server.getUrl())
    }

    override fun tearDown() {
        try {
            server.stop()
        } finally {
            super.tearDown()
        }
    }

    fun testSuccessfulPostRequest() {
        server.respondWith(200, """{"content": "hello world"}""",
            mapOf("Content-Type" to "application/json"))

        val response = client.post(
            headers = mapOf("Content-Type" to "application/json"),
            body = """{"input_prefix": "test", "input_suffix": ""}"""
        )

        assertTrue(response is HttpResponse.Success)
        assertEquals(200, response.statusCode)
        assertEquals("""{"content": "hello world"}""", response.body)
        assertTrue(response.headers.isNotEmpty())
    }

    fun testErrorResponse() {
        server.respondWith(404, """{"error": "Not found"}""")

        val response = client.post(
            headers = mapOf("Content-Type" to "application/json"),
            body = """{"input_prefix": "test"}"""
        )

        assertTrue(response is HttpResponse.Error)
        assertEquals(404, response.statusCode)
    }

    fun testConnectionFailure() {
        server.stop()

        try {
            client.post(
                headers = mapOf("Content-Type" to "application/json"),
                body = """{"test": "data"}"""
            )
            fail("Expected an exception to be thrown")
        } catch (e: Exception) {
            // Expected - connection failed
        }
    }
}

The BasePlatformTestCase class is the PHPUnit\Framework\TestCase equivalent of testing things in the context of an IntelliJ plugin.

The header bug

Of course, the first time I ran these tests, they failed. Naturally. The testSuccessfulPostRequest was failing because response.headers didn't contain "Content-Type".

I spent a few minutes debugging before realizing: I was calling exchange.responseHeaders.add(key, value) after exchange.sendResponseHeaders(). HTTP doesn't work that way - headers have to be set before you start sending the body. It's literally in the name. Headers. First. Body. Second. Fun fact: that is how headers and body work in PHP as well.
You'd think after all these years of web development I'd know better, but here we are.

Changed it to:

headers.forEach { (key, value) ->
    exchange.responseHeaders.set(key, value)  // BEFORE sendResponseHeaders!
}
val responseBytes = body.toByteArray()
exchange.sendResponseHeaders(statusCode, responseBytes.size.toLong())

And the tests passed. This is exactly why you write tests - you catch these silly mistakes before they become production bugs.

Progress.

Refactoring Service to use InfillHttpClient

With the HTTP client tested and working, I could refactor Service:

class Service : InlineCompletionProvider {
    private val logger = Logger.getInstance(Service::class.java)
    private var httpClient: InfillHttpClient? = null

    // ... other methods ...

    private suspend fun getCompletion(prefix: String, suffix: String): String {
        return withContext(Dispatchers.IO) {
            try {
                val settings = Settings.getInstance()
                val state = settings.state
                val endpointUrl = state.endpointUrl

                // Initialize or update HTTP client
                if (httpClient == null) {
                    httpClient = InfillHttpClient(endpointUrl)
                } else if (httpClient!!.getUrl() != endpointUrl) {
                    // Udpate the URL if it changed in the settings.
                    httpClient!!.setUrl(endpointUrl)
                }

                // Build headers
                val headers = mutableMapOf("Content-Type" to "application/json")
                if (state.apiKey.isNotEmpty()) {
                    headers["Authorization"] = "Bearer ${state.apiKey}"
                }

                // Build the request body
                val requestBody = JSONObject()
                requestBody.put("input_prefix", prefix)
                requestBody.put("input_suffix", suffix)

                // Make the HTTP request
                val response = httpClient!!.post(headers, requestBody.toString())

                // Handle the response
                when (response) {
                    is HttpResponse.Success -> {
                        val jsonResponse = JSONObject(response.body)
                        val content = jsonResponse.optString("content", "")
                        logger.info("Got completion: $content")
                        content
                    }
                    is HttpResponse.Error -> {
                        logger.warn("HTTP request failed with code: ${response.statusCode}")
                        showErrorNotification("Failed to get completion: HTTP ${response.statusCode}")
                        ""
                    }
                }
            } catch (e: Exception) {
                logger.warn("Failed to get completion", e)
                showErrorNotification("Failed to connect to LLM endpoint: ${e.message}")
                ""
            }
        }
    }
}

Much cleaner! The HTTP logic is gone. The when expression for handling HttpResponse is type-safe and explicit. And the best part? All existing tests still pass - I didn't break anything.

Next steps

The plugin now has a solid HTTP foundation. But there's more to do:

  • The completion logic is still naive - I'm sending the entire file as prefix/suffix
  • The request is not properly configured
  • There is no context to the request if not for the surrounding lines

But those are problems for future posts. For now, the code is cleaner, tested, and ready to build on.