Skip to content

Arsalaerfan/rate limiting#80

Open
arsalaerfan wants to merge 6 commits intomainfrom
arsalaerfan/rate-limiting
Open

Arsalaerfan/rate limiting#80
arsalaerfan wants to merge 6 commits intomainfrom
arsalaerfan/rate-limiting

Conversation

@arsalaerfan
Copy link
Collaborator

API rate limiting

Added rate limiting for Canvas API calls using the requests-ratelimiter library. Created a CanvasClient class in src/utils/rate_limiting/canvas_api.py that wraps all HTTP requests with a LimiterSession, which uses a leaky bucket algorithm to automatically throttle requests to 10/second (configurable via CANVAS_RATE_LIMIT_PER_SECOND).

Also added rate limiting for SendGrid and Google Sheets APIs using pyrate-limiter with manual acquire/block pattern, since their SDKs handle HTTP calls internally and cannot be wrapped with LimiterSession from requests-ratelimiter.

Issues Fixed

Tests

Each client includes a minor throttle test script that verifies rate limiting is active by sending rapid requests and measuring throughput:

  • canvas: python -m src.utils.rate_limiting.canvas_minor_stress_testing
  • sengrid: python -m src.utils.rate_limiting.sendgrid_minor_stress_testing
  • gspread:python -m src.utils.rate_limiting.gspread_minor_stress_testing
Screenshot 2026-02-06 at 6 13 23 PM Screenshot 2026-02-06 at 6 13 44 PM Screenshot 2026-02-06 at 6 13 57 PM

Notes

For this PR, only the check-activity endpoint has been updated to use the new canvas_client. Other endpoints using Canvas/sendgrid/gspread API calls can be migrated in future PRs.

Notes on Library changes

  • Rate limiting wrapper for requests library (includes pyrate-limiter as a dependency)
requests-ratelimiter==0.8.0
Screenshot 2026-02-06 at 6 32 39 PM

Checklist

  • I've reviewed the contribution guide
  • I've confirmed the changes work on my machine
  • This pull request is ready for review

Copy link
Owner

Choose a reason for hiding this comment

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

Thanks for making the rate limits adjustable from the environment. The more values that keep getting added to the config.py, the more I'm favor of breaking it up, so that config files are closer to where they'll get used. Not for this PR, but worth noting.

print(f"Status: {response.status_code}")
print(f"User: {response.json().get('name')}\n")

# Test rate limiting is active
Copy link
Owner

Choose a reason for hiding this comment

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

Rate limiting test code is pretty similar across the 3 clients. There might be a DRY method on encapsulating the timing logic and passing the actual test as a function parameter. Not critical, but worth noting in case we want to adjust the timing logic or re-use it elsewhere.


REQUEST_COUNT = 15
TEST_SHEET_KEY = "17gjWh6YdrrX5r_ci9kF6COFSzxehKt53nMZPG5uTdp0"
CREDENTIALS_FILE = "gspread_credentials.json"
Copy link
Owner

Choose a reason for hiding this comment

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

I'm aware these files aren't necessarily the focus of the PR, but it's concerning to have values from the env file leak (even if private keys weren't leaked). These should ideally be references to the settings object from the config file. I'm assuming these are temporary, but I can't merge the PR with this present.

self.gc = self.create_credentials_from_env()
return self.gc

def create_credentials_from_env(self) -> gspread.Client:
Copy link
Owner

Choose a reason for hiding this comment

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

Note that create_credentials in the gsheet/utils.py does the same thing; I'd prefer to not have duplicate functions so that function should be used instead. Again, since this isn't the focus of the PR, I'm assuming that this is not meant to be the final implementation and is here for testing, but I'm wary of formally merging.

self.limiter.try_acquire("gsheets")
break
except BucketFullException:
time.sleep(0.1)
Copy link
Owner

Choose a reason for hiding this comment

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

A bit odd to have a hard-coded value here, ideally this is changeable from either the function call or config. Not part of this PR, but it might be interesting to use a dynamic wait system to help predict when to check.

Additionally, I'm concerned about the infinite loop. It's not clear what exceptions limiter.try_acquire can produce or what's expected in this context. Ideally, there's another exit point (TimeOutException) to prevent this. Another issue preventing me from merging to main.

return response

if attempt < self.max_retries:
time.sleep(2 ** attempt)
Copy link
Owner

Choose a reason for hiding this comment

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

This also shouldn't be hard-coded value, since we may want to tweak it in the future (or possibly try to implement the same sleep time algorithm across each client. Not necessary to change for this PR, but something to consider in the follow-up.

@NickGuerrero
Copy link
Owner

The new code, especially regarding the Canvas client, looks good. The rate limiting appears to be implemented correctly, and the defaults represent what's listed in Canvas. Tests pass, but I've yet to test it properly in a deployment. The main issues preventing me from merging are

  • The gspread and sendgrid code could use some clean-up. I'd prefer if they weren't included in this PR.
  • The infinite loops without a clear exit could pose a problem

An explanation could resolve both of them (possibly in today's meeting), but some changes may be needed.

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.

2 participants