-
Notifications
You must be signed in to change notification settings - Fork 28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(rt): implement interceptors #769
Conversation
…ng a builder as an immutable view of HttpRequest
val requestCopy = request.copy(body = reqBody) | ||
val requestCopy = HttpRequest(method = request.method, url = request.url, headers = request.headers, body = reqBody) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Is it worth supporting a copy
method on HttpRequest
or its implementations? Or would this be better written as request.toBuilder().build()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could add one, we only had one or two usages of it though so I opted to just do it manually.
// This changes as execution progresses, it represents the most up-to-date version of the operation input. | ||
// If we begin executing an operation at all it is guaranteed to exist because `readBeforeExecution` is the first | ||
// thing invoked when executing a request | ||
private var _lastInput: I? = null | ||
|
||
// Track most up to date http request and response. The final two hooks do not have easy access to this data | ||
// so we store it as execution progresses | ||
private var _lastHttpRequest: HttpRequest? = null | ||
private var _lastHttpResponse: HttpResponse? = null |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Why the underscore prefixes on these variables?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤷
// This changes as execution progresses, it represents the most up-to-date version of the operation input. | ||
// If we begin executing an operation at all it is guaranteed to exist because `readBeforeExecution` is the first | ||
// thing invoked when executing a request | ||
private var _lastInput: I? = null | ||
|
||
// Track most up to date http request and response. The final two hooks do not have easy access to this data | ||
// so we store it as execution progresses | ||
private var _lastHttpRequest: HttpRequest? = null | ||
private var _lastHttpResponse: HttpResponse? = null |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Is multithreaded access a concern here? Should these be wrapped in atomics?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
InterceptorExecutor
is scoped to a single operation. All the hooks should fire from one thread (may not be the same thread for the entirety of the operation because coroutines can switch after suspension points but only one thread should ever be interacting with it).
Is there a scenario where you see this as not holding true?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, that makes sense.
private fun <T> checkType(phase: String, expected: KClass<*>, actual: Any): T { | ||
check(expected.isInstance(actual)) { "$phase invalid type conversion: found ${actual::class}; expected $expected" } | ||
@Suppress("UNCHECKED_CAST") | ||
return actual as T | ||
} | ||
private fun <T> checkResultType(phase: String, result: Result<Any>, expected: KClass<*>): Result<T> = | ||
result.map { checkType(phase, expected, it) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Style: Missing empty line between multi-line member definitions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stupid ktlint!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stupid ktlint!
private fun <T> checkType(phase: String, expected: KClass<*>, actual: Any): T { | ||
check(expected.isInstance(actual)) { "$phase invalid type conversion: found ${actual::class}; expected $expected" } | ||
@Suppress("UNCHECKED_CAST") | ||
return actual as T | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: You can use contracts to indicate that a non-exceptional return implies narrowed type information for actual
. This would help avoid recasts in the calling method:
@ExperimentalContracts
private inline fun <reified T> checkType(phase: String, expected: KClass<*>, actual: Any): T {
contract {
returns() implies (actual is T)
}
check(expected.isInstance(actual)) { "$phase invalid type conversion: found ${actual::class}; expected $expected" }
@Suppress("UNCHECKED_CAST")
return actual as T
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oddly enough I had this at one point. We can add it back but in most cases it doesn't improve the calling method due to how they are structured (e.g. compiler is smart enough inside of fold{}
blocks but outside of them it sees Any
again). I removed it when I had to revisit how type checking was done, I can add it back.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having issues getting this to work. I'm going to leave it for now.
override suspend fun call(request: OperationRequest<Input>): Output { | ||
val result = interceptors.readBeforeExecution(request.subject) | ||
.mapCatching { | ||
inner.call(request) | ||
} | ||
.let { | ||
interceptors.modifyBeforeCompletion(it) | ||
} | ||
|
||
interceptors.readAfterExecution(result) | ||
|
||
return result.getOrThrow() | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: I looks like a failure in readAfterExecution
will override any failures in previous interceptors. Should they be added as suppressed exceptions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I wasn't sure. I can see it either way. Do you have a strong opinion one way or another here?
internal data class ImmutableHttpRequestBuilder( | ||
internal val builder: HttpRequestBuilder, | ||
internal val allowToBuilder: Boolean, | ||
) : HttpRequest { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: This class doesn't feel like a "builder". It's not externally mutable and may be turned into a builder by the toBuilder()
extension method. Maybe it's an ImmutableHttpRequest
or an HttpRequestBuilderView
?
private var _url: Url? = null | ||
override val url: Url | ||
get() = _url ?: builder.url.build().also { _url = it } | ||
|
||
override val headers: Headers | ||
get() = builder.headers.build() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Is there a reason to memoize url
but not headers
?
/** | ||
* Convert an outcome to a [Result] | ||
*/ | ||
@InternalApi | ||
public fun <T> Outcome<T>.toResult(): Result<T> = when (this) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment: While we created this specifically for retry middleware, it seems generally useful and I can't think of a reason it shouldn't be public
(non-@InternalApi
).
private var _url: Url? = null | ||
override val url: Url | ||
get() = _url ?: builder.url.build().also { _url = it } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
override val url: Url by lazy { builder.url.build() }
Kudos, SonarCloud Quality Gate passed! 0 Bugs No Coverage information |
Issue #
#122
Description of changes
HttpRequest
an interface and allowHttpRequestBuilder
to implement it. This allows for the COW (copy-on-write) semantics we want for interceptor modify hooks but is cheap for all the read hooks (and if the modify hooks do nothing)HttpRequestBuilder
is what flow through our operation middleware. We don't make it immutable until we are ready to send it to the engine usually.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.