Repository navigation
fix(microvm-worker): wait at least 30 s after a suspend refused for now - #799
Merged
Merged
Conversation
The docs said that a first-attempt throttle or a clock offset is the normal case for a suspend refused for now. A probe of the default client showed otherwise. 1. The default client uses botocore's standard retry mode with 3 attempts. 2. That mode retries ThrottlingException. A 429 on every attempt ends with RetryAttempts 2. 3. An error after a retry is an unknown outcome. So a throttle from the default client usually ends as unknown, with the 30-second refusal. 4. botocore retries no signature or token error, also when the server's Date header is 10 minutes ahead. These errors end with RetryAttempts 0, so they stay refused for now. The README and the docstrings now state this. The create_microvms_client doc states that a client that retries must report RetryAttempts on its error. Without it, a retried throttle counts as a first attempt. A new test pins the default client's retries for these four errors.
After a suspend refused for now, the worker started the idle time again at once. Some of these errors repeat on every call. A wrong secret key gives a signature error on every call. A static session token gives ExpiredTokenException on every call after it expires. So an idle MicroVM made one SuspendMicrovm call per idle time, without a bound. With a 0.0001-second idle time, a probe counted 15 calls and 30 log lines in 3 seconds. The restart after a refusal for now now waits max(idle time, 30 s). The 30 s is SUSPEND_GRACE_SECONDS. The probe now counts 1 call in 3 seconds for each idle time. 1. The refused branch asks for the idle time with after_refusal=True. 2. The call is still counted as running at that point. So the request waits in _idle_after_call, and _idle_after_call_floor records the floor. 3. Each later request during the same call overwrites the floor. So the latest request decides. 4. A resume hook during the call comes before the refused outcome. So the floor applies. A job that ends after the outcome writes False. So the normal idle time applies. 5. Every other start, such as a job end or a resume hook after the refusal, uses the normal idle time. The warning is still logged once per refused call. The JavaScript worker (#1040) is being changed to the same rule.
The warning for a suspend refused for now said that the worker tries again after the next idle time, "at least 30 seconds". That promise was false in two cases. 1. A suspend hook can arrive during the call. The hook's refusal of jobs then stays, and the worker sets no 30-second floor. The worker still logged the same warning. 2. A job that ends after the refusal, or a resume hook after it, starts the normal idle time. So the next call can come sooner than 30 s. The worker now checks the suspend hook count first, and then logs one of two warnings. - Without a suspend hook during the call, the warning says that the next idle time lasts at least 30 seconds, unless a job or a resume hook starts the idle time again. - With a suspend hook during the call, the warning says that the hook's refusal stays. That refusal ends when the resume hook arrives, or 30 seconds after the suspend hook. The idle time then starts again. The tests now assert both texts in full. The JavaScript worker (#1040) is being changed to the same wording.
Two paths had no test. A probe in review showed the right behavior for both. 1. A resume hook arrives during the call, and a job starts. The call ends refused for now while the job runs. The job ends after the call. The worker makes no call while the job runs. It makes one call one normal idle time after the job ends. 2. A resume hook arrives during the call. A job starts and ends during the call. The call then ends refused for now. The refused outcome is the later request to start the idle time. So the 30-second floor applies.
1. README item 7 said that the JavaScript worker decides these cases by HTTP status. The JavaScript worker checks the error name first, as this worker does. The two differ in the default client. The JavaScript client retries a clock-skew error. So there, a clock-skew error usually ends as an unknown outcome. 2. README item 7 said that the worker makes at most one call per 30 seconds while the error lasts. A job that ends restarts the normal idle time. So the limit holds only while no job ends and no lifecycle hook arrives. The sentence now states that condition. 3. README item 7 said that only a resume hook during the call keeps the floor. A job that ends during the call keeps it too, because the refused outcome arrives after the job's end. The sentence now names both. 4. The create_microvms_client docstring said that each error reports its retries in ResponseMetadata.RetryAttempts. Only a ClientError carries ResponseMetadata. The docstring now says ClientError.
Item 7 of the listener docstring named only a resume hook during the refused call. The README also names a job that ends during the call. Both keep the 30-second wait, because the refused outcome arrives after them. So the docstring now names both.
…fused-for-now-floor
…fused-for-now-floor
The settle helper waited until no SuspendMicrovm call ran. It read the call count without the listener lock. 1. The call's thread lowers the count and then starts the idle time. Both steps run in one locked block. 2. So a reader without the lock can see the lower count before the idle timer exists. 3. settle then returned. The next advance moved the fake clock first. 4. A timer created after that move was due later by the amount of the move. So a test could see no call where it expected one. A 20 ms sleep between the two steps made 14 tests fail. settle now takes the listener lock after its wait, as finish_job does. With the same sleep, every test passes.
The worker logs a warning when its own SuspendMicrovm call fails. The service can reject the call, refuse it for now, or leave its outcome unknown. Some of these texts were false in rare sequences. 1. A suspend hook can arrive during the call, and its refusal can end before the call ends. A resume hook ends it, or its 30 seconds end. A refusal for now still logged that the hook's refusal stays. Jobs got HTTP 202. 2. A suspend hook alone can arrive during a rejected call. Its refusal then stays. The rejection still logged "It keeps running". Jobs got HTTP 503. 3. A terminate hook can arrive during the call. The worker then never calls again, and it refuses jobs for good. Each of the three texts still said that the worker goes on as before. 4. close() can come during the call. The worker then never calls again. The refusal for now still logged that it tries again. A new function _failed_call_warning chooses the text. Its conditions, their order, and its texts match failedCallWarning in the JavaScript worker (#1049). - The locked block that handles the outcome reads the terminate flag, the closed flag, the suspend hook count, and the refusal flag. It reads them before it changes them. - The warning is logged after that block. So the rejection warning now comes after the block too. - The refusal-for-now text without a hook now uses the JavaScript wording. It states the same facts. The behavior is unchanged. 19 new tests assert the HTTP statuses and the call times, and then the full text, logged once. On the previous worker.py, every new status and time assertion passes. Only the text assertions fail.
The info line "suspending the idle MicroVM" logged only idleSeconds. After a refusal for now, the wait before the next call lasts at least 30 seconds. So with a 1-second idle time, the line said idleSeconds: 1 for a call that came 30 seconds after the previous one. The line now also logs waitedSeconds. It is the delay that the idle timer waited. 1. _start_idle_time passes the delay that it chose to the timer's action. 2. The action passes it on to the thread that makes the call. 3. The call logs it next to idleSeconds. The JavaScript worker logs the same value as waitedMs. A new test checks both fields for a 1-second and a 45-second idle time.
README item 6 and rule 2 of classify_suspend_error gave a reason why missing credentials count as a rejection. The reason was that the call sends no request. 1. A call refused for now also does nothing at the service. 2. So that reason does not separate a rejection from a refusal for now. 3. The difference is whether a later call can succeed. A later call in the same MicroVM most likely finds no credentials either. Both texts now give that reason, as the JavaScript worker does.
README item 6 and item 6 of the listener docstring said that a rejected call ends the refusal at once. README item 7 said the same for a call refused for now. 1. A suspend hook can arrive during the call. Its refusal can still be in effect when the call ends. 2. The MicroVM is then suspending anyway. So the failed call does not end that refusal. 3. That refusal ends on the resume hook, or 30 seconds after the last suspend hook. The idle time then starts again. Both items now name this exception, as the JavaScript README does since #1049. Item 7 of the listener docstring got the same sentence in dadacce. Item 6 also said that the worker does not retry a rejected call until a job ends. A resume hook during the call is an exception too. The idle time then starts when the call ends. Item 6 now names it, as the JavaScript README does. Tests in dadacce check each of these rules. Two suspend hooks arrive 10 seconds apart. Jobs get HTTP 503 until the resume hook, or until 30 seconds after the second hook. The next call comes one idle time after that.
The "suspending the idle MicroVM" info line logs waitedSeconds. Only one test checked the field. That test covers a refusal for now, where the wait lasts at least 30 seconds. So a worker that logged the session's idle time as waitedSeconds failed one test case: idle-1s. A new test checks waitedSeconds after four other paths. Each path uses an idle time of 1 second. 1. A job ends after a refusal for now. The wait is 1 second. 2. A resume hook arrives after a refusal for now. The wait is 1 second. 3. The 30-second grace after an unknown outcome ends. The wait is 1 second. 4. A resume hook arrives during a call that ends refused for now. The refusal asks for the idle time last. So the floor applies, and the wait is 30 seconds. A mutation logged the session's idle time as waitedSeconds. Case 4 and idle-1s then failed. A second mutation logged at least 30 seconds on every call. All four new cases then failed. No worker code changed.
yaythomas
approved these changes
Oct 22, 2026
Contributor
Codex AI reviewNo actionable findings in the PR changes. Tests were inspected but not executed under the read-only constraints, leaving runtime concurrency and service-integration test risk. Reviewed commit |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #797.
Summary
After a suspend that the service refused for now, the MicroVM worker now waits at least 30 seconds before its next SuspendMicrovm call. The docs now say which errors reach "refused for now" with the default client. The JavaScript worker made the same change in aws/aws-durable-execution-sdk-js#1040.
The warnings for a failed SuspendMicrovm call now match the JavaScript worker after aws/aws-durable-execution-sdk-js#1049. The "suspending the idle MicroVM" line now logs
waitedSeconds.Why
ExpiredTokenExceptioneach time after it expires.autoSuspendIdleSeconds. So an idle MicroVM made one call per idle time, with no lower bound, and wrote two log lines per call.ThrottlingException. A probe showed that a 429 on every attempt ends withRetryAttempts2. So the error is classified unknown.Changes
The 30-second minimum
_start_idle_time(after_refusal=True)uses max(autoSuspendIdleSeconds, 30 s) for the next idle time._suspend_calls. So the request waits in_idle_after_call, and_idle_after_call_floorrecords the minimum._suspend_call_endedapplies it.resumehook or a job that ends during the call does not shorten the wait, because the refused outcome arrives after it.resumehook after the refusal starts the normal idle time.Warnings
A new function
_failed_call_warningchooses the warning for a failed call. Its conditions, their order, and its texts matchfailedCallWarningin the JavaScript worker (#1049), word for word.suspendhook arrived since the idle time ended, and whether a refusal of jobs is in effect. It reads them before it changes them.An unknown outcome, such as a timeout, a conflict, a server error, or an error after a retry:
terminatehook arrived: "the suspend call for the idle MicroVM had an unknown outcome. The MicroVM is terminating, so the worker refuses jobs and does not try the call again."A rejection, such as
AccessDeniedExceptionor missing credentials:terminatehook arrived: "could not suspend the idle MicroVM, because the service rejected the call. The MicroVM is terminating, so the worker does not try the call again. A missing permission needs lambda:SuspendMicrovm on the MicroVM image in its execution role."suspendhook arrived during the call, and its refusal is still in effect: "could not suspend the idle MicroVM, because the service rejected the call. A suspend hook arrived during the call, so the MicroVM is suspending anyway. The hook's refusal of jobs stays until the resume hook, or 30 seconds after the last suspend hook. A missing permission needs lambda:SuspendMicrovm on the MicroVM image in its execution role."close(): "could not suspend the idle MicroVM, because the service rejected the call. It keeps running. A missing permission needs lambda:SuspendMicrovm on the MicroVM image in its execution role."A refusal for now, such as expired credentials or a signature error:
terminatehook arrived: "could not suspend the idle MicroVM, because the service refused the call for now. The MicroVM is terminating, so the worker does not try the call again."close()came: "could not suspend the idle MicroVM, because the service refused the call for now. The worker is closed, so it does not try the call again."suspendhook arrived during the call, and its refusal is still in effect: "could not suspend the idle MicroVM, because the service refused the call for now. A suspend hook arrived during the call, so this call does not end the hook's refusal. The idle time starts again after the resume hook, or 30 seconds after the last suspend hook."suspendhook arrived during the call, and its refusal ended before the call ended, on aresumehook or after 30 seconds: "could not suspend the idle MicroVM, because the service refused the call for now. A suspend hook arrived during the call, and the hook's refusal of jobs ended before the call ended. So the worker accepts jobs. It tries the call again one idle time after the call and every job end."The earlier suspend-hook text said that the hook's refusal stays. That was false when the refusal had ended during the call: jobs got HTTP 202. The earlier texts also said that the worker goes on as before after a
terminatehook orclose(). The behavior is unchanged. Only the texts differ.Log line
The info line "suspending the idle MicroVM" logged only
idleSeconds. After a refusal for now, the wait before the next call lasts at least 30 seconds. So the line now also logswaitedSeconds, the delay that the idle timer waited._start_idle_timepasses that delay to the call. The JavaScript worker logs the same value aswaitedMs.Docs
SuspendOutcome,classify_suspend_error,create_microvms_clientand the listener say that the default client retries a throttle. So a throttle usually ends as unknown. "Refused for now" covers expired credentials and a signature error, with or without a clock offset. A throttle counts only from a client that does not retry it.create_microvms_clientoption doc says that a caller client that retries must report its retries inResponseMetadata.RetryAttemptson eachClientError. Otherwise a retried throttle counts as a first attempt.classify_suspend_errorgive the JavaScript reason why missing credentials count as a rejection: a later call in the same MicroVM most likely finds no credentials either.suspendhook whose refusal is still in effect when the call ends keeps that refusal. It ends on theresumehook, or 30 seconds after the lastsuspendhook. README item 6 and the listener docstring also name theresumehook during a rejected call: the worker then retries one idle time after the call ends.Testing
worker.pyandself_suspend.pyare at 100 % line and branch coverage. Typecheck,hatch fmt --checkand the commit lint pass.resumehook that follows the refusal;resumehook, or a job that starts and ends, during the call;terminatehook during the call, also after asuspendhook: jobs get 503, also after aresumehook, and no call follows;close()during the call: jobs get 202, and no call follows;close()and aterminatehook: the terminating text;suspendhooks 10 s apart during a rejected or refused call: jobs get 503 until theresumehook, or until 30 s after the second hook, and the next call comes one idle time later;suspendhook whose refusal ended during the call: jobs get 202, and the next call comes one idle time after the call or after the job.idleSecondsandwaitedSecondsfor a 1-second and a 45-second idle time after a refusal for now.waitedSecondson four other paths, with a 1-second idle time:resumehook after a refusal for now: 1 s;resumehook during a call that ends refused for now: 30 s, because the refusal asks for the idle time last.worker.py: every status and timing assertion passes. Only the text andwaitedSecondsassertions fail.suspendingcheck removed,closedchecked beforeterminating,terminatingchecked after the hook,waitedSecondstaken from the idle time. Reading the refusal flag after the block passes every test. That mutation does not change the result, because the block changes the flag only when nosuspendhook arrived.waitedSecondstest, the mutation that takeswaitedSecondsfrom the idle time fails 2 test cases: the 1-second refusal and theresumehook during a refused call. A mutation that logs at least 30 s on every call fails all 4 cases of the second test.settlenow takes the listener lock after its wait, asfinish_jobdoes. A 20 ms sleep inside the listener's call-end block made 14 tests fail without this change, and none with it.