Outbound Http #
Use TimedHttpClient to call external services from your custom services and plugins. It records request timing metrics, pools connections, throws typed exceptions on failure, and redacts the secrets you declare from those exceptions. Wrap it in ConcurrencyLimitedHttpClient to cap how many requests run at once.
Create a client #
Construct a TimedHttpClient once, when your service or plugin is created, and reuse it for every request. Each client owns its own
connection pool and threads, so it can make concurrent requests; do not create one per request.
import pragma.http.TimedHttpClient
//...
private val httpClient = TimedHttpClient("mystudio.outgoing.http", service)
The first argument is the name of the timer metric recorded for each request. Each recording is tagged with method, result (the response status code, or -1 when there was no response), and failure_reason.
Clients use the following defaults:
| Setting | Value |
|---|---|
| Connect timeout | 10 seconds |
| Socket timeout | 35 seconds |
| Request timeout | 40 seconds |
| Automatic retries | None. Each call makes one attempt. |
| Error statuses | Throw ResponseException |
Make a request #
TimedHttpClient has get, post, put, patch, and delete helpers. Pass query parameters, headers, and an auth provider as named arguments:
import io.ktor.client.statement.bodyAsText
//...
val response = httpClient.get(
"https://api.example.com/v1/players",
parameters = mapOf("playerId" to playerId),
headers = mapOf("Accept" to "application/json"),
)
val body = response.bodyAsText()
Set expectSuccess = false to receive error-status responses as an HttpResponse instead of an exception.
Handle failures #
A request fails in one of two ways:
| Failure | Exception |
|---|---|
| The server responds with a 4xx or 5xx status, or a redirect that isn’t followed | Ktor’s ResponseException (ClientRequestException, ServerResponseException, or RedirectResponseException). e.response holds the status, headers, and body. |
| No response, such as a timeout, DNS failure, refused connection, or TLS error | pragma.http.HttpRequestFailedException. failureReason says why. |
Coroutine cancellation propagates unchanged as CancellationException.
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.ResponseException
import pragma.http.HttpFailureReason
import pragma.http.HttpRequestFailedException
//...
try {
httpClient.get("https://api.example.com/v1/players/$playerId")
} catch (e: ClientRequestException) {
// 4xx: the request was rejected
} catch (e: ResponseException) {
// Other error statuses
} catch (e: HttpRequestFailedException) {
when (e.failureReason) {
HttpFailureReason.CONNECT_TIMEOUT, HttpFailureReason.REQUEST_TIMEOUT -> { /* retry later */ }
else -> { /* fail the call */ }
}
}
failureReason values match the failure_reason metric tag:
| Value | Tag | Cause |
|---|---|---|
CONNECT_TIMEOUT | connect_timeout | The connection wasn’t established in time. |
SOCKET_TIMEOUT | socket_timeout | No data was received in time on an open connection. |
REQUEST_TIMEOUT | request_timeout | The whole request exceeded its time limit, including time waiting for a pooled connection. |
UNKNOWN_HOST | unknown_host | The host name didn’t resolve. |
LOCAL_BIND_FAILED | local_bind_failed | A local socket couldn’t be bound, typically because the node ran out of ephemeral ports. |
CONNECT_FAILED | connect_failed | The connection was refused or the host was unreachable. |
TLS_ERROR | tls_error | The TLS handshake failed. |
CONNECTION_CLOSED | connection_closed | The connection closed before a response arrived. |
IO_ERROR | io_error | Another I/O failure. |
UNEXPECTED | unexpected | A failure that isn’t an I/O error. |
Successful requests and error-status responses are tagged none; the result tag carries the status.
Redact secrets from exceptions #
Ktor includes the request Url and the response body in its exception messages, so an Api key or player account Id sent as a query parameter or path segment reaches your logs when the request fails. Declare those values in sensitiveValues, keyed by a label:
val response = httpClient.get(
"https://api.example.com/v1/players/$providerAccountId",
parameters = mapOf("key" to apiKey.getDecryptedValue()),
sensitiveValues = mapOf(
"exampleApiKey" to apiKey.getDecryptedValue(),
"providerAccountId" to providerAccountId,
),
)
In this example providerAccountId is redacted as it might be PII, but it can be very useful to know which players an error is effecting. What to redact is a judgment call between security and diagnostics.
When the request fails, each declared value, raw or Url-encoded, is replaced with <<label>> in the thrown exception:
ResponseException: the request Url and response text in the message are redacted. The exception keeps its Ktor type, ande.responsestill returns the original status, headers, and body.HttpRequestFailedException: every message in the cause chain is redacted.
Notes:
- Only declared values are redacted. An undeclared secret in the Url reaches the logs.
- Values sent only in headers, including the
authprovider, or in the request body don’t appear in exception messages and don’t need declaring. - Redaction doesn’t cover what you log yourself, such as
e.response.bodyAsText()or an Id interpolated into a log message. - Error-status exceptions include the full response body in their message, untruncated. Values in the body are redacted only if you declared them, so data the external service returns, such as a player’s email address, is logged as-is.
Limit concurrent requests #
ConcurrencyLimitedHttpClient wraps a TimedHttpClient and caps the number of requests in flight. Calls over the cap wait for a permit. Use it to protect an external service with rate limits or to bound the load a burst of traffic sends downstream.
import pragma.http.ConcurrencyLimitedHttpClient
import pragma.http.TimedHttpClient
import pragma.utils.MetrickingPragmaSemaphore
//...
private val httpClient = ConcurrencyLimitedHttpClient(
TimedHttpClient("mystudio.outgoing.http", service),
MetrickingPragmaSemaphore(
10,
"mystudio.outgoing.http.concurrency",
service.metricsManager(),
"MyStudioHttpClient",
),
)
The arguments to MetrickingPragmaSemaphore are the initial permit count, the name of a gauge metric reporting permits in use, the metrics manager, and a logger name. ConcurrencyLimitedHttpClient has the same request helpers as TimedHttpClient, including sensitiveValues.
To change the cap at runtime, for example from your config’s onConfigChanged, call setMaxConcurrentRequests. Requests already in flight release against the previous limit, so concurrency can briefly exceed the new cap.
httpClient.setMaxConcurrentRequests(config.maximumConcurrentRequests)
Engine partner clients #
Game-to-social calls and calls to a dedicated telemetry backend use the same concurrency limit. Set the cap with maximumConcurrentRequests, which is hot-reloadable:
game:
serviceConfigs:
SocialBackendPartnerClientConfig:
maximumConcurrentRequests: 10
TelemetryBackendClientConfig:
maximumConcurrentRequests: 10
| Setting | Default | Description |
|---|---|---|
SocialBackendPartnerClientConfig.maximumConcurrentRequests | 10 | The maximum number of concurrent requests from a game node to the social backend. |
TelemetryBackendClientConfig.maximumConcurrentRequests | 10 | The maximum number of concurrent requests to the dedicated telemetry backend. Also configurable on the social backend. |