Skip to content
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

Merged
merged 18 commits into from
Dec 21, 2022
Merged

Conversation

aajtodd
Copy link
Contributor

@aajtodd aajtodd commented Dec 16, 2022

Issue #

#122

Description of changes

  • feat: Implements the runtime design for interceptors
  • refactor: Make HttpRequest an interface and allow HttpRequestBuilder 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)
    • This is somewhat fallout from the fact that 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.

@aajtodd aajtodd marked this pull request as ready for review December 19, 2022 13:36
@aajtodd aajtodd requested a review from a team as a code owner December 19, 2022 13:36
val requestCopy = request.copy(body = reqBody)
val requestCopy = HttpRequest(method = request.method, url = request.url, headers = request.headers, body = reqBody)
Copy link
Contributor

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()?

Copy link
Contributor Author

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.

Comment on lines +86 to +94
// 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
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤷

Comment on lines +86 to +94
// 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
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that makes sense.

Comment on lines +96 to +102
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) }
Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stupid ktlint!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stupid ktlint!

Comment on lines +96 to +100
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
}
Copy link
Contributor

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
}

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

Comment on lines 187 to 199
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()
}
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Comment on lines 39 to 42
internal data class ImmutableHttpRequestBuilder(
internal val builder: HttpRequestBuilder,
internal val allowToBuilder: Boolean,
) : HttpRequest {
Copy link
Contributor

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?

Comment on lines 45 to 50
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()
Copy link
Contributor

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?

Comment on lines 45 to 49
/**
* Convert an outcome to a [Result]
*/
@InternalApi
public fun <T> Outcome<T>.toResult(): Result<T> = when (this) {
Copy link
Contributor

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).

Comment on lines 45 to 47
private var _url: Url? = null
override val url: Url
get() = _url ?: builder.url.build().also { _url = it }
Copy link
Contributor

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() }

@sonarqubecloud
Copy link

Kudos, SonarCloud Quality Gate passed!    Quality Gate passed

Bug A 0 Bugs
Vulnerability A 0 Vulnerabilities
Security Hotspot A 0 Security Hotspots
Code Smell A 20 Code Smells

No Coverage information No Coverage information
1.7% 1.7% Duplication

@aajtodd aajtodd merged commit d8aa7b5 into feat-interceptors Dec 21, 2022
@aajtodd aajtodd deleted the impl-interceptors branch December 21, 2022 20:54
aajtodd added a commit that referenced this pull request Jan 6, 2023
@aajtodd aajtodd mentioned this pull request Jan 6, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants