It runs on my machine 03
October 19, 2025
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:
- Extract HTTP logic to a dedicated class (
InfillHttpClient
) that only handles raw HTTP requests - Create a result type (
HttpResponse
) that represents success vs. error responses - Keep JSON parsing in Service - that's application logic, not HTTP logic
- Let HTTP exceptions bubble up - connection failures are exceptional, not normal flow
- 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:
- 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.
- Read from errorStream for non-2xx responses: HTTP servers should put error details in the error stream, not the output stream.
- 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.